mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-12 16:00:27 +00:00
Compare commits
45 Commits
feat/secur
...
v4.15.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38f6dad118 | ||
|
|
c3b29f1ee6 | ||
|
|
3e85d18ab5 | ||
|
|
df48c01ce4 | ||
|
|
209776a9e8 | ||
|
|
09dae7269a | ||
|
|
2dcf8004ab | ||
|
|
74b1da67d8 | ||
|
|
78ac36f670 | ||
|
|
74781fda0a | ||
|
|
3bead89d46 | ||
|
|
a4527fd8ca | ||
|
|
52b6627ebd | ||
|
|
a5769b6a62 | ||
|
|
d9297c1e10 | ||
|
|
94f07ab98b | ||
|
|
01a6594707 | ||
|
|
82a7154b92 | ||
|
|
9b385ac9c9 | ||
|
|
e3d4cee416 | ||
|
|
f6c79370cb | ||
|
|
39460e4acb | ||
|
|
f971c312b9 | ||
|
|
2c3a304440 | ||
|
|
286b0e03f7 | ||
|
|
447f86e2b5 | ||
|
|
0592f1a99a | ||
|
|
90e3936204 | ||
|
|
1239f622d2 | ||
|
|
d511e2bb3f | ||
|
|
ff93aa3dc7 | ||
|
|
cc8891b6a1 | ||
|
|
7c65b1eaf1 | ||
|
|
ebe3e9c63c | ||
|
|
d33a872c42 | ||
|
|
9377dc3d52 | ||
|
|
17322bb5a4 | ||
|
|
c0bcced5fb | ||
|
|
805c1d5ea2 | ||
|
|
b3399b07ad | ||
|
|
71f8504849 | ||
|
|
3b7ca1a08f | ||
|
|
57f3c4dd31 | ||
|
|
5b20ebb7b0 | ||
|
|
3a3eaeec7c |
54
.github/workflows/release.yml
vendored
54
.github/workflows/release.yml
vendored
@@ -125,18 +125,54 @@ jobs:
|
|||||||
cd "$TMPDIR"
|
cd "$TMPDIR"
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 1) 下载 QQ x64
|
# 1) 下载 QQ x64 (使用缓存)
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# JS_URL="https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
# JS_URL="https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
||||||
# JS_URL="https://slave.docadan488.workers.dev/proxy?url=https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
# JS_URL="https://slave.docadan488.workers.dev/proxy?url=https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
||||||
# NT_URL=$(curl -fsSL "$JS_URL" | grep -oP '"ntDownloadX64Url"\s*:\s*"\K[^"]+')
|
# NT_URL=$(curl -fsSL "$JS_URL" | grep -oP '"ntDownloadX64Url"\s*:\s*"\K[^"]+')
|
||||||
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/eb263b35/QQ9.9.23.42086_x64.exe"
|
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/32876254/QQ9.9.27.45627_x64.exe"
|
||||||
QQ_ZIP="$(basename "$NT_URL")"
|
QQ_ZIP="$(basename "$NT_URL")"
|
||||||
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
|
# 根据 URL 生成缓存键
|
||||||
|
QQ_CACHE_KEY="qq-x64-$(echo "$NT_URL" | md5sum | cut -d' ' -f1)"
|
||||||
|
echo "QQ_CACHE_KEY=$QQ_CACHE_KEY" >> $GITHUB_ENV
|
||||||
|
echo "QQ_ZIP=$QQ_ZIP" >> $GITHUB_ENV
|
||||||
|
echo "NT_URL=$NT_URL" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Cache QQ x64 Installer
|
||||||
|
id: cache-qq
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/qq-cache
|
||||||
|
key: ${{ env.QQ_CACHE_KEY }}
|
||||||
|
|
||||||
|
- name: Download and Extract QQ x64
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
cd "$TMPDIR"
|
||||||
|
|
||||||
|
QQ_CACHE_DIR="$HOME/qq-cache"
|
||||||
|
mkdir -p "$QQ_CACHE_DIR"
|
||||||
|
|
||||||
|
if [ -f "$QQ_CACHE_DIR/$QQ_ZIP" ]; then
|
||||||
|
echo "Using cached QQ installer: $QQ_ZIP"
|
||||||
|
cp "$QQ_CACHE_DIR/$QQ_ZIP" "$QQ_ZIP"
|
||||||
|
else
|
||||||
|
echo "Downloading QQ installer: $QQ_ZIP"
|
||||||
|
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
|
||||||
|
cp "$QQ_ZIP" "$QQ_CACHE_DIR/$QQ_ZIP"
|
||||||
|
fi
|
||||||
|
|
||||||
QQ_EXTRACT="$TMPDIR/qq_extracted"
|
QQ_EXTRACT="$TMPDIR/qq_extracted"
|
||||||
mkdir -p "$QQ_EXTRACT"
|
mkdir -p "$QQ_EXTRACT"
|
||||||
7z x -y -o"$QQ_EXTRACT" "$QQ_ZIP" >/dev/null
|
7z x -y -o"$QQ_EXTRACT" "$QQ_ZIP" >/dev/null
|
||||||
|
echo "QQ_EXTRACT=$QQ_EXTRACT" >> $GITHUB_ENV
|
||||||
|
echo "WORK_TMPDIR=$TMPDIR" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Download Node.js and Assemble NapCat.Shell.Windows.Node.zip
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$WORK_TMPDIR"
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 2) 下载 Node.js Windows x64 zip 22.11.0
|
# 2) 下载 Node.js Windows x64 zip 22.11.0
|
||||||
@@ -146,7 +182,7 @@ jobs:
|
|||||||
NODE_ZIP="node-v$NODE_VER-win-x64.zip"
|
NODE_ZIP="node-v$NODE_VER-win-x64.zip"
|
||||||
aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL"
|
aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL"
|
||||||
|
|
||||||
NODE_EXTRACT="$TMPDIR/node_extracted"
|
NODE_EXTRACT="$WORK_TMPDIR/node_extracted"
|
||||||
mkdir -p "$NODE_EXTRACT"
|
mkdir -p "$NODE_EXTRACT"
|
||||||
unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT"
|
unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT"
|
||||||
|
|
||||||
@@ -164,11 +200,18 @@ jobs:
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 5) 拷贝 QQ 文件到 NapCat.Shell.Windows.Node
|
# 5) 拷贝 QQ 文件到 NapCat.Shell.Windows.Node
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node")
|
QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node" "LightQuic.dll")
|
||||||
for name in "${QQ_TARGETS[@]}"; do
|
for name in "${QQ_TARGETS[@]}"; do
|
||||||
find "$QQ_EXTRACT" -iname "$name" -exec cp -a {} "$OUT_DIR" \; || true
|
find "$QQ_EXTRACT" -iname "$name" -exec cp -a {} "$OUT_DIR" \; || true
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# 5.1) 拷贝 win64 目录下的文件
|
||||||
|
# -----------------------------
|
||||||
|
mkdir -p "$OUT_DIR/win64"
|
||||||
|
find "$QQ_EXTRACT" -ipath "*/win64/SSOShareInfoHelper64.dll" -exec cp -a {} "$OUT_DIR/win64/" \; || true
|
||||||
|
find "$QQ_EXTRACT" -ipath "*/win64/parent-ipc-core-x64.dll" -exec cp -a {} "$OUT_DIR/win64/" \; || true
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 6) 拷贝仓库文件 napcat.bat 和 index.js
|
# 6) 拷贝仓库文件 napcat.bat 和 index.js
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
@@ -178,6 +221,7 @@ jobs:
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 7) 拷贝 Node.exe 到 NapCat.Shell.Windows.Node
|
# 7) 拷贝 Node.exe 到 NapCat.Shell.Windows.Node
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
NODE_VER="22.11.0"
|
||||||
cp -a "$NODE_EXTRACT/node-v$NODE_VER-win-x64/node.exe" "$OUT_DIR/" || true
|
cp -a "$NODE_EXTRACT/node-v$NODE_VER-win-x64/node.exe" "$OUT_DIR/" || true
|
||||||
|
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,4 +16,5 @@ checkVersion.sh
|
|||||||
bun.lockb
|
bun.lockb
|
||||||
tests/run/
|
tests/run/
|
||||||
guild1.db-wal
|
guild1.db-wal
|
||||||
guild1.db-shm
|
guild1.db-shm
|
||||||
|
packages/napcat-develop/config/.env
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build:shell": "pnpm --filter napcat-shell run build || exit 1",
|
"build:shell": "pnpm --filter napcat-shell run build || exit 1",
|
||||||
"build:shell:dev": "pnpm --filter napcat-shell run build:dev || exit 1",
|
"build:shell:dev": "pnpm --filter napcat-shell run build:dev || exit 1",
|
||||||
|
"build:shell:config": "pnpm --filter napcat-shell run build && pnpm --filter napcat-develop run copy-env",
|
||||||
"build:framework": "pnpm --filter napcat-framework 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",
|
"build:webui": "pnpm --filter napcat-webui-frontend run build || exit 1",
|
||||||
"build:plugin-builtin": "pnpm --filter napcat-plugin-builtin run build || exit 1",
|
"build:plugin-builtin": "pnpm --filter napcat-plugin-builtin run build || exit 1",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export class NTQQFriendApi {
|
|||||||
async getBuddyV2SimpleInfoMap () {
|
async getBuddyV2SimpleInfoMap () {
|
||||||
const buddyService = this.context.session.getBuddyService();
|
const buddyService = this.context.session.getBuddyService();
|
||||||
let uids: string[] = [];
|
let uids: string[] = [];
|
||||||
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
|
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('39038')) {
|
||||||
const buddyListV2NT = await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL);
|
const buddyListV2NT = await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL);
|
||||||
uids = buddyListV2NT.data.flatMap(item => item.buddyUids);
|
uids = buddyListV2NT.data.flatMap(item => item.buddyUids);
|
||||||
} else {
|
} else {
|
||||||
@@ -55,7 +55,7 @@ export class NTQQFriendApi {
|
|||||||
const buddyService = this.context.session.getBuddyService();
|
const buddyService = this.context.session.getBuddyService();
|
||||||
let uids: string[] = [];
|
let uids: string[] = [];
|
||||||
let buddyListV2: Awaited<ReturnType<typeof buddyService.getBuddyListV2>>['data'];
|
let buddyListV2: Awaited<ReturnType<typeof buddyService.getBuddyListV2>>['data'];
|
||||||
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
|
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('39038')) {
|
||||||
buddyListV2 = (await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL)).data;
|
buddyListV2 = (await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL)).data;
|
||||||
uids = buddyListV2.flatMap(item => item.buddyUids);
|
uids = buddyListV2.flatMap(item => item.buddyUids);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
8
packages/napcat-core/external/appid.json
vendored
8
packages/napcat-core/external/appid.json
vendored
@@ -518,5 +518,13 @@
|
|||||||
"9.9.26-44725": {
|
"9.9.26-44725": {
|
||||||
"appid": 537337569,
|
"appid": 537337569,
|
||||||
"qua": "V1_WIN_NQ_9.9.26_44725_GW_B"
|
"qua": "V1_WIN_NQ_9.9.26_44725_GW_B"
|
||||||
|
},
|
||||||
|
"9.9.27-45627": {
|
||||||
|
"appid": 537340060,
|
||||||
|
"qua": "V1_WIN_NQ_9.9.27_45627_GW_B"
|
||||||
|
},
|
||||||
|
"6.9.88-44725": {
|
||||||
|
"appid": 537337594,
|
||||||
|
"qua": "V1_MAC_NQ_6.9.88_44725_GW_B"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
12
packages/napcat-core/external/napi2native.json
vendored
12
packages/napcat-core/external/napi2native.json
vendored
@@ -154,5 +154,17 @@
|
|||||||
"9.9.26-44725-x64": {
|
"9.9.26-44725-x64": {
|
||||||
"send": "0A18D0C",
|
"send": "0A18D0C",
|
||||||
"recv": "1D4BF0D"
|
"recv": "1D4BF0D"
|
||||||
|
},
|
||||||
|
"9.9.27-45627-x64": {
|
||||||
|
"send": "0A697CC",
|
||||||
|
"recv": "1E86AC1"
|
||||||
|
},
|
||||||
|
"6.9.88-44725-x64": {
|
||||||
|
"send": "2756EF6",
|
||||||
|
"recv": "0A36152"
|
||||||
|
},
|
||||||
|
"6.9.88-44725-arm64": {
|
||||||
|
"send": "2313C68",
|
||||||
|
"recv": "09693E4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
12
packages/napcat-core/external/packet.json
vendored
12
packages/napcat-core/external/packet.json
vendored
@@ -662,5 +662,17 @@
|
|||||||
"9.9.26-44725-x64": {
|
"9.9.26-44725-x64": {
|
||||||
"send": "2CEBB20",
|
"send": "2CEBB20",
|
||||||
"recv": "2CEF0A0"
|
"recv": "2CEF0A0"
|
||||||
|
},
|
||||||
|
"9.9.27-45627-x64": {
|
||||||
|
"send": "2E59CC0",
|
||||||
|
"recv": "2E5D240"
|
||||||
|
},
|
||||||
|
"6.9.88-44725-x64": {
|
||||||
|
"send": "451FE90",
|
||||||
|
"recv": "4522A40"
|
||||||
|
},
|
||||||
|
"6.9.88-44725-arm64": {
|
||||||
|
"send": "3D79168",
|
||||||
|
"recv": "3D7BA78"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,14 @@ import * as crypto from 'node:crypto';
|
|||||||
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
||||||
|
|
||||||
interface ForwardMsgJson {
|
interface ForwardMsgJson {
|
||||||
app: string
|
app: string;
|
||||||
config: ForwardMsgJsonConfig,
|
config: ForwardMsgJsonConfig,
|
||||||
desc: string,
|
desc: string,
|
||||||
extra: ForwardMsgJsonExtra,
|
extra: ForwardMsgJsonExtra,
|
||||||
meta: ForwardMsgJsonMeta,
|
meta: ForwardMsgJsonMeta,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
ver: string,
|
ver: string,
|
||||||
view: string
|
view: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ForwardMsgJsonConfig {
|
interface ForwardMsgJsonConfig {
|
||||||
@@ -17,7 +17,7 @@ interface ForwardMsgJsonConfig {
|
|||||||
forward: number,
|
forward: number,
|
||||||
round: number,
|
round: number,
|
||||||
type: string,
|
type: string,
|
||||||
width: number
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ForwardMsgJsonExtra {
|
interface ForwardMsgJsonExtra {
|
||||||
@@ -26,17 +26,17 @@ interface ForwardMsgJsonExtra {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ForwardMsgJsonMeta {
|
interface ForwardMsgJsonMeta {
|
||||||
detail: ForwardMsgJsonMetaDetail
|
detail: ForwardMsgJsonMetaDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ForwardMsgJsonMetaDetail {
|
interface ForwardMsgJsonMetaDetail {
|
||||||
news: {
|
news: {
|
||||||
text: string
|
text: string;
|
||||||
}[],
|
}[],
|
||||||
resid: string,
|
resid: string,
|
||||||
source: string,
|
source: string,
|
||||||
summary: string,
|
summary: string,
|
||||||
uniseq: string
|
uniseq: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ForwardAdaptMsg {
|
interface ForwardAdaptMsg {
|
||||||
@@ -50,8 +50,8 @@ interface ForwardAdaptMsgElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ForwardMsgBuilder {
|
export class ForwardMsgBuilder {
|
||||||
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||||
const id = crypto.randomUUID();
|
const id = uuid ?? crypto.randomUUID();
|
||||||
const isGroupMsg = msg.some(m => m.isGroupMsg);
|
const isGroupMsg = msg.some(m => m.isGroupMsg);
|
||||||
if (!source) {
|
if (!source) {
|
||||||
source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录');
|
source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录');
|
||||||
@@ -104,13 +104,19 @@ export class ForwardMsgBuilder {
|
|||||||
return this.build(resId, []);
|
return this.build(resId, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||||
return this.build(resId, packetMsg.map(msg => ({
|
return this.build(resId, packetMsg.map(msg => ({
|
||||||
senderName: msg.senderName,
|
senderName: msg.senderName,
|
||||||
isGroupMsg: msg.groupId !== undefined,
|
isGroupMsg: msg.groupId !== undefined,
|
||||||
msg: msg.msg.map(m => ({
|
msg: msg.msg.map(m => ({
|
||||||
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
|
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
|
||||||
})),
|
})),
|
||||||
})), source, news, summary, prompt);
|
})),
|
||||||
|
source,
|
||||||
|
news,
|
||||||
|
summary,
|
||||||
|
prompt,
|
||||||
|
uuid,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { OidbPacket } from '@/napcat-core/packet/transformer/base';
|
|||||||
import { ImageOcrResult } from '@/napcat-core/packet/entities/ocrResult';
|
import { ImageOcrResult } from '@/napcat-core/packet/entities/ocrResult';
|
||||||
import { gunzipSync } from 'zlib';
|
import { gunzipSync } from 'zlib';
|
||||||
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
|
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
|
||||||
|
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
|
||||||
|
|
||||||
export class PacketOperationContext {
|
export class PacketOperationContext {
|
||||||
private readonly context: PacketContext;
|
private readonly context: PacketContext;
|
||||||
@@ -26,7 +27,7 @@ export class PacketOperationContext {
|
|||||||
this.context = context;
|
this.context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendPacket<T extends boolean = false>(pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
|
async sendPacket<T extends boolean = false> (pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
|
||||||
return await this.context.client.sendOidbPacket(pkt, rsp);
|
return await this.context.client.sendOidbPacket(pkt, rsp);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,12 +95,15 @@ export class PacketOperationContext {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
);
|
);
|
||||||
const res = await Promise.allSettled(reqList);
|
const res = await Promise.allSettled(reqList);
|
||||||
this.context.logger.info(`上传资源${res.length}个,失败${res.filter((r) => r.status === 'rejected').length}个`);
|
const failedCount = res.filter((r) => r.status === 'rejected').length;
|
||||||
res.forEach((result, index) => {
|
if (failedCount > 0) {
|
||||||
if (result.status === 'rejected') {
|
this.context.logger.warn(`上传资源${res.length}个,失败${failedCount}个`);
|
||||||
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
|
res.forEach((result, index) => {
|
||||||
}
|
if (result.status === 'rejected') {
|
||||||
});
|
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async UploadImage (img: PacketMsgPicElement) {
|
async UploadImage (img: PacketMsgPicElement) {
|
||||||
@@ -224,7 +228,15 @@ export class PacketOperationContext {
|
|||||||
const res = trans.UploadForwardMsg.parse(resp);
|
const res = trans.UploadForwardMsg.parse(resp);
|
||||||
return res.result.resId;
|
return res.result.resId;
|
||||||
}
|
}
|
||||||
|
async UploadForwardMsgV2 (msg: UploadForwardMsgParams[], groupUin: number = 0) {
|
||||||
|
//await this.SendPreprocess(msg, groupUin);
|
||||||
|
// 遍历上传资源
|
||||||
|
await Promise.allSettled(msg.map(async (item) => { return await this.SendPreprocess(item.actionMsg, groupUin); }));
|
||||||
|
const req = trans.UploadForwardMsgV2.build(this.context.napcore.basicInfo.uid, msg, groupUin);
|
||||||
|
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||||
|
const res = trans.UploadForwardMsg.parse(resp);
|
||||||
|
return res.result.resId;
|
||||||
|
}
|
||||||
async MoveGroupFile (
|
async MoveGroupFile (
|
||||||
groupUin: number,
|
groupUin: number,
|
||||||
fileUUID: string,
|
fileUUID: string,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import zlib from 'node:zlib';
|
||||||
|
import * as proto from '@/napcat-core/packet/transformer/proto';
|
||||||
|
import { NapProtoMsg } from 'napcat-protobuf';
|
||||||
|
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/napcat-core/packet/transformer/base';
|
||||||
|
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
||||||
|
|
||||||
|
export interface UploadForwardMsgParams {
|
||||||
|
actionCommand: string;
|
||||||
|
actionMsg: PacketMsg[];
|
||||||
|
}
|
||||||
|
class UploadForwardMsgV2 extends PacketTransformer<typeof proto.SendLongMsgResp> {
|
||||||
|
build (selfUid: string, msg: UploadForwardMsgParams[], groupUin: number = 0): OidbPacket {
|
||||||
|
const reqdata = msg.map((item) => ({
|
||||||
|
actionCommand: item.actionCommand,
|
||||||
|
actionData: {
|
||||||
|
msgBody: this.msgBuilder.buildFakeMsg(selfUid, item.actionMsg),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const longMsgResultData = new NapProtoMsg(proto.LongMsgResult).encode(
|
||||||
|
{
|
||||||
|
action: reqdata,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const payload = zlib.gzipSync(Buffer.from(longMsgResultData));
|
||||||
|
const req = new NapProtoMsg(proto.SendLongMsgReq).encode(
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
type: groupUin === 0 ? 1 : 3,
|
||||||
|
uid: {
|
||||||
|
uid: groupUin === 0 ? selfUid : groupUin.toString(),
|
||||||
|
},
|
||||||
|
groupUin,
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
field1: 4, field2: 1, field3: 7, field4: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
cmd: 'trpc.group.long_msg_interface.MsgService.SsoSendLongMsg',
|
||||||
|
data: PacketBufBuilder(req),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
parse (data: Buffer) {
|
||||||
|
return new NapProtoMsg(proto.SendLongMsgResp).decode(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new UploadForwardMsgV2();
|
||||||
@@ -2,3 +2,4 @@ export { default as UploadForwardMsg } from './UploadForwardMsg';
|
|||||||
export { default as FetchGroupMessage } from './FetchGroupMessage';
|
export { default as FetchGroupMessage } from './FetchGroupMessage';
|
||||||
export { default as FetchC2CMessage } from './FetchC2CMessage';
|
export { default as FetchC2CMessage } from './FetchC2CMessage';
|
||||||
export { default as DownloadForwardMsg } from './DownloadForwardMsg';
|
export { default as DownloadForwardMsg } from './DownloadForwardMsg';
|
||||||
|
export { default as UploadForwardMsgV2 } from './UploadForwardMsgV2';
|
||||||
@@ -8,6 +8,7 @@ export interface LoginInitConfig {
|
|||||||
commonPath: string;
|
commonPath: string;
|
||||||
clientVer: string;
|
clientVer: string;
|
||||||
hostName: string;
|
hostName: string;
|
||||||
|
externalVersion: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasswordLoginRetType {
|
export interface PasswordLoginRetType {
|
||||||
@@ -21,7 +22,7 @@ export interface PasswordLoginRetType {
|
|||||||
jumpWord: string;
|
jumpWord: string;
|
||||||
tipsTitle: string;
|
tipsTitle: string;
|
||||||
tipsContent: string;
|
tipsContent: string;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasswordLoginArgType {
|
export interface PasswordLoginArgType {
|
||||||
@@ -55,37 +56,37 @@ export interface QuickLoginResult {
|
|||||||
jumpUrl: string,
|
jumpUrl: string,
|
||||||
jumpWord: string,
|
jumpWord: string,
|
||||||
tipsTitle: string,
|
tipsTitle: string,
|
||||||
tipsContent: string
|
tipsContent: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeIKernelLoginService {
|
export interface NodeIKernelLoginService {
|
||||||
getMsfStatus: () => number;
|
getMsfStatus: () => number;
|
||||||
|
|
||||||
setLoginMiscData(arg0: string, value: string): unknown;
|
setLoginMiscData (arg0: string, value: string): unknown;
|
||||||
|
|
||||||
getMachineGuid(): string;
|
getMachineGuid (): string;
|
||||||
|
|
||||||
get(): NodeIKernelLoginService;
|
get (): NodeIKernelLoginService;
|
||||||
|
|
||||||
connect(): boolean;
|
connect (): boolean;
|
||||||
|
|
||||||
addKernelLoginListener(listener: NodeIKernelLoginListener): number;
|
addKernelLoginListener (listener: NodeIKernelLoginListener): number;
|
||||||
|
|
||||||
removeKernelLoginListener(listener: number): void;
|
removeKernelLoginListener (listener: number): void;
|
||||||
|
|
||||||
initConfig(config: LoginInitConfig): void;
|
initConfig (config: LoginInitConfig): void;
|
||||||
|
|
||||||
getLoginMiscData(data: string): Promise<GeneralCallResult & { value: string }>;
|
getLoginMiscData (data: string): Promise<GeneralCallResult & { value: string; }>;
|
||||||
|
|
||||||
getLoginList(): Promise<{
|
getLoginList (): Promise<{
|
||||||
result: number, // 0是ok
|
result: number, // 0是ok
|
||||||
LocalLoginInfoList: LoginListItem[]
|
LocalLoginInfoList: LoginListItem[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
quickLoginWithUin(uin: string): Promise<QuickLoginResult>;
|
quickLoginWithUin (uin: string): Promise<QuickLoginResult>;
|
||||||
|
|
||||||
passwordLogin(param: PasswordLoginArgType): Promise<unknown>;
|
passwordLogin (param: PasswordLoginArgType): Promise<QuickLoginResult>;
|
||||||
|
|
||||||
getQRCodePicture(): boolean;
|
getQRCodePicture (): boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { GeneralCallResult } from './common';
|
import { GeneralCallResult } from './common';
|
||||||
|
|
||||||
export interface NodeIKernelNodeMiscService {
|
export interface NodeIKernelNodeMiscService {
|
||||||
writeVersionToRegistry(version: string): void;
|
writeVersionToRegistry (version: string): void;
|
||||||
|
|
||||||
getMiniAppPath(): unknown;
|
getMiniAppPath (): unknown;
|
||||||
|
|
||||||
setMiniAppVersion(version: string): unknown;
|
setMiniAppVersion (version: string): unknown;
|
||||||
|
|
||||||
wantWinScreenOCR(imagepath: string): Promise<GeneralCallResult>;
|
wantWinScreenOCR (imagepath: string): Promise<GeneralCallResult>;
|
||||||
|
|
||||||
SendMiniAppMsg(arg1: string, arg2: string, arg3: string): unknown;
|
SendMiniAppMsg (arg1: string, arg2: string, arg3: string): unknown;
|
||||||
|
|
||||||
startNewMiniApp(appfile: string, params: string): unknown;
|
startNewMiniApp (appfile: string, params: string): unknown;
|
||||||
|
|
||||||
|
getQimei36WithNewSdk (): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,9 +87,9 @@ export interface NodeQQNTWrapperUtil {
|
|||||||
|
|
||||||
fullWordToHalfWord (word: string): unknown;
|
fullWordToHalfWord (word: string): unknown;
|
||||||
|
|
||||||
getNTUserDataInfoConfig (): unknown;
|
getNTUserDataInfoConfig (): Promise<string>;
|
||||||
|
|
||||||
pathIsReadableAndWriteable (path: string): unknown; // 直接的猜测
|
pathIsReadableAndWriteable (path: string, type: number): Promise<number>; // type 2 , result 0 成功
|
||||||
|
|
||||||
resetUserDataSavePathToDocument (): unknown;
|
resetUserDataSavePathToDocument (): unknown;
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ export interface NodeIQQNTStartupSessionWrapper {
|
|||||||
stop (): void;
|
stop (): void;
|
||||||
start (): void;
|
start (): void;
|
||||||
createWithModuleList (uk: unknown): unknown;
|
createWithModuleList (uk: unknown): unknown;
|
||||||
getSessionIdList (): unknown;
|
getSessionIdList (): Promise<Map<unknown, unknown>>;
|
||||||
}
|
}
|
||||||
export interface NodeIQQNTWrapperSession {
|
export interface NodeIQQNTWrapperSession {
|
||||||
getNTWrapperSession (str: string): NodeIQQNTWrapperSession;
|
getNTWrapperSession (str: string): NodeIQQNTWrapperSession;
|
||||||
|
|||||||
4
packages/napcat-develop/config/.env.example
Normal file
4
packages/napcat-develop/config/.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
NAPCAT_DISABLE_PIPE=1
|
||||||
|
NAPCAT_DISABLE_MULTI_PROCESS=1
|
||||||
|
NAPCAT_WEBUI_JWT_SECRET_KEY=napcat_dev_secret_key
|
||||||
|
NAPCAT_WEBUI_SECRET_KEY=napcatqq
|
||||||
39
packages/napcat-develop/config/onebot11.json
Normal file
39
packages/napcat-develop/config/onebot11.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"network": {
|
||||||
|
"httpServers": [
|
||||||
|
{
|
||||||
|
"enable": true,
|
||||||
|
"name": "HTTP",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 3000,
|
||||||
|
"enableCors": true,
|
||||||
|
"enableWebsocket": false,
|
||||||
|
"messagePostFormat": "array",
|
||||||
|
"token": "",
|
||||||
|
"debug": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"httpSseServers": [],
|
||||||
|
"httpClients": [],
|
||||||
|
"websocketServers": [
|
||||||
|
{
|
||||||
|
"enable": true,
|
||||||
|
"name": "WebSocket",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 3001,
|
||||||
|
"reportSelfMessage": false,
|
||||||
|
"enableForcePushEvent": true,
|
||||||
|
"messagePostFormat": "array",
|
||||||
|
"token": "",
|
||||||
|
"debug": false,
|
||||||
|
"heartInterval": 30000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"websocketClients": [],
|
||||||
|
"plugins": []
|
||||||
|
},
|
||||||
|
"musicSignUrl": "",
|
||||||
|
"enableLocalFile2Url": false,
|
||||||
|
"parseMultMsg": false,
|
||||||
|
"imageDownloadProxy": ""
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ if (versionFolders.length === 0) {
|
|||||||
const BASE_DIR = path.join(versionsDir, selectedFolder, 'resources', 'app');
|
const BASE_DIR = path.join(versionsDir, selectedFolder, 'resources', 'app');
|
||||||
console.log(`BASE_DIR: ${BASE_DIR}`);
|
console.log(`BASE_DIR: ${BASE_DIR}`);
|
||||||
const TARGET_DIR = path.join(__dirname, 'dist');
|
const TARGET_DIR = path.join(__dirname, 'dist');
|
||||||
|
const TARGET_WIN64_DIR = path.join(__dirname, 'dist', 'win64');
|
||||||
const QQNT_FILE = path.join(__dirname, 'QQNT.dll');
|
const QQNT_FILE = path.join(__dirname, 'QQNT.dll');
|
||||||
const NAPCAT_MJS_PATH = path.join(__dirname, '..', 'napcat-shell', 'dist', 'napcat.mjs');
|
const NAPCAT_MJS_PATH = path.join(__dirname, '..', 'napcat-shell', 'dist', 'napcat.mjs');
|
||||||
|
|
||||||
@@ -46,6 +47,12 @@ const itemsToCopy = [
|
|||||||
'package.json',
|
'package.json',
|
||||||
'QBar.dll',
|
'QBar.dll',
|
||||||
'wrapper.node',
|
'wrapper.node',
|
||||||
|
'LightQuic.dll'
|
||||||
|
];
|
||||||
|
|
||||||
|
const win64ItemsToCopy = [
|
||||||
|
'SSOShareInfoHelper64.dll',
|
||||||
|
'parent-ipc-core-x64.dll'
|
||||||
];
|
];
|
||||||
|
|
||||||
async function copyAll () {
|
async function copyAll () {
|
||||||
@@ -53,13 +60,23 @@ async function copyAll () {
|
|||||||
const configPath = path.join(TARGET_DIR, 'config.json');
|
const configPath = path.join(TARGET_DIR, 'config.json');
|
||||||
const allItemsExist = await fs.pathExists(qqntDllPath) &&
|
const allItemsExist = await fs.pathExists(qqntDllPath) &&
|
||||||
await fs.pathExists(configPath) &&
|
await fs.pathExists(configPath) &&
|
||||||
(await Promise.all(itemsToCopy.map(item => fs.pathExists(path.join(TARGET_DIR, item))))).every(exists => exists);
|
(await Promise.all(itemsToCopy.map(item => fs.pathExists(path.join(TARGET_DIR, item))))).every(exists => exists) &&
|
||||||
|
(await Promise.all(win64ItemsToCopy.map(item => fs.pathExists(path.join(TARGET_WIN64_DIR, item))))).every(exists => exists);
|
||||||
|
|
||||||
if (!allItemsExist) {
|
if (!allItemsExist) {
|
||||||
console.log('Copying required files...');
|
console.log('Copying required files...');
|
||||||
await fs.ensureDir(TARGET_DIR);
|
await fs.ensureDir(TARGET_DIR);
|
||||||
|
await fs.ensureDir(TARGET_WIN64_DIR);
|
||||||
await fs.copy(QQNT_FILE, qqntDllPath, { overwrite: true });
|
await fs.copy(QQNT_FILE, qqntDllPath, { overwrite: true });
|
||||||
await fs.copy(path.join(versionsDir, 'config.json'), configPath, { overwrite: true });
|
await fs.copy(path.join(versionsDir, 'config.json'), configPath, { overwrite: true });
|
||||||
|
|
||||||
|
// 复制 win64 目录下的文件
|
||||||
|
await Promise.all(win64ItemsToCopy.map(async (item) => {
|
||||||
|
await fs.copy(path.join(BASE_DIR, 'win64', item), path.join(TARGET_WIN64_DIR, item), { overwrite: true });
|
||||||
|
console.log(`Copied ${item} to win64`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 复制根目录下的文件
|
||||||
await Promise.all(itemsToCopy.map(async (item) => {
|
await Promise.all(itemsToCopy.map(async (item) => {
|
||||||
await fs.copy(path.join(BASE_DIR, item), path.join(TARGET_DIR, item), { overwrite: true });
|
await fs.copy(path.join(BASE_DIR, item), path.join(TARGET_DIR, item), { overwrite: true });
|
||||||
console.log(`Copied ${item}`);
|
console.log(`Copied ${item}`);
|
||||||
@@ -78,7 +95,7 @@ async function copyAll () {
|
|||||||
process.env.NAPCAT_WORKDIR = TARGET_DIR;
|
process.env.NAPCAT_WORKDIR = TARGET_DIR;
|
||||||
// 开发环境使用固定密钥
|
// 开发环境使用固定密钥
|
||||||
process.env.NAPCAT_WEBUI_JWT_SECRET_KEY = 'napcat_dev_secret_key';
|
process.env.NAPCAT_WEBUI_JWT_SECRET_KEY = 'napcat_dev_secret_key';
|
||||||
process.env.NAPCAT_WEBUI_SECRET_KEY = 'napcat';
|
process.env.NAPCAT_WEBUI_SECRET_KEY = 'napcatqq';
|
||||||
console.log('Loading NapCat module...');
|
console.log('Loading NapCat module...');
|
||||||
await import(pathToFileURL(NAPCAT_MJS_PATH).href);
|
await import(pathToFileURL(NAPCAT_MJS_PATH).href);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "napcat-develop",
|
"name": "napcat-develop",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "powershell ./nodeTest.ps1"
|
"dev": "powershell ./nodeTest.ps1",
|
||||||
|
"copy-env": "xcopy config ..\\napcat-shell\\dist\\config /E /I /Y"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"require": "./index.js"
|
||||||
},
|
},
|
||||||
"exports": {
|
"./*": {
|
||||||
".": {
|
"require": "./*"
|
||||||
"require": "./index.js"
|
|
||||||
},
|
|
||||||
"./*": {
|
|
||||||
"require": "./*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"fs-extra": "^11.3.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"fs-extra": "^11.3.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ const FrameworkBaseConfig = () =>
|
|||||||
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
||||||
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
||||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||||
'@/image-size': resolve(__dirname, '../image-size'),
|
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||||
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
||||||
'@/napcat-adapter': resolve(__dirname, '../napcat-adapter'),
|
'@/napcat-adapter': resolve(__dirname, '../napcat-adapter'),
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
packages/napcat-image-size/resource/test-20x20.jpg
Normal file
BIN
packages/napcat-image-size/resource/test-20x20.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
packages/napcat-image-size/resource/test-20x20.png
Normal file
BIN
packages/napcat-image-size/resource/test-20x20.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
packages/napcat-image-size/resource/test-20x20.tiff
Normal file
BIN
packages/napcat-image-size/resource/test-20x20.tiff
Normal file
Binary file not shown.
BIN
packages/napcat-image-size/resource/test-20x20.webp
Normal file
BIN
packages/napcat-image-size/resource/test-20x20.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 962 B |
BIN
packages/napcat-image-size/resource/test-490x498.gif
Normal file
BIN
packages/napcat-image-size/resource/test-490x498.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -1,5 +1,12 @@
|
|||||||
|
import { BmpParser } from '@/napcat-image-size/src/parser/BmpParser';
|
||||||
|
import { GifParser } from '@/napcat-image-size/src/parser/GifParser';
|
||||||
|
import { JpegParser } from '@/napcat-image-size/src/parser/JpegParser';
|
||||||
|
import { PngParser } from '@/napcat-image-size/src/parser/PngParser';
|
||||||
|
import { TiffParser } from '@/napcat-image-size/src/parser/TiffParser';
|
||||||
|
import { WebpParser } from '@/napcat-image-size/src/parser/WebpParser';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { ReadStream } from 'fs';
|
import { ReadStream } from 'fs';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
export interface ImageSize {
|
export interface ImageSize {
|
||||||
width: number;
|
width: number;
|
||||||
@@ -12,17 +19,18 @@ export enum ImageType {
|
|||||||
BMP = 'bmp',
|
BMP = 'bmp',
|
||||||
GIF = 'gif',
|
GIF = 'gif',
|
||||||
WEBP = 'webp',
|
WEBP = 'webp',
|
||||||
|
TIFF = 'tiff',
|
||||||
UNKNOWN = 'unknown',
|
UNKNOWN = 'unknown',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImageParser {
|
export interface ImageParser {
|
||||||
readonly type: ImageType;
|
readonly type: ImageType;
|
||||||
canParse(buffer: Buffer): boolean;
|
canParse (buffer: Buffer): boolean;
|
||||||
parseSize(stream: ReadStream): Promise<ImageSize | undefined>;
|
parseSize (stream: ReadStream): Promise<ImageSize | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 魔术匹配
|
// 魔术匹配
|
||||||
function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
|
export function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
|
||||||
if (buffer.length < offset + magic.length) {
|
if (buffer.length < offset + magic.length) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -35,316 +43,39 @@ function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PNG解析器
|
// 所有解析器实例
|
||||||
class PngParser implements ImageParser {
|
const parserInstances = {
|
||||||
readonly type = ImageType.PNG;
|
png: new PngParser(),
|
||||||
// PNG 魔术头:89 50 4E 47 0D 0A 1A 0A
|
jpeg: new JpegParser(),
|
||||||
private readonly PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
bmp: new BmpParser(),
|
||||||
|
gif: new GifParser(),
|
||||||
|
webp: new WebpParser(),
|
||||||
|
tiff: new TiffParser(),
|
||||||
|
};
|
||||||
|
|
||||||
canParse (buffer: Buffer): boolean {
|
// 首字节到可能的图片类型映射,用于快速筛选
|
||||||
return matchMagic(buffer, this.PNG_SIGNATURE);
|
const firstByteMap = new Map<number, ImageType[]>([
|
||||||
}
|
[0x42, [ImageType.BMP]], // 'B' - BMP
|
||||||
|
[0x47, [ImageType.GIF]], // 'G' - GIF
|
||||||
|
[0x49, [ImageType.TIFF]], // 'I' - TIFF (II - little endian)
|
||||||
|
[0x4D, [ImageType.TIFF]], // 'M' - TIFF (MM - big endian)
|
||||||
|
[0x52, [ImageType.WEBP]], // 'R' - RIFF (WebP)
|
||||||
|
[0x89, [ImageType.PNG]], // PNG signature
|
||||||
|
[0xFF, [ImageType.JPEG]], // JPEG SOI
|
||||||
|
]);
|
||||||
|
|
||||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
// 类型到解析器的映射
|
||||||
return new Promise((resolve, reject) => {
|
const typeToParser = new Map<ImageType, ImageParser>([
|
||||||
stream.once('error', reject);
|
[ImageType.PNG, parserInstances.png],
|
||||||
stream.once('readable', () => {
|
[ImageType.JPEG, parserInstances.jpeg],
|
||||||
const buf = stream.read(24) as Buffer;
|
[ImageType.BMP, parserInstances.bmp],
|
||||||
if (!buf || buf.length < 24) {
|
[ImageType.GIF, parserInstances.gif],
|
||||||
return resolve(undefined);
|
[ImageType.WEBP, parserInstances.webp],
|
||||||
}
|
[ImageType.TIFF, parserInstances.tiff],
|
||||||
if (this.canParse(buf)) {
|
]);
|
||||||
const width = buf.readUInt32BE(16);
|
|
||||||
const height = buf.readUInt32BE(20);
|
|
||||||
resolve({ width, height });
|
|
||||||
} else {
|
|
||||||
resolve(undefined);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// JPEG解析器
|
// 所有解析器列表(用于回退)
|
||||||
class JpegParser implements ImageParser {
|
const parsers: ReadonlyArray<ImageParser> = Object.values(parserInstances);
|
||||||
readonly type = ImageType.JPEG;
|
|
||||||
// JPEG 魔术头:FF D8
|
|
||||||
private readonly JPEG_SIGNATURE = [0xFF, 0xD8];
|
|
||||||
|
|
||||||
// JPEG标记常量
|
|
||||||
private readonly SOF_MARKERS = {
|
|
||||||
SOF0: 0xC0, // 基线DCT
|
|
||||||
SOF1: 0xC1, // 扩展顺序DCT
|
|
||||||
SOF2: 0xC2, // 渐进式DCT
|
|
||||||
SOF3: 0xC3, // 无损
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// 非SOF标记
|
|
||||||
private readonly NON_SOF_MARKERS: number[] = [
|
|
||||||
0xC4, // DHT
|
|
||||||
0xC8, // JPEG扩展
|
|
||||||
0xCC, // DAC
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
canParse (buffer: Buffer): boolean {
|
|
||||||
return matchMagic(buffer, this.JPEG_SIGNATURE);
|
|
||||||
}
|
|
||||||
|
|
||||||
isSOFMarker (marker: number): boolean {
|
|
||||||
return (
|
|
||||||
marker === this.SOF_MARKERS.SOF0 ||
|
|
||||||
marker === this.SOF_MARKERS.SOF1 ||
|
|
||||||
marker === this.SOF_MARKERS.SOF2 ||
|
|
||||||
marker === this.SOF_MARKERS.SOF3
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
isNonSOFMarker (marker: number): boolean {
|
|
||||||
return this.NON_SOF_MARKERS.includes(marker);
|
|
||||||
}
|
|
||||||
|
|
||||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
|
||||||
return new Promise<ImageSize | undefined>((resolve, reject) => {
|
|
||||||
const BUFFER_SIZE = 1024; // 读取块大小,可以根据需要调整
|
|
||||||
let buffer = Buffer.alloc(0);
|
|
||||||
let offset = 0;
|
|
||||||
let found = false;
|
|
||||||
|
|
||||||
// 处理错误
|
|
||||||
stream.on('error', (err) => {
|
|
||||||
stream.destroy();
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 处理数据块
|
|
||||||
stream.on('data', (chunk: Buffer | string) => {
|
|
||||||
// 追加新数据到缓冲区
|
|
||||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
||||||
buffer = Buffer.concat([buffer.subarray(offset), chunkBuffer]);
|
|
||||||
offset = 0;
|
|
||||||
|
|
||||||
// 保持缓冲区在合理大小内,只保留最后的部分用于跨块匹配
|
|
||||||
const bufferSize = buffer.length;
|
|
||||||
const MIN_REQUIRED_BYTES = 10; // SOF段最低字节数
|
|
||||||
|
|
||||||
// 从JPEG头部后开始扫描
|
|
||||||
while (offset < bufferSize - MIN_REQUIRED_BYTES) {
|
|
||||||
// 寻找FF标记
|
|
||||||
if (buffer[offset] === 0xFF && buffer[offset + 1]! >= 0xC0 && buffer[offset + 1]! <= 0xCF) {
|
|
||||||
const marker = buffer[offset + 1];
|
|
||||||
if (!marker) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// 跳过非SOF标记
|
|
||||||
if (this.isNonSOFMarker(marker)) {
|
|
||||||
offset += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理SOF标记 (包含尺寸信息)
|
|
||||||
if (this.isSOFMarker(marker)) {
|
|
||||||
// 确保缓冲区中有足够数据读取尺寸
|
|
||||||
if (offset + 9 < bufferSize) {
|
|
||||||
// 解析尺寸: FF XX YY YY PP HH HH WW WW ...
|
|
||||||
// XX = 标记, YY YY = 段长度, PP = 精度, HH HH = 高, WW WW = 宽
|
|
||||||
const height = buffer.readUInt16BE(offset + 5);
|
|
||||||
const width = buffer.readUInt16BE(offset + 7);
|
|
||||||
|
|
||||||
found = true;
|
|
||||||
stream.destroy();
|
|
||||||
resolve({ width, height });
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
// 如果缓冲区内数据不够,保留当前位置等待更多数据
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
offset++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 缓冲区管理: 如果处理了许多数据但没找到标记,
|
|
||||||
// 保留最后N字节用于跨块匹配,丢弃之前的数据
|
|
||||||
if (offset > BUFFER_SIZE) {
|
|
||||||
const KEEP_BYTES = 20; // 保留足够数据以处理跨块边界的情况
|
|
||||||
if (offset > KEEP_BYTES) {
|
|
||||||
buffer = buffer.subarray(offset - KEEP_BYTES);
|
|
||||||
offset = KEEP_BYTES;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 处理流结束
|
|
||||||
stream.on('end', () => {
|
|
||||||
if (!found) {
|
|
||||||
resolve(undefined);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BMP解析器
|
|
||||||
class BmpParser implements ImageParser {
|
|
||||||
readonly type = ImageType.BMP;
|
|
||||||
// BMP 魔术头:42 4D (BM)
|
|
||||||
private readonly BMP_SIGNATURE = [0x42, 0x4D];
|
|
||||||
|
|
||||||
canParse (buffer: Buffer): boolean {
|
|
||||||
return matchMagic(buffer, this.BMP_SIGNATURE);
|
|
||||||
}
|
|
||||||
|
|
||||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
stream.once('error', reject);
|
|
||||||
stream.once('readable', () => {
|
|
||||||
const buf = stream.read(26) as Buffer;
|
|
||||||
if (!buf || buf.length < 26) {
|
|
||||||
return resolve(undefined);
|
|
||||||
}
|
|
||||||
if (this.canParse(buf)) {
|
|
||||||
const width = buf.readUInt32LE(18);
|
|
||||||
const height = buf.readUInt32LE(22);
|
|
||||||
resolve({ width, height });
|
|
||||||
} else {
|
|
||||||
resolve(undefined);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GIF解析器
|
|
||||||
class GifParser implements ImageParser {
|
|
||||||
readonly type = ImageType.GIF;
|
|
||||||
// GIF87a 魔术头:47 49 46 38 37 61
|
|
||||||
private readonly GIF87A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
|
|
||||||
// GIF89a 魔术头:47 49 46 38 39 61
|
|
||||||
private readonly GIF89A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
|
|
||||||
|
|
||||||
canParse (buffer: Buffer): boolean {
|
|
||||||
return (
|
|
||||||
matchMagic(buffer, this.GIF87A_SIGNATURE) ||
|
|
||||||
matchMagic(buffer, this.GIF89A_SIGNATURE)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
stream.once('error', reject);
|
|
||||||
stream.once('readable', () => {
|
|
||||||
const buf = stream.read(10) as Buffer;
|
|
||||||
if (!buf || buf.length < 10) {
|
|
||||||
return resolve(undefined);
|
|
||||||
}
|
|
||||||
if (this.canParse(buf)) {
|
|
||||||
const width = buf.readUInt16LE(6);
|
|
||||||
const height = buf.readUInt16LE(8);
|
|
||||||
resolve({ width, height });
|
|
||||||
} else {
|
|
||||||
resolve(undefined);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WEBP解析器 - 完整支持VP8, VP8L, VP8X格式
|
|
||||||
class WebpParser implements ImageParser {
|
|
||||||
readonly type = ImageType.WEBP;
|
|
||||||
// WEBP RIFF 头:52 49 46 46 (RIFF)
|
|
||||||
private readonly RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46];
|
|
||||||
// WEBP 魔术头:57 45 42 50 (WEBP)
|
|
||||||
private readonly WEBP_SIGNATURE = [0x57, 0x45, 0x42, 0x50];
|
|
||||||
|
|
||||||
// WEBP 块头
|
|
||||||
private readonly CHUNK_VP8 = [0x56, 0x50, 0x38, 0x20]; // "VP8 "
|
|
||||||
private readonly CHUNK_VP8L = [0x56, 0x50, 0x38, 0x4C]; // "VP8L"
|
|
||||||
private readonly CHUNK_VP8X = [0x56, 0x50, 0x38, 0x58]; // "VP8X"
|
|
||||||
|
|
||||||
canParse (buffer: Buffer): boolean {
|
|
||||||
return (
|
|
||||||
buffer.length >= 12 &&
|
|
||||||
matchMagic(buffer, this.RIFF_SIGNATURE, 0) &&
|
|
||||||
matchMagic(buffer, this.WEBP_SIGNATURE, 8)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
isChunkType (buffer: Buffer, offset: number, chunkType: number[]): boolean {
|
|
||||||
return buffer.length >= offset + 4 && matchMagic(buffer, chunkType, offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// 需要读取足够的字节来检测所有三种格式
|
|
||||||
const MAX_HEADER_SIZE = 32;
|
|
||||||
let totalBytes = 0;
|
|
||||||
let buffer = Buffer.alloc(0);
|
|
||||||
|
|
||||||
stream.on('error', reject);
|
|
||||||
|
|
||||||
stream.on('data', (chunk: Buffer | string) => {
|
|
||||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
||||||
buffer = Buffer.concat([buffer, chunkBuffer]);
|
|
||||||
totalBytes += chunk.length;
|
|
||||||
|
|
||||||
// 检查是否有足够的字节进行格式检测
|
|
||||||
if (totalBytes >= MAX_HEADER_SIZE) {
|
|
||||||
stream.destroy();
|
|
||||||
|
|
||||||
// 检查基本的WEBP签名
|
|
||||||
if (!this.canParse(buffer)) {
|
|
||||||
return resolve(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查chunk头部,位于字节12-15
|
|
||||||
if (this.isChunkType(buffer, 12, this.CHUNK_VP8)) {
|
|
||||||
// VP8格式 - 标准WebP
|
|
||||||
// 宽度和高度在帧头中
|
|
||||||
const width = buffer.readUInt16LE(26) & 0x3FFF;
|
|
||||||
const height = buffer.readUInt16LE(28) & 0x3FFF;
|
|
||||||
return resolve({ width, height });
|
|
||||||
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8L)) {
|
|
||||||
// VP8L格式 - 无损WebP
|
|
||||||
// 1字节标记后是14位宽度和14位高度
|
|
||||||
const bits = buffer.readUInt32LE(21);
|
|
||||||
const width = 1 + (bits & 0x3FFF);
|
|
||||||
const height = 1 + ((bits >> 14) & 0x3FFF);
|
|
||||||
return resolve({ width, height });
|
|
||||||
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8X)) {
|
|
||||||
// VP8X格式 - 扩展WebP
|
|
||||||
// 24位宽度和高度(减去1)
|
|
||||||
if (!buffer[24] || !buffer[25] || !buffer[26] || !buffer[27] || !buffer[28] || !buffer[29]) {
|
|
||||||
return resolve(undefined);
|
|
||||||
}
|
|
||||||
const width = 1 + ((buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) & 0xFFFFFF);
|
|
||||||
const height = 1 + ((buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) & 0xFFFFFF);
|
|
||||||
return resolve({ width, height });
|
|
||||||
} else {
|
|
||||||
// 未知的WebP子格式
|
|
||||||
return resolve(undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('end', () => {
|
|
||||||
// 如果没有读到足够的字节
|
|
||||||
if (totalBytes < MAX_HEADER_SIZE) {
|
|
||||||
resolve(undefined);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsers: ReadonlyArray<ImageParser> = [
|
|
||||||
new PngParser(),
|
|
||||||
new JpegParser(),
|
|
||||||
new BmpParser(),
|
|
||||||
new GifParser(),
|
|
||||||
new WebpParser(),
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function detectImageType (filePath: string): Promise<ImageType> {
|
export async function detectImageType (filePath: string): Promise<ImageType> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -354,18 +85,22 @@ export async function detectImageType (filePath: string): Promise<ImageType> {
|
|||||||
end: 63,
|
end: 63,
|
||||||
});
|
});
|
||||||
|
|
||||||
let buffer: Buffer | null = null;
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
stream.once('error', (err) => {
|
stream.on('error', (err) => {
|
||||||
stream.destroy();
|
stream.destroy();
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.once('readable', () => {
|
stream.on('data', (chunk: Buffer | string) => {
|
||||||
buffer = stream.read(64) as Buffer;
|
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
stream.destroy();
|
chunks.push(chunkBuffer);
|
||||||
|
});
|
||||||
|
|
||||||
if (!buffer) {
|
stream.on('end', () => {
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
if (buffer.length === 0) {
|
||||||
return resolve(ImageType.UNKNOWN);
|
return resolve(ImageType.UNKNOWN);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,12 +112,6 @@ export async function detectImageType (filePath: string): Promise<ImageType> {
|
|||||||
|
|
||||||
resolve(ImageType.UNKNOWN);
|
resolve(ImageType.UNKNOWN);
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.once('end', () => {
|
|
||||||
if (!buffer) {
|
|
||||||
resolve(ImageType.UNKNOWN);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +119,7 @@ export async function imageSizeFromFile (filePath: string): Promise<ImageSize |
|
|||||||
try {
|
try {
|
||||||
// 先检测类型
|
// 先检测类型
|
||||||
const type = await detectImageType(filePath);
|
const type = await detectImageType(filePath);
|
||||||
const parser = parsers.find(p => p.type === type);
|
const parser = typeToParser.get(type);
|
||||||
if (!parser) {
|
if (!parser) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -422,3 +151,71 @@ export async function imageSizeFallBack (
|
|||||||
): Promise<ImageSize> {
|
): Promise<ImageSize> {
|
||||||
return await imageSizeFromFile(filePath) ?? fallback;
|
return await imageSizeFromFile(filePath) ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 Buffer 创建可读流
|
||||||
|
function bufferToReadStream (buffer: Buffer): ReadStream {
|
||||||
|
const readable = new Readable({
|
||||||
|
read () {
|
||||||
|
this.push(buffer);
|
||||||
|
this.push(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return readable as unknown as ReadStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Buffer 检测图片类型(使用首字节快速筛选)
|
||||||
|
export function detectImageTypeFromBuffer (buffer: Buffer): ImageType {
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
return ImageType.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstByte = buffer[0]!;
|
||||||
|
const possibleTypes = firstByteMap.get(firstByte);
|
||||||
|
|
||||||
|
if (possibleTypes) {
|
||||||
|
// 根据首字节快速筛选可能的类型
|
||||||
|
for (const type of possibleTypes) {
|
||||||
|
const parser = typeToParser.get(type);
|
||||||
|
if (parser && parser.canParse(buffer)) {
|
||||||
|
return parser.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:遍历所有解析器
|
||||||
|
for (const parser of parsers) {
|
||||||
|
if (parser.canParse(buffer)) {
|
||||||
|
return parser.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImageType.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Buffer 解析图片尺寸
|
||||||
|
export async function imageSizeFromBuffer (buffer: Buffer): Promise<ImageSize | undefined> {
|
||||||
|
const type = detectImageTypeFromBuffer(buffer);
|
||||||
|
const parser = typeToParser.get(type);
|
||||||
|
if (!parser) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = bufferToReadStream(buffer);
|
||||||
|
return await parser.parseSize(stream);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`解析图片尺寸出错: ${err}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Buffer 解析图片尺寸,带回退值
|
||||||
|
export async function imageSizeFromBufferFallBack (
|
||||||
|
buffer: Buffer,
|
||||||
|
fallback: ImageSize = {
|
||||||
|
width: 1024,
|
||||||
|
height: 1024,
|
||||||
|
}
|
||||||
|
): Promise<ImageSize> {
|
||||||
|
return await imageSizeFromBuffer(buffer) ?? fallback;
|
||||||
|
}
|
||||||
|
|||||||
32
packages/napcat-image-size/src/parser/BmpParser.ts
Normal file
32
packages/napcat-image-size/src/parser/BmpParser.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||||
|
import { ReadStream } from 'fs';
|
||||||
|
|
||||||
|
// BMP解析器
|
||||||
|
export class BmpParser implements ImageParser {
|
||||||
|
readonly type = ImageType.BMP;
|
||||||
|
// BMP 魔术头:42 4D (BM)
|
||||||
|
private readonly BMP_SIGNATURE = [0x42, 0x4D];
|
||||||
|
|
||||||
|
canParse (buffer: Buffer): boolean {
|
||||||
|
return matchMagic(buffer, this.BMP_SIGNATURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
stream.once('error', reject);
|
||||||
|
stream.once('readable', () => {
|
||||||
|
const buf = stream.read(26) as Buffer;
|
||||||
|
if (!buf || buf.length < 26) {
|
||||||
|
return resolve(undefined);
|
||||||
|
}
|
||||||
|
if (this.canParse(buf)) {
|
||||||
|
const width = buf.readUInt32LE(18);
|
||||||
|
const height = buf.readUInt32LE(22);
|
||||||
|
resolve({ width, height });
|
||||||
|
} else {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
37
packages/napcat-image-size/src/parser/GifParser.ts
Normal file
37
packages/napcat-image-size/src/parser/GifParser.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||||
|
import { ReadStream } from 'fs';
|
||||||
|
|
||||||
|
// GIF解析器
|
||||||
|
export class GifParser implements ImageParser {
|
||||||
|
readonly type = ImageType.GIF;
|
||||||
|
// GIF87a 魔术头:47 49 46 38 37 61
|
||||||
|
private readonly GIF87A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
|
||||||
|
// GIF89a 魔术头:47 49 46 38 39 61
|
||||||
|
private readonly GIF89A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
|
||||||
|
|
||||||
|
canParse (buffer: Buffer): boolean {
|
||||||
|
return (
|
||||||
|
matchMagic(buffer, this.GIF87A_SIGNATURE) ||
|
||||||
|
matchMagic(buffer, this.GIF89A_SIGNATURE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
stream.once('error', reject);
|
||||||
|
stream.once('readable', () => {
|
||||||
|
const buf = stream.read(10) as Buffer;
|
||||||
|
if (!buf || buf.length < 10) {
|
||||||
|
return resolve(undefined);
|
||||||
|
}
|
||||||
|
if (this.canParse(buf)) {
|
||||||
|
const width = buf.readUInt16LE(6);
|
||||||
|
const height = buf.readUInt16LE(8);
|
||||||
|
resolve({ width, height });
|
||||||
|
} else {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
123
packages/napcat-image-size/src/parser/JpegParser.ts
Normal file
123
packages/napcat-image-size/src/parser/JpegParser.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||||
|
import { ReadStream } from 'fs';
|
||||||
|
|
||||||
|
// JPEG解析器
|
||||||
|
export class JpegParser implements ImageParser {
|
||||||
|
readonly type = ImageType.JPEG;
|
||||||
|
// JPEG 魔术头:FF D8
|
||||||
|
private readonly JPEG_SIGNATURE = [0xFF, 0xD8];
|
||||||
|
|
||||||
|
// JPEG标记常量
|
||||||
|
private readonly SOF_MARKERS = {
|
||||||
|
SOF0: 0xC0, // 基线DCT
|
||||||
|
SOF1: 0xC1, // 扩展顺序DCT
|
||||||
|
SOF2: 0xC2, // 渐进式DCT
|
||||||
|
SOF3: 0xC3, // 无损
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 非SOF标记
|
||||||
|
private readonly NON_SOF_MARKERS: number[] = [
|
||||||
|
0xC4, // DHT
|
||||||
|
0xC8, // JPEG扩展
|
||||||
|
0xCC, // DAC
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
canParse (buffer: Buffer): boolean {
|
||||||
|
return matchMagic(buffer, this.JPEG_SIGNATURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
isSOFMarker (marker: number): boolean {
|
||||||
|
return (
|
||||||
|
marker === this.SOF_MARKERS.SOF0 ||
|
||||||
|
marker === this.SOF_MARKERS.SOF1 ||
|
||||||
|
marker === this.SOF_MARKERS.SOF2 ||
|
||||||
|
marker === this.SOF_MARKERS.SOF3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isNonSOFMarker (marker: number): boolean {
|
||||||
|
return this.NON_SOF_MARKERS.includes(marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||||
|
return new Promise<ImageSize | undefined>((resolve, reject) => {
|
||||||
|
const BUFFER_SIZE = 1024; // 读取块大小,可以根据需要调整
|
||||||
|
let buffer = Buffer.alloc(0);
|
||||||
|
let offset = 0;
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
// 处理错误
|
||||||
|
stream.on('error', (err) => {
|
||||||
|
stream.destroy();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理数据块
|
||||||
|
stream.on('data', (chunk: Buffer | string) => {
|
||||||
|
// 追加新数据到缓冲区
|
||||||
|
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
buffer = Buffer.concat([buffer.subarray(offset), chunkBuffer]);
|
||||||
|
offset = 0;
|
||||||
|
|
||||||
|
// 保持缓冲区在合理大小内,只保留最后的部分用于跨块匹配
|
||||||
|
const bufferSize = buffer.length;
|
||||||
|
const MIN_REQUIRED_BYTES = 10; // SOF段最低字节数
|
||||||
|
|
||||||
|
|
||||||
|
// 从JPEG头部后开始扫描
|
||||||
|
while (offset < bufferSize - MIN_REQUIRED_BYTES) {
|
||||||
|
// 寻找FF标记
|
||||||
|
if (buffer[offset] === 0xFF && buffer[offset + 1]! >= 0xC0 && buffer[offset + 1]! <= 0xCF) {
|
||||||
|
const marker = buffer[offset + 1];
|
||||||
|
if (!marker) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 跳过非SOF标记
|
||||||
|
if (this.isNonSOFMarker(marker)) {
|
||||||
|
offset += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理SOF标记 (包含尺寸信息)
|
||||||
|
if (this.isSOFMarker(marker)) {
|
||||||
|
// 确保缓冲区中有足够数据读取尺寸
|
||||||
|
if (offset + 9 < bufferSize) {
|
||||||
|
// 解析尺寸: FF XX YY YY PP HH HH WW WW ...
|
||||||
|
// XX = 标记, YY YY = 段长度, PP = 精度, HH HH = 高, WW WW = 宽
|
||||||
|
const height = buffer.readUInt16BE(offset + 5);
|
||||||
|
const width = buffer.readUInt16BE(offset + 7);
|
||||||
|
|
||||||
|
found = true;
|
||||||
|
stream.destroy();
|
||||||
|
resolve({ width, height });
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// 如果缓冲区内数据不够,保留当前位置等待更多数据
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓冲区管理: 如果处理了许多数据但没找到标记,
|
||||||
|
// 保留最后N字节用于跨块匹配,丢弃之前的数据
|
||||||
|
if (offset > BUFFER_SIZE) {
|
||||||
|
const KEEP_BYTES = 20; // 保留足够数据以处理跨块边界的情况
|
||||||
|
if (offset > KEEP_BYTES) {
|
||||||
|
buffer = buffer.subarray(offset - KEEP_BYTES);
|
||||||
|
offset = KEEP_BYTES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理流结束
|
||||||
|
stream.on('end', () => {
|
||||||
|
if (!found) {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
32
packages/napcat-image-size/src/parser/PngParser.ts
Normal file
32
packages/napcat-image-size/src/parser/PngParser.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||||
|
import { ReadStream } from 'fs';
|
||||||
|
|
||||||
|
// PNG解析器
|
||||||
|
export class PngParser implements ImageParser {
|
||||||
|
readonly type = ImageType.PNG;
|
||||||
|
// PNG 魔术头:89 50 4E 47 0D 0A 1A 0A
|
||||||
|
private readonly PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||||
|
|
||||||
|
canParse (buffer: Buffer): boolean {
|
||||||
|
return matchMagic(buffer, this.PNG_SIGNATURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
stream.once('error', reject);
|
||||||
|
stream.once('readable', () => {
|
||||||
|
const buf = stream.read(24) as Buffer;
|
||||||
|
if (!buf || buf.length < 24) {
|
||||||
|
return resolve(undefined);
|
||||||
|
}
|
||||||
|
if (this.canParse(buf)) {
|
||||||
|
const width = buf.readUInt32BE(16);
|
||||||
|
const height = buf.readUInt32BE(20);
|
||||||
|
resolve({ width, height });
|
||||||
|
} else {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
124
packages/napcat-image-size/src/parser/TiffParser.ts
Normal file
124
packages/napcat-image-size/src/parser/TiffParser.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||||
|
import { ReadStream } from 'fs';
|
||||||
|
|
||||||
|
// TIFF解析器
|
||||||
|
export class TiffParser implements ImageParser {
|
||||||
|
readonly type = ImageType.TIFF;
|
||||||
|
// TIFF Little Endian 魔术头:49 49 2A 00 (II)
|
||||||
|
private readonly TIFF_LE_SIGNATURE = [0x49, 0x49, 0x2A, 0x00];
|
||||||
|
// TIFF Big Endian 魔术头:4D 4D 00 2A (MM)
|
||||||
|
private readonly TIFF_BE_SIGNATURE = [0x4D, 0x4D, 0x00, 0x2A];
|
||||||
|
|
||||||
|
canParse (buffer: Buffer): boolean {
|
||||||
|
return (
|
||||||
|
matchMagic(buffer, this.TIFF_LE_SIGNATURE) ||
|
||||||
|
matchMagic(buffer, this.TIFF_BE_SIGNATURE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
const MAX_BYTES = 64 * 1024; // 最多读取 64KB
|
||||||
|
|
||||||
|
stream.on('error', reject);
|
||||||
|
|
||||||
|
stream.on('data', (chunk: Buffer | string) => {
|
||||||
|
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
chunks.push(chunkBuffer);
|
||||||
|
totalBytes += chunkBuffer.length;
|
||||||
|
|
||||||
|
if (totalBytes >= MAX_BYTES) {
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
const size = this.parseTiffSize(buffer);
|
||||||
|
resolve(size);
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('close', () => {
|
||||||
|
if (chunks.length > 0) {
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
const size = this.parseTiffSize(buffer);
|
||||||
|
resolve(size);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseTiffSize (buffer: Buffer): ImageSize | undefined {
|
||||||
|
if (buffer.length < 8) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断字节序
|
||||||
|
const isLittleEndian = buffer[0] === 0x49; // 'I'
|
||||||
|
|
||||||
|
const readUInt16 = isLittleEndian
|
||||||
|
? (offset: number) => buffer.readUInt16LE(offset)
|
||||||
|
: (offset: number) => buffer.readUInt16BE(offset);
|
||||||
|
|
||||||
|
const readUInt32 = isLittleEndian
|
||||||
|
? (offset: number) => buffer.readUInt32LE(offset)
|
||||||
|
: (offset: number) => buffer.readUInt32BE(offset);
|
||||||
|
|
||||||
|
// 获取第一个 IFD 的偏移量
|
||||||
|
const ifdOffset = readUInt32(4);
|
||||||
|
if (ifdOffset + 2 > buffer.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 IFD 条目数量
|
||||||
|
const numEntries = readUInt16(ifdOffset);
|
||||||
|
let width: number | undefined;
|
||||||
|
let height: number | undefined;
|
||||||
|
|
||||||
|
// TIFF 标签
|
||||||
|
const TAG_IMAGE_WIDTH = 0x0100;
|
||||||
|
const TAG_IMAGE_HEIGHT = 0x0101;
|
||||||
|
|
||||||
|
// 遍历 IFD 条目
|
||||||
|
for (let i = 0; i < numEntries; i++) {
|
||||||
|
const entryOffset = ifdOffset + 2 + i * 12;
|
||||||
|
if (entryOffset + 12 > buffer.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = readUInt16(entryOffset);
|
||||||
|
const type = readUInt16(entryOffset + 2);
|
||||||
|
// const count = readUInt32(entryOffset + 4);
|
||||||
|
|
||||||
|
// 根据类型读取值
|
||||||
|
let value: number;
|
||||||
|
if (type === 3) {
|
||||||
|
// SHORT (2 bytes)
|
||||||
|
value = readUInt16(entryOffset + 8);
|
||||||
|
} else if (type === 4) {
|
||||||
|
// LONG (4 bytes)
|
||||||
|
value = readUInt32(entryOffset + 8);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === TAG_IMAGE_WIDTH) {
|
||||||
|
width = value;
|
||||||
|
} else if (tag === TAG_IMAGE_HEIGHT) {
|
||||||
|
height = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width !== undefined && height !== undefined) {
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width !== undefined && height !== undefined) {
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
packages/napcat-image-size/src/parser/WebpParser.ts
Normal file
90
packages/napcat-image-size/src/parser/WebpParser.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||||
|
import { ReadStream } from 'fs';
|
||||||
|
|
||||||
|
// WEBP解析器 - 完整支持VP8, VP8L, VP8X格式
|
||||||
|
export class WebpParser implements ImageParser {
|
||||||
|
readonly type = ImageType.WEBP;
|
||||||
|
// WEBP RIFF 头:52 49 46 46 (RIFF)
|
||||||
|
private readonly RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46];
|
||||||
|
// WEBP 魔术头:57 45 42 50 (WEBP)
|
||||||
|
private readonly WEBP_SIGNATURE = [0x57, 0x45, 0x42, 0x50];
|
||||||
|
|
||||||
|
// WEBP 块头
|
||||||
|
private readonly CHUNK_VP8 = [0x56, 0x50, 0x38, 0x20]; // "VP8 "
|
||||||
|
private readonly CHUNK_VP8L = [0x56, 0x50, 0x38, 0x4C]; // "VP8L"
|
||||||
|
private readonly CHUNK_VP8X = [0x56, 0x50, 0x38, 0x58]; // "VP8X"
|
||||||
|
|
||||||
|
canParse (buffer: Buffer): boolean {
|
||||||
|
return (
|
||||||
|
buffer.length >= 12 &&
|
||||||
|
matchMagic(buffer, this.RIFF_SIGNATURE, 0) &&
|
||||||
|
matchMagic(buffer, this.WEBP_SIGNATURE, 8)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isChunkType (buffer: Buffer, offset: number, chunkType: number[]): boolean {
|
||||||
|
return buffer.length >= offset + 4 && matchMagic(buffer, chunkType, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 需要读取足够的字节来检测所有三种格式
|
||||||
|
const MAX_HEADER_SIZE = 32;
|
||||||
|
let totalBytes = 0;
|
||||||
|
let buffer = Buffer.alloc(0);
|
||||||
|
|
||||||
|
stream.on('error', reject);
|
||||||
|
|
||||||
|
stream.on('data', (chunk: Buffer | string) => {
|
||||||
|
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
buffer = Buffer.concat([buffer, chunkBuffer]);
|
||||||
|
totalBytes += chunk.length;
|
||||||
|
|
||||||
|
// 检查是否有足够的字节进行格式检测
|
||||||
|
if (totalBytes >= MAX_HEADER_SIZE) {
|
||||||
|
stream.destroy();
|
||||||
|
|
||||||
|
// 检查基本的WEBP签名
|
||||||
|
if (!this.canParse(buffer)) {
|
||||||
|
return resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查chunk头部,位于字节12-15
|
||||||
|
if (this.isChunkType(buffer, 12, this.CHUNK_VP8)) {
|
||||||
|
// VP8格式 - 标准WebP
|
||||||
|
// 宽度和高度在帧头中
|
||||||
|
const width = buffer.readUInt16LE(26) & 0x3FFF;
|
||||||
|
const height = buffer.readUInt16LE(28) & 0x3FFF;
|
||||||
|
return resolve({ width, height });
|
||||||
|
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8L)) {
|
||||||
|
// VP8L格式 - 无损WebP
|
||||||
|
// 1字节标记后是14位宽度和14位高度
|
||||||
|
const bits = buffer.readUInt32LE(21);
|
||||||
|
const width = 1 + (bits & 0x3FFF);
|
||||||
|
const height = 1 + ((bits >> 14) & 0x3FFF);
|
||||||
|
return resolve({ width, height });
|
||||||
|
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8X)) {
|
||||||
|
// VP8X格式 - 扩展WebP
|
||||||
|
// 24位宽度和高度(减去1)
|
||||||
|
if (buffer.length < 30) {
|
||||||
|
return resolve(undefined);
|
||||||
|
}
|
||||||
|
const width = 1 + ((buffer[24]! | (buffer[25]! << 8) | (buffer[26]! << 16)) & 0xFFFFFF);
|
||||||
|
const height = 1 + ((buffer[27]! | (buffer[28]! << 8) | (buffer[29]! << 16)) & 0xFFFFFF);
|
||||||
|
return resolve({ width, height });
|
||||||
|
} else {
|
||||||
|
// 未知的WebP子格式
|
||||||
|
return resolve(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
// 如果没有读到足够的字节
|
||||||
|
if (totalBytes < MAX_HEADER_SIZE) {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import { Type } from '@sinclair/typebox';
|
|||||||
|
|
||||||
export class BotExit extends OneBotAction<void, void> {
|
export class BotExit extends OneBotAction<void, void> {
|
||||||
override actionName = ActionName.Exit;
|
override actionName = ActionName.Exit;
|
||||||
override payloadSchema = Type.Void();
|
override payloadSchema = Type.Object({});
|
||||||
override returnSchema = Type.Void();
|
override returnSchema = Type.Object({});
|
||||||
override actionSummary = '退出登录';
|
override actionSummary = '退出登录';
|
||||||
override actionTags = ['系统扩展'];
|
override actionTags = ['系统扩展'];
|
||||||
override payloadExample = {};
|
override payloadExample = {};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
|||||||
|
|
||||||
export class GetClientkey extends OneBotAction<void, ReturnType> {
|
export class GetClientkey extends OneBotAction<void, ReturnType> {
|
||||||
override actionName = ActionName.GetClientkey;
|
override actionName = ActionName.GetClientkey;
|
||||||
override payloadSchema = Type.Void();
|
override payloadSchema = Type.Object({});
|
||||||
override returnSchema = ReturnSchema;
|
override returnSchema = ReturnSchema;
|
||||||
override actionSummary = '获取ClientKey';
|
override actionSummary = '获取ClientKey';
|
||||||
override actionDescription = '获取当前登录帐号的ClientKey';
|
override actionDescription = '获取当前登录帐号的ClientKey';
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
|||||||
|
|
||||||
export class GetFriendWithCategory extends OneBotAction<void, ReturnType> {
|
export class GetFriendWithCategory extends OneBotAction<void, ReturnType> {
|
||||||
override actionName = ActionName.GetFriendsWithCategory;
|
override actionName = ActionName.GetFriendsWithCategory;
|
||||||
override payloadSchema = Type.Void();
|
override payloadSchema = Type.Object({});
|
||||||
override returnSchema = ReturnSchema;
|
override returnSchema = ReturnSchema;
|
||||||
override actionSummary = '获取带分组的好友列表';
|
override actionSummary = '获取带分组的好友列表';
|
||||||
override actionTags = ['用户扩展'];
|
override actionTags = ['用户扩展'];
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
|||||||
|
|
||||||
export default class GetGroupAddRequest extends OneBotAction<void, ReturnType> {
|
export default class GetGroupAddRequest extends OneBotAction<void, ReturnType> {
|
||||||
override actionName = ActionName.GetGroupIgnoreAddRequest;
|
override actionName = ActionName.GetGroupIgnoreAddRequest;
|
||||||
override payloadSchema = Type.Void();
|
override payloadSchema = Type.Object({});
|
||||||
override returnSchema = ReturnSchema;
|
override returnSchema = ReturnSchema;
|
||||||
override actionSummary = '获取群被忽略的加群请求';
|
override actionSummary = '获取群被忽略的加群请求';
|
||||||
override actionTags = ['群组接口'];
|
override actionTags = ['群组接口'];
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
|||||||
|
|
||||||
export class GetRkey extends GetPacketStatusDepends<void, ReturnType> {
|
export class GetRkey extends GetPacketStatusDepends<void, ReturnType> {
|
||||||
override actionName = ActionName.GetRkey;
|
override actionName = ActionName.GetRkey;
|
||||||
override payloadSchema = Type.Void();
|
override payloadSchema = Type.Object({});
|
||||||
override returnSchema = ReturnSchema;
|
override returnSchema = ReturnSchema;
|
||||||
override actionSummary = '获取 RKey';
|
override actionSummary = '获取 RKey';
|
||||||
override actionTags = ['系统扩展'];
|
override actionTags = ['系统扩展'];
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export class GetRobotUinRange extends OneBotAction<void, ReturnType> {
|
|||||||
override returnExample = [
|
override returnExample = [
|
||||||
{ minUin: '12345678', maxUin: '87654321' }
|
{ minUin: '12345678', maxUin: '87654321' }
|
||||||
];
|
];
|
||||||
override payloadSchema = Type.Void();
|
override payloadSchema = Type.Object({});
|
||||||
override returnSchema = ReturnSchema;
|
override returnSchema = ReturnSchema;
|
||||||
|
|
||||||
async _handle () {
|
async _handle () {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
|||||||
|
|
||||||
export class GetUnidirectionalFriendList extends OneBotAction<void, ReturnType> {
|
export class GetUnidirectionalFriendList extends OneBotAction<void, ReturnType> {
|
||||||
override actionName = ActionName.GetUnidirectionalFriendList;
|
override actionName = ActionName.GetUnidirectionalFriendList;
|
||||||
override payloadSchema = Type.Void();
|
override payloadSchema = Type.Object({});
|
||||||
override returnSchema = ReturnSchema;
|
override returnSchema = ReturnSchema;
|
||||||
override actionSummary = '获取单向好友列表';
|
override actionSummary = '获取单向好友列表';
|
||||||
override actionTags = ['用户扩展'];
|
override actionTags = ['用户扩展'];
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { rawMsgWithSendMsg } from 'napcat-core/packet/message/converter';
|
|||||||
import { Static, Type } from '@sinclair/typebox';
|
import { Static, Type } from '@sinclair/typebox';
|
||||||
import { MsgActionsExamples } from '@/napcat-onebot/action/msg/examples';
|
import { MsgActionsExamples } from '@/napcat-onebot/action/msg/examples';
|
||||||
import { OB11MessageMixTypeSchema } from '@/napcat-onebot/types/message';
|
import { OB11MessageMixTypeSchema } from '@/napcat-onebot/types/message';
|
||||||
|
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
|
||||||
|
|
||||||
export const SendMsgPayloadSchema = Type.Object({
|
export const SendMsgPayloadSchema = Type.Object({
|
||||||
message_type: Type.Optional(Type.Union([Type.Literal('private'), Type.Literal('group')], { description: '消息类型 (private/group)' })),
|
message_type: Type.Optional(Type.Union([Type.Literal('private'), Type.Literal('group')], { description: '消息类型 (private/group)' })),
|
||||||
@@ -211,10 +212,14 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
|||||||
}, dp: number = 0): Promise<{
|
}, dp: number = 0): Promise<{
|
||||||
finallySendElements: SendArkElement,
|
finallySendElements: SendArkElement,
|
||||||
res_id?: string,
|
res_id?: string,
|
||||||
|
uuid?: string,
|
||||||
|
packetMsg: PacketMsg[],
|
||||||
deleteAfterSentFiles: string[],
|
deleteAfterSentFiles: string[],
|
||||||
|
innerPacketMsg?: Array<{ uuid: string, packetMsg: PacketMsg[]; }>;
|
||||||
} | null> {
|
} | null> {
|
||||||
const packetMsg: PacketMsg[] = [];
|
const packetMsg: PacketMsg[] = [];
|
||||||
const delFiles: string[] = [];
|
const delFiles: string[] = [];
|
||||||
|
const innerMsg: Array<{ uuid: string, packetMsg: PacketMsg[]; }> = new Array();
|
||||||
for (const node of messageNodes) {
|
for (const node of messageNodes) {
|
||||||
if (dp >= 3) {
|
if (dp >= 3) {
|
||||||
this.core.context.logger.logWarn('转发消息深度超过3层,将停止解析!');
|
this.core.context.logger.logWarn('转发消息深度超过3层,将停止解析!');
|
||||||
@@ -232,6 +237,13 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
|||||||
}, dp + 1);
|
}, dp + 1);
|
||||||
sendElements = uploadReturnData?.finallySendElements ? [uploadReturnData.finallySendElements] : [];
|
sendElements = uploadReturnData?.finallySendElements ? [uploadReturnData.finallySendElements] : [];
|
||||||
delFiles.push(...(uploadReturnData?.deleteAfterSentFiles || []));
|
delFiles.push(...(uploadReturnData?.deleteAfterSentFiles || []));
|
||||||
|
if (uploadReturnData?.uuid) {
|
||||||
|
innerMsg.push({ uuid: uploadReturnData.uuid, packetMsg: uploadReturnData.packetMsg });
|
||||||
|
uploadReturnData.innerPacketMsg?.forEach(m => {
|
||||||
|
innerMsg.push({ uuid: m.uuid, packetMsg: m.packetMsg });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
const sendElementsCreateReturn = await this.obContext.apis.MsgApi.createSendElements(OB11Data, msgPeer);
|
const sendElementsCreateReturn = await this.obContext.apis.MsgApi.createSendElements(OB11Data, msgPeer);
|
||||||
sendElements = sendElementsCreateReturn.sendElements;
|
sendElements = sendElementsCreateReturn.sendElements;
|
||||||
@@ -273,8 +285,19 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
|||||||
this.core.context.logger.logWarn('handleForwardedNodesPacket 元素为空!');
|
this.core.context.logger.logWarn('handleForwardedNodesPacket 元素为空!');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsg(packetMsg, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
|
const uploadMsgData: UploadForwardMsgParams[] = [{
|
||||||
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt);
|
actionCommand: 'MultiMsg',
|
||||||
|
actionMsg: packetMsg,
|
||||||
|
}];
|
||||||
|
innerMsg.forEach(({ uuid, packetMsg: msg }) => {
|
||||||
|
uploadMsgData.push({
|
||||||
|
actionCommand: uuid,
|
||||||
|
actionMsg: msg,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsgV2(uploadMsgData, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
|
||||||
|
const uuid = crypto.randomUUID();
|
||||||
|
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt, uuid);
|
||||||
return {
|
return {
|
||||||
deleteAfterSentFiles: delFiles,
|
deleteAfterSentFiles: delFiles,
|
||||||
finallySendElements: {
|
finallySendElements: {
|
||||||
@@ -285,6 +308,9 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
|||||||
},
|
},
|
||||||
} as SendArkElement,
|
} as SendArkElement,
|
||||||
res_id: resid,
|
res_id: resid,
|
||||||
|
uuid: uuid,
|
||||||
|
packetMsg: packetMsg,
|
||||||
|
innerPacketMsg: innerMsg,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export { PluginModule } from './plugin/types';
|
|||||||
export { PluginStatusConfig } from './plugin/types';
|
export { PluginStatusConfig } from './plugin/types';
|
||||||
export { PluginRouterRegistry, PluginRequestHandler, PluginApiRouteDefinition, PluginPageDefinition, HttpMethod } from './plugin/types';
|
export { PluginRouterRegistry, PluginRequestHandler, PluginApiRouteDefinition, PluginPageDefinition, HttpMethod } from './plugin/types';
|
||||||
export { PluginHttpRequest, PluginHttpResponse, PluginNextFunction } from './plugin/types';
|
export { PluginHttpRequest, PluginHttpResponse, PluginNextFunction } from './plugin/types';
|
||||||
|
export { MemoryStaticFile, MemoryFileGenerator } from './plugin/types';
|
||||||
export { PluginRouterRegistryImpl } from './plugin/router-registry';
|
export { PluginRouterRegistryImpl } from './plugin/router-registry';
|
||||||
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> implements IPluginManager {
|
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> implements IPluginManager {
|
||||||
private readonly pluginPath: string;
|
private readonly pluginPath: string;
|
||||||
@@ -179,6 +180,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
|||||||
this.pluginRouters.delete(entry.id);
|
this.pluginRouters.delete(entry.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理模块缓存
|
||||||
|
this.loader.clearCache(entry.pluginPath);
|
||||||
|
|
||||||
// 重置状态
|
// 重置状态
|
||||||
entry.loaded = false;
|
entry.loaded = false;
|
||||||
entry.runtime = {
|
entry.runtime = {
|
||||||
@@ -211,6 +215,15 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
|||||||
// 保存到路由注册表
|
// 保存到路由注册表
|
||||||
this.pluginRouters.set(entry.id, routerRegistry);
|
this.pluginRouters.set(entry.id, routerRegistry);
|
||||||
|
|
||||||
|
// 创建获取其他插件导出的方法
|
||||||
|
const getPluginExports = <T = any> (pluginId: string): T | undefined => {
|
||||||
|
const targetEntry = this.plugins.get(pluginId);
|
||||||
|
if (!targetEntry || !targetEntry.loaded || targetEntry.runtime.status !== 'loaded') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return targetEntry.runtime.module as T;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
core: this.core,
|
core: this.core,
|
||||||
oneBot: this.obContext,
|
oneBot: this.obContext,
|
||||||
@@ -224,6 +237,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
|||||||
pluginManager: this,
|
pluginManager: this,
|
||||||
logger: pluginLogger,
|
logger: pluginLogger,
|
||||||
router: routerRegistry,
|
router: routerRegistry,
|
||||||
|
getPluginExports,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +330,11 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
|||||||
entry = newEntry;
|
entry = newEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!entry.enable) {
|
||||||
|
this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return await this.loadPlugin(entry);
|
return await this.loadPlugin(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
import { LogWrapper } from 'napcat-core/helper/log';
|
import { LogWrapper } from 'napcat-core/helper/log';
|
||||||
import {
|
import {
|
||||||
PluginPackageJson,
|
PluginPackageJson,
|
||||||
@@ -123,8 +125,8 @@ export class PluginLoader {
|
|||||||
const entryFile = this.findEntryFile(pluginDir, packageJson);
|
const entryFile = this.findEntryFile(pluginDir, packageJson);
|
||||||
const entryPath = entryFile ? path.join(pluginDir, entryFile) : undefined;
|
const entryPath = entryFile ? path.join(pluginDir, entryFile) : undefined;
|
||||||
|
|
||||||
// 获取启用状态(默认启用)
|
// 获取启用状态(默认禁用,内置插件除外)
|
||||||
const enable = statusConfig[pluginId] !== false;
|
const enable = statusConfig[pluginId] ?? (pluginId === 'napcat-plugin-builtin');
|
||||||
|
|
||||||
// 创建插件条目
|
// 创建插件条目
|
||||||
const entry: PluginEntry = {
|
const entry: PluginEntry = {
|
||||||
@@ -159,7 +161,7 @@ export class PluginLoader {
|
|||||||
id: dirname, // 使用目录名作为 ID
|
id: dirname, // 使用目录名作为 ID
|
||||||
fileId: dirname,
|
fileId: dirname,
|
||||||
pluginPath: path.join(this.pluginPath, dirname),
|
pluginPath: path.join(this.pluginPath, dirname),
|
||||||
enable: statusConfig[dirname] !== false,
|
enable: statusConfig[dirname] ?? (dirname === 'napcat-plugin-builtin'),
|
||||||
loaded: false,
|
loaded: false,
|
||||||
runtime: {
|
runtime: {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
@@ -295,4 +297,24 @@ export class PluginLoader {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 清除插件文件的 require 缓存
|
||||||
|
* 用于确保卸载插件时清理 CJS 模块缓存
|
||||||
|
*/
|
||||||
|
clearCache (pluginPath: string): void {
|
||||||
|
try {
|
||||||
|
// 规范化路径以确保匹配正确
|
||||||
|
const normalizedPluginPath = path.resolve(pluginPath);
|
||||||
|
|
||||||
|
// 遍历缓存并删除属于该插件目录的模块
|
||||||
|
Object.keys(require.cache).forEach((id) => {
|
||||||
|
if (id.startsWith(normalizedPluginPath)) {
|
||||||
|
delete require.cache[id];
|
||||||
|
this.logger.logDebug(`[PluginLoader] Cleared cache for: ${id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.logError('[PluginLoader] Error clearing module cache:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,6 +194,15 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
|||||||
this.pluginRouters.set(entry.id, router);
|
this.pluginRouters.set(entry.id, router);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 创建获取其他插件导出的方法
|
||||||
|
const getPluginExports = <T = any> (pluginId: string): T | undefined => {
|
||||||
|
const targetEntry = this.plugins.get(pluginId);
|
||||||
|
if (!targetEntry || !targetEntry.loaded || targetEntry.runtime.status !== 'loaded') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return targetEntry.runtime.module as T;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
core: this.core,
|
core: this.core,
|
||||||
oneBot: this.obContext,
|
oneBot: this.obContext,
|
||||||
@@ -207,6 +216,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
|||||||
pluginManager: this,
|
pluginManager: this,
|
||||||
logger: pluginLogger,
|
logger: pluginLogger,
|
||||||
router,
|
router,
|
||||||
|
getPluginExports,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +295,11 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
|||||||
entry = newEntry;
|
entry = newEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!entry.enable) {
|
||||||
|
this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return await this.loadPlugin(entry);
|
return await this.loadPlugin(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Router, static as expressStatic, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {
|
import {
|
||||||
PluginRouterRegistry,
|
PluginRouterRegistry,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
PluginHttpRequest,
|
PluginHttpRequest,
|
||||||
PluginHttpResponse,
|
PluginHttpResponse,
|
||||||
HttpMethod,
|
HttpMethod,
|
||||||
|
MemoryStaticFile,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,10 +60,18 @@ function wrapResponse (res: Response): PluginHttpResponse {
|
|||||||
* 插件路由注册器实现
|
* 插件路由注册器实现
|
||||||
* 为每个插件创建独立的路由注册器,收集路由定义
|
* 为每个插件创建独立的路由注册器,收集路由定义
|
||||||
*/
|
*/
|
||||||
|
/** 内存静态路由定义 */
|
||||||
|
interface MemoryStaticRoute {
|
||||||
|
urlPath: string;
|
||||||
|
files: MemoryStaticFile[];
|
||||||
|
}
|
||||||
|
|
||||||
export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
||||||
private apiRoutes: PluginApiRouteDefinition[] = [];
|
private apiRoutes: PluginApiRouteDefinition[] = [];
|
||||||
|
private apiNoAuthRoutes: PluginApiRouteDefinition[] = [];
|
||||||
private pageDefinitions: PluginPageDefinition[] = [];
|
private pageDefinitions: PluginPageDefinition[] = [];
|
||||||
private staticRoutes: Array<{ urlPath: string; localPath: string; }> = [];
|
private staticRoutes: Array<{ urlPath: string; localPath: string; }> = [];
|
||||||
|
private memoryStaticRoutes: MemoryStaticRoute[] = [];
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
private readonly pluginId: string,
|
private readonly pluginId: string,
|
||||||
@@ -91,6 +100,28 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
|||||||
this.api('delete', routePath, handler);
|
this.api('delete', routePath, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 无认证 API 路由注册 ====================
|
||||||
|
|
||||||
|
apiNoAuth (method: HttpMethod, routePath: string, handler: PluginRequestHandler): void {
|
||||||
|
this.apiNoAuthRoutes.push({ method, path: routePath, handler });
|
||||||
|
}
|
||||||
|
|
||||||
|
getNoAuth (routePath: string, handler: PluginRequestHandler): void {
|
||||||
|
this.apiNoAuth('get', routePath, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
postNoAuth (routePath: string, handler: PluginRequestHandler): void {
|
||||||
|
this.apiNoAuth('post', routePath, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
putNoAuth (routePath: string, handler: PluginRequestHandler): void {
|
||||||
|
this.apiNoAuth('put', routePath, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteNoAuth (routePath: string, handler: PluginRequestHandler): void {
|
||||||
|
this.apiNoAuth('delete', routePath, handler);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 页面注册 ====================
|
// ==================== 页面注册 ====================
|
||||||
|
|
||||||
page (pageDef: PluginPageDefinition): void {
|
page (pageDef: PluginPageDefinition): void {
|
||||||
@@ -111,19 +142,19 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
|||||||
this.staticRoutes.push({ urlPath, localPath: absolutePath });
|
this.staticRoutes.push({ urlPath, localPath: absolutePath });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
staticOnMem (urlPath: string, files: MemoryStaticFile[]): void {
|
||||||
|
this.memoryStaticRoutes.push({ urlPath, files });
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 构建路由 ====================
|
// ==================== 构建路由 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建 Express Router(用于 API 路由)
|
* 构建 Express Router(用于 API 路由)
|
||||||
|
* 注意:静态资源路由不在此处挂载,由 webui-backend 直接在不需要鉴权的路径下处理
|
||||||
*/
|
*/
|
||||||
buildApiRouter (): Router {
|
buildApiRouter (): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// 注册静态文件路由
|
|
||||||
for (const { urlPath, localPath } of this.staticRoutes) {
|
|
||||||
router.use(urlPath, expressStatic(localPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注册 API 路由
|
// 注册 API 路由
|
||||||
for (const route of this.apiRoutes) {
|
for (const route of this.apiRoutes) {
|
||||||
const handler = this.wrapHandler(route.handler);
|
const handler = this.wrapHandler(route.handler);
|
||||||
@@ -176,10 +207,57 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
|||||||
// ==================== 查询方法 ====================
|
// ==================== 查询方法 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查是否有注册的 API 路由
|
* 检查是否有注册的 API 路由(需要认证)
|
||||||
*/
|
*/
|
||||||
hasApiRoutes (): boolean {
|
hasApiRoutes (): boolean {
|
||||||
return this.apiRoutes.length > 0 || this.staticRoutes.length > 0;
|
return this.apiRoutes.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有注册的无认证 API 路由
|
||||||
|
*/
|
||||||
|
hasApiNoAuthRoutes (): boolean {
|
||||||
|
return this.apiNoAuthRoutes.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建无认证 Express Router(用于 /plugin/{pluginId}/api/ 路径)
|
||||||
|
*/
|
||||||
|
buildApiNoAuthRouter (): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
for (const route of this.apiNoAuthRoutes) {
|
||||||
|
const handler = this.wrapHandler(route.handler);
|
||||||
|
switch (route.method) {
|
||||||
|
case 'get':
|
||||||
|
router.get(route.path, handler);
|
||||||
|
break;
|
||||||
|
case 'post':
|
||||||
|
router.post(route.path, handler);
|
||||||
|
break;
|
||||||
|
case 'put':
|
||||||
|
router.put(route.path, handler);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
router.delete(route.path, handler);
|
||||||
|
break;
|
||||||
|
case 'patch':
|
||||||
|
router.patch(route.path, handler);
|
||||||
|
break;
|
||||||
|
case 'all':
|
||||||
|
router.all(route.path, handler);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有注册的静态资源路由
|
||||||
|
*/
|
||||||
|
hasStaticRoutes (): boolean {
|
||||||
|
return this.staticRoutes.length > 0 || this.memoryStaticRoutes.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,12 +288,28 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
|||||||
return this.pluginPath;
|
return this.pluginPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有注册的静态路由
|
||||||
|
*/
|
||||||
|
getStaticRoutes (): Array<{ urlPath: string; localPath: string; }> {
|
||||||
|
return [...this.staticRoutes];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有注册的内存静态路由
|
||||||
|
*/
|
||||||
|
getMemoryStaticRoutes (): MemoryStaticRoute[] {
|
||||||
|
return [...this.memoryStaticRoutes];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清空路由(用于插件卸载)
|
* 清空路由(用于插件卸载)
|
||||||
*/
|
*/
|
||||||
clear (): void {
|
clear (): void {
|
||||||
this.apiRoutes = [];
|
this.apiRoutes = [];
|
||||||
|
this.apiNoAuthRoutes = [];
|
||||||
this.pageDefinitions = [];
|
this.pageDefinitions = [];
|
||||||
this.staticRoutes = [];
|
this.staticRoutes = [];
|
||||||
|
this.memoryStaticRoutes = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,26 +125,57 @@ export interface PluginPageDefinition {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 内存文件生成器 - 用于动态生成静态文件内容 */
|
||||||
|
export type MemoryFileGenerator = () => string | Buffer | Promise<string | Buffer>;
|
||||||
|
|
||||||
|
/** 内存静态文件定义 */
|
||||||
|
export interface MemoryStaticFile {
|
||||||
|
/** 文件路径(相对于 urlPath) */
|
||||||
|
path: string;
|
||||||
|
/** 文件内容或生成器 */
|
||||||
|
content: string | Buffer | MemoryFileGenerator;
|
||||||
|
/** 可选的 MIME 类型 */
|
||||||
|
contentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** 插件路由注册器 */
|
/** 插件路由注册器 */
|
||||||
export interface PluginRouterRegistry {
|
export interface PluginRouterRegistry {
|
||||||
// ==================== API 路由注册 ====================
|
// ==================== API 路由注册(需要认证) ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册单个 API 路由
|
* 注册单个 API 路由(需要认证,挂载到 /api/Plugin/ext/{pluginId}/)
|
||||||
* @param method HTTP 方法
|
* @param method HTTP 方法
|
||||||
* @param path 路由路径
|
* @param path 路由路径
|
||||||
* @param handler 请求处理器
|
* @param handler 请求处理器
|
||||||
*/
|
*/
|
||||||
api (method: HttpMethod, path: string, handler: PluginRequestHandler): void;
|
api (method: HttpMethod, path: string, handler: PluginRequestHandler): void;
|
||||||
/** 注册 GET API */
|
/** 注册 GET API(需要认证) */
|
||||||
get (path: string, handler: PluginRequestHandler): void;
|
get (path: string, handler: PluginRequestHandler): void;
|
||||||
/** 注册 POST API */
|
/** 注册 POST API(需要认证) */
|
||||||
post (path: string, handler: PluginRequestHandler): void;
|
post (path: string, handler: PluginRequestHandler): void;
|
||||||
/** 注册 PUT API */
|
/** 注册 PUT API(需要认证) */
|
||||||
put (path: string, handler: PluginRequestHandler): void;
|
put (path: string, handler: PluginRequestHandler): void;
|
||||||
/** 注册 DELETE API */
|
/** 注册 DELETE API(需要认证) */
|
||||||
delete (path: string, handler: PluginRequestHandler): void;
|
delete (path: string, handler: PluginRequestHandler): void;
|
||||||
|
|
||||||
|
// ==================== 无认证 API 路由注册 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册单个无认证 API 路由(挂载到 /plugin/{pluginId}/api/)
|
||||||
|
* @param method HTTP 方法
|
||||||
|
* @param path 路由路径
|
||||||
|
* @param handler 请求处理器
|
||||||
|
*/
|
||||||
|
apiNoAuth (method: HttpMethod, path: string, handler: PluginRequestHandler): void;
|
||||||
|
/** 注册 GET API(无认证) */
|
||||||
|
getNoAuth (path: string, handler: PluginRequestHandler): void;
|
||||||
|
/** 注册 POST API(无认证) */
|
||||||
|
postNoAuth (path: string, handler: PluginRequestHandler): void;
|
||||||
|
/** 注册 PUT API(无认证) */
|
||||||
|
putNoAuth (path: string, handler: PluginRequestHandler): void;
|
||||||
|
/** 注册 DELETE API(无认证) */
|
||||||
|
deleteNoAuth (path: string, handler: PluginRequestHandler): void;
|
||||||
|
|
||||||
// ==================== 页面注册 ====================
|
// ==================== 页面注册 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,6 +198,13 @@ export interface PluginRouterRegistry {
|
|||||||
* @param localPath 本地文件夹路径(相对于插件目录或绝对路径)
|
* @param localPath 本地文件夹路径(相对于插件目录或绝对路径)
|
||||||
*/
|
*/
|
||||||
static (urlPath: string, localPath: string): void;
|
static (urlPath: string, localPath: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供内存生成的静态文件服务
|
||||||
|
* @param urlPath URL 路径
|
||||||
|
* @param files 内存文件列表
|
||||||
|
*/
|
||||||
|
staticOnMem (urlPath: string, files: MemoryStaticFile[]): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 插件管理器接口 ====================
|
// ==================== 插件管理器接口 ====================
|
||||||
@@ -247,8 +285,15 @@ export interface NapCatPluginContext {
|
|||||||
/**
|
/**
|
||||||
* WebUI 路由注册器
|
* WebUI 路由注册器
|
||||||
* 用于注册插件的 HTTP API 路由,路由将挂载到 /api/Plugin/ext/{pluginId}/
|
* 用于注册插件的 HTTP API 路由,路由将挂载到 /api/Plugin/ext/{pluginId}/
|
||||||
|
* 静态资源将挂载到 /plugin/{pluginId}/files/{urlPath}/
|
||||||
*/
|
*/
|
||||||
router: PluginRouterRegistry;
|
router: PluginRouterRegistry;
|
||||||
|
/**
|
||||||
|
* 获取其他插件的导出模块
|
||||||
|
* @param pluginId 目标插件 ID
|
||||||
|
* @returns 插件导出的模块,如果插件未加载则返回 undefined
|
||||||
|
*/
|
||||||
|
getPluginExports: <T = PluginModule>(pluginId: string) => T | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 插件模块接口 ====================
|
// ==================== 插件模块接口 ====================
|
||||||
|
|||||||
@@ -65,10 +65,32 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
|
|||||||
|
|
||||||
// ==================== 注册 WebUI 路由示例 ====================
|
// ==================== 注册 WebUI 路由示例 ====================
|
||||||
|
|
||||||
// 注册静态资源目录(webui 目录下的文件可通过 /api/Plugin/ext/{pluginId}/static/ 访问)
|
// 注册静态资源目录
|
||||||
|
// 静态资源可通过 /plugin/{pluginId}/files/static/ 访问(无需鉴权)
|
||||||
ctx.router.static('/static', 'webui');
|
ctx.router.static('/static', 'webui');
|
||||||
|
|
||||||
// 注册 API 路由
|
// 注册内存生成的静态资源(无需鉴权)
|
||||||
|
// 可通过 /plugin/{pluginId}/mem/dynamic/info.json 访问
|
||||||
|
ctx.router.staticOnMem('/dynamic', [
|
||||||
|
{
|
||||||
|
path: '/info.json',
|
||||||
|
contentType: 'application/json',
|
||||||
|
// 使用生成器函数动态生成内容
|
||||||
|
content: () => JSON.stringify({
|
||||||
|
pluginName: ctx.pluginName,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
uptime: Date.now() - startTime,
|
||||||
|
config: currentConfig
|
||||||
|
}, null, 2)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/readme.txt',
|
||||||
|
contentType: 'text/plain',
|
||||||
|
content: `NapCat Builtin Plugin\n=====================\nThis is a demonstration of the staticOnMem feature.\nPlugin: ${ctx.pluginName}\nPath: ${ctx.pluginPath}`
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 注册 API 路由(需要鉴权,挂载到 /api/Plugin/ext/{pluginId}/)
|
||||||
ctx.router.get('/status', (_req, res) => {
|
ctx.router.get('/status', (_req, res) => {
|
||||||
const uptime = Date.now() - startTime;
|
const uptime = Date.now() - startTime;
|
||||||
res.json({
|
res.json({
|
||||||
@@ -107,7 +129,74 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 注册扩展页面
|
// ==================== 无认证 API 路由示例 ====================
|
||||||
|
// 路由挂载到 /plugin/{pluginId}/api/,无需 WebUI 登录即可访问
|
||||||
|
|
||||||
|
// 获取插件公开信息(无需鉴权)
|
||||||
|
ctx.router.getNoAuth('/public/info', (_req, res) => {
|
||||||
|
const uptime = Date.now() - startTime;
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
pluginName: ctx.pluginName,
|
||||||
|
uptime,
|
||||||
|
uptimeFormatted: formatUptime(uptime),
|
||||||
|
platform: process.platform
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 健康检查接口(无需鉴权)
|
||||||
|
ctx.router.getNoAuth('/health', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 插件互调用示例 ====================
|
||||||
|
// 演示如何调用其他插件的导出方法
|
||||||
|
ctx.router.get('/call-plugin/:pluginId', (req, res) => {
|
||||||
|
const { pluginId } = req.params;
|
||||||
|
|
||||||
|
if (!pluginId) {
|
||||||
|
res.status(400).json({
|
||||||
|
code: -1,
|
||||||
|
message: 'Plugin ID is required'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 getPluginExports 获取其他插件的导出模块
|
||||||
|
const targetPlugin = ctx.getPluginExports<PluginModule>(pluginId);
|
||||||
|
|
||||||
|
if (!targetPlugin) {
|
||||||
|
res.status(404).json({
|
||||||
|
code: -1,
|
||||||
|
message: `Plugin '${pluginId}' not found or not loaded`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回目标插件的信息
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
pluginId,
|
||||||
|
hasInit: typeof targetPlugin.plugin_init === 'function',
|
||||||
|
hasOnMessage: typeof targetPlugin.plugin_onmessage === 'function',
|
||||||
|
hasOnEvent: typeof targetPlugin.plugin_onevent === 'function',
|
||||||
|
hasCleanup: typeof targetPlugin.plugin_cleanup === 'function',
|
||||||
|
hasConfigSchema: Array.isArray(targetPlugin.plugin_config_schema),
|
||||||
|
hasConfigUI: Array.isArray(targetPlugin.plugin_config_ui),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册扩展页面(无需鉴权,可通过 /plugin/{pluginId}/page/dashboard 访问)
|
||||||
ctx.router.page({
|
ctx.router.page({
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
title: '插件仪表盘',
|
title: '插件仪表盘',
|
||||||
@@ -116,7 +205,12 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
|
|||||||
description: '查看内置插件的运行状态和配置'
|
description: '查看内置插件的运行状态和配置'
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('WebUI 路由已注册: /api/Plugin/ext/' + ctx.pluginName);
|
logger.info('WebUI 路由已注册:');
|
||||||
|
logger.info(' - API 路由(需认证): /api/Plugin/ext/' + ctx.pluginName + '/');
|
||||||
|
logger.info(' - API 路由(无认证): /plugin/' + ctx.pluginName + '/api/');
|
||||||
|
logger.info(' - 扩展页面: /plugin/' + ctx.pluginName + '/page/dashboard');
|
||||||
|
logger.info(' - 静态资源: /plugin/' + ctx.pluginName + '/files/static/');
|
||||||
|
logger.info(' - 内存资源: /plugin/' + ctx.pluginName + '/mem/dynamic/');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const plugin_get_config: PluginModule['plugin_get_config'] = async () => {
|
export const plugin_get_config: PluginModule['plugin_get_config'] = async () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"description": "NapCat 内置插件",
|
"description": "NapCat 内置插件",
|
||||||
"author": "NapNeko",
|
"author": "NapNeko",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"napcat-types": "0.0.14"
|
"napcat-types": "0.0.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.1"
|
"@types/node": "^22.0.1"
|
||||||
|
|||||||
@@ -259,11 +259,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="static-content">
|
<div id="static-content">
|
||||||
<p style="color: var(--text-secondary); font-size: 13px; margin-bottom: 12px;">
|
<p style="color: var(--text-secondary); font-size: 13px; margin-bottom: 12px;">
|
||||||
测试插件静态资源服务是否正常工作
|
测试插件静态资源服务是否正常工作(不需要鉴权)
|
||||||
</p>
|
</p>
|
||||||
<div class="actions" style="margin-top: 0;">
|
<div class="actions" style="margin-top: 0;">
|
||||||
<button class="btn btn-primary" onclick="testStaticResource()">
|
<button class="btn btn-primary" onclick="testStaticResource()">
|
||||||
获取 test.txt
|
获取 test.txt(文件系统)
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" onclick="testMemoryResource()">
|
||||||
|
获取 info.json(内存生成)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="static-result" style="margin-top: 12px;"></div>
|
<div id="static-result" style="margin-top: 12px;"></div>
|
||||||
@@ -276,12 +279,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// 从 URL 参数获取 webui_token
|
// 从 localStorage 获取 token(与父页面同源,可直接访问)
|
||||||
|
// 兼容旧版:如果 URL 有 webui_token 参数则优先使用
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const webuiToken = urlParams.get('webui_token') || '';
|
const webuiToken = localStorage.getItem('token') || '';
|
||||||
|
|
||||||
// 插件自行管理 API 调用
|
// 插件 API 基础路径(需要鉴权)
|
||||||
const apiBase = '/api/Plugin/ext/napcat-plugin-builtin';
|
const apiBase = '/api/Plugin/ext/napcat-plugin-builtin';
|
||||||
|
// 插件静态资源基础路径(不需要鉴权)
|
||||||
|
const staticBase = '/plugin/napcat-plugin-builtin/files';
|
||||||
|
// 插件内存资源基础路径(不需要鉴权)
|
||||||
|
const memBase = '/plugin/napcat-plugin-builtin/mem';
|
||||||
|
|
||||||
// 封装 fetch,自动携带认证
|
// 封装 fetch,自动携带认证
|
||||||
async function authFetch (url, options = {}) {
|
async function authFetch (url, options = {}) {
|
||||||
@@ -392,12 +400,13 @@
|
|||||||
resultDiv.innerHTML = '<div class="loading">加载中</div>';
|
resultDiv.innerHTML = '<div class="loading">加载中</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authFetch(`${apiBase}/static/test.txt`);
|
// 静态资源不需要鉴权,直接请求
|
||||||
|
const response = await fetch(`${staticBase}/static/test.txt`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
resultDiv.innerHTML = `
|
resultDiv.innerHTML = `
|
||||||
<div style="background: var(--bg-item); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px;">
|
<div style="background: var(--bg-item); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px;">
|
||||||
<div style="font-size: 11px; color: var(--success-color); margin-bottom: 8px;">静态资源访问成功</div>
|
<div style="font-size: 11px; color: var(--success-color); margin-bottom: 8px;">文件系统静态资源访问成功</div>
|
||||||
<pre style="font-family: Monaco, Consolas, monospace; font-size: 12px; color: var(--text-primary); white-space: pre-wrap; margin: 0;">${text}</pre>
|
<pre style="font-family: Monaco, Consolas, monospace; font-size: 12px; color: var(--text-primary); white-space: pre-wrap; margin: 0;">${text}</pre>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -408,6 +417,30 @@
|
|||||||
resultDiv.innerHTML = `<div class="error">请求失败: ${error.message}</div>`;
|
resultDiv.innerHTML = `<div class="error">请求失败: ${error.message}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 测试内存资源
|
||||||
|
async function testMemoryResource () {
|
||||||
|
const resultDiv = document.getElementById('static-result');
|
||||||
|
resultDiv.innerHTML = '<div class="loading">加载中</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 内存资源不需要鉴权,直接请求
|
||||||
|
const response = await fetch(`${memBase}/dynamic/info.json`);
|
||||||
|
if (response.ok) {
|
||||||
|
const json = await response.json();
|
||||||
|
resultDiv.innerHTML = `
|
||||||
|
<div style="background: var(--bg-item); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px;">
|
||||||
|
<div style="font-size: 11px; color: var(--success-color); margin-bottom: 8px;">内存生成资源访问成功</div>
|
||||||
|
<pre style="font-family: Monaco, Consolas, monospace; font-size: 12px; color: var(--text-primary); white-space: pre-wrap; margin: 0;">${JSON.stringify(json, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
resultDiv.innerHTML = `<div class="error">请求失败: ${response.status} ${response.statusText}</div>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
resultDiv.innerHTML = `<div class="error">请求失败: ${error.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
25
packages/napcat-rpc/package.json
Normal file
25
packages/napcat-rpc/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "napcat-rpc",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./src/*": {
|
||||||
|
"import": "./src/*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
366
packages/napcat-rpc/src/client.ts
Normal file
366
packages/napcat-rpc/src/client.ts
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import {
|
||||||
|
type DeepProxyOptions,
|
||||||
|
type ProxyMeta,
|
||||||
|
RpcOperationType,
|
||||||
|
PROXY_META,
|
||||||
|
type RpcRequest,
|
||||||
|
} from './types.js';
|
||||||
|
import {
|
||||||
|
serialize,
|
||||||
|
deserialize,
|
||||||
|
SimpleCallbackRegistry,
|
||||||
|
extractCallbackIds,
|
||||||
|
} from './serializer.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成唯一请求 ID
|
||||||
|
*/
|
||||||
|
function generateRequestId (): string {
|
||||||
|
return `req_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建深层 RPC 代理
|
||||||
|
*
|
||||||
|
* 将所有属性访问、方法调用等操作转换为 RPC 请求
|
||||||
|
*/
|
||||||
|
export function createDeepProxy<T = unknown> (options: DeepProxyOptions): T {
|
||||||
|
const {
|
||||||
|
transport,
|
||||||
|
rootPath = [],
|
||||||
|
refId: rootRefId,
|
||||||
|
// callbackTimeout 可供未来扩展使用
|
||||||
|
} = options;
|
||||||
|
void options.callbackTimeout;
|
||||||
|
|
||||||
|
const callbackRegistry = new SimpleCallbackRegistry();
|
||||||
|
|
||||||
|
// 注册回调处理器
|
||||||
|
if (transport.onCallback) {
|
||||||
|
transport.onCallback(async (callbackId, serializedArgs) => {
|
||||||
|
const callback = callbackRegistry.get(callbackId);
|
||||||
|
if (!callback) {
|
||||||
|
throw new Error(`Callback not found: ${callbackId}`);
|
||||||
|
}
|
||||||
|
const args = serializedArgs.map(arg => deserialize(arg, {
|
||||||
|
callbackResolver: (id) => {
|
||||||
|
const cb = callbackRegistry.get(id);
|
||||||
|
if (!cb) throw new Error(`Nested callback not found: ${id}`);
|
||||||
|
return cb;
|
||||||
|
},
|
||||||
|
proxyCreator: (path, proxyRefId, cachedProps) => createProxyAtPath(path, proxyRefId, cachedProps),
|
||||||
|
}));
|
||||||
|
const result = await callback(...args);
|
||||||
|
return serialize(result, { callbackRegistry });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在指定路径创建代理
|
||||||
|
* @param path 路径
|
||||||
|
* @param refId 远程对象引用 ID
|
||||||
|
* @param cachedProps 缓存的属性值(避免属性访问需要 RPC)
|
||||||
|
*/
|
||||||
|
function createProxyAtPath (path: PropertyKey[], refId?: string, cachedProps?: Record<string, unknown>): unknown {
|
||||||
|
const proxyMeta: ProxyMeta = {
|
||||||
|
path: [...path],
|
||||||
|
isProxy: true,
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建一个函数目标,以支持 apply 和 construct
|
||||||
|
const target = function () { } as unknown as Record<PropertyKey, unknown>;
|
||||||
|
|
||||||
|
return new Proxy(target, {
|
||||||
|
get (_target, prop) {
|
||||||
|
// 返回代理元数据
|
||||||
|
if (prop === PROXY_META) {
|
||||||
|
return proxyMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// then 方法特殊处理,使代理可以被 await
|
||||||
|
if (prop === 'then') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查缓存属性(仅顶层代理,即 path 为空时)
|
||||||
|
if (path.length === 0 && cachedProps && typeof prop === 'string' && prop in cachedProps) {
|
||||||
|
return cachedProps[prop];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回新的子路径代理(继承 refId,不继承 cachedProps)
|
||||||
|
return createProxyAtPath([...path, prop], refId);
|
||||||
|
},
|
||||||
|
|
||||||
|
set (_target, prop, value) {
|
||||||
|
const request: RpcRequest = {
|
||||||
|
id: generateRequestId(),
|
||||||
|
type: RpcOperationType.SET,
|
||||||
|
path: [...path, prop],
|
||||||
|
args: [serialize(value, { callbackRegistry })],
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 同步返回,但实际是异步操作
|
||||||
|
transport.send(request).catch(() => { /* ignore */ });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
apply (_target, _thisArg, args) {
|
||||||
|
const serializedArgs = args.map(arg => serialize(arg, { callbackRegistry }));
|
||||||
|
const callbackIds = extractCallbackIds(serializedArgs);
|
||||||
|
|
||||||
|
const request: RpcRequest = {
|
||||||
|
id: generateRequestId(),
|
||||||
|
type: RpcOperationType.APPLY,
|
||||||
|
path,
|
||||||
|
args: serializedArgs,
|
||||||
|
callbackIds: Object.keys(callbackIds).length > 0 ? callbackIds : undefined,
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return createAsyncResultProxy(request);
|
||||||
|
},
|
||||||
|
|
||||||
|
construct (_target, args): object {
|
||||||
|
const serializedArgs = args.map(arg => serialize(arg, { callbackRegistry }));
|
||||||
|
const callbackIds = extractCallbackIds(serializedArgs);
|
||||||
|
|
||||||
|
const request: RpcRequest = {
|
||||||
|
id: generateRequestId(),
|
||||||
|
type: RpcOperationType.CONSTRUCT,
|
||||||
|
path,
|
||||||
|
args: serializedArgs,
|
||||||
|
callbackIds: Object.keys(callbackIds).length > 0 ? callbackIds : undefined,
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return createAsyncResultProxy(request) as object;
|
||||||
|
},
|
||||||
|
|
||||||
|
has (_target, prop) {
|
||||||
|
// 检查是否为代理元数据符号
|
||||||
|
if (prop === PROXY_META) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 同步返回 true,实际检查通过异步完成
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
ownKeys () {
|
||||||
|
// 返回空数组,实际键需要通过异步获取
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
|
getOwnPropertyDescriptor (_target, _prop) {
|
||||||
|
return {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
writable: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteProperty (_target, prop) {
|
||||||
|
const request: RpcRequest = {
|
||||||
|
id: generateRequestId(),
|
||||||
|
type: RpcOperationType.DELETE,
|
||||||
|
path: [...path, prop],
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
|
||||||
|
transport.send(request).catch(() => { /* ignore */ });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPrototypeOf () {
|
||||||
|
return Object.prototype;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建异步结果代理
|
||||||
|
* 返回一个 Promise-like 对象,可以被 await,
|
||||||
|
* 同时也可以继续链式访问属性
|
||||||
|
*/
|
||||||
|
function createAsyncResultProxy (request: RpcRequest): unknown {
|
||||||
|
let resultPromise: Promise<unknown> | null = null;
|
||||||
|
|
||||||
|
const getResult = async (): Promise<unknown> => {
|
||||||
|
if (!resultPromise) {
|
||||||
|
resultPromise = (async () => {
|
||||||
|
const response = await transport.send(request);
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
const error = new Error(response.error ?? 'RPC call failed');
|
||||||
|
if (response.stack) {
|
||||||
|
error.stack = response.stack;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.result === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果结果是可代理对象,返回代理
|
||||||
|
if (response.isProxyable && response.result) {
|
||||||
|
const deserialized = deserialize(response.result, {
|
||||||
|
callbackResolver: (id) => {
|
||||||
|
const cb = callbackRegistry.get(id);
|
||||||
|
if (!cb) throw new Error(`Callback not found: ${id}`);
|
||||||
|
return cb;
|
||||||
|
},
|
||||||
|
proxyCreator: (proxyPath, proxyRefId, cachedProps) => createProxyAtPath(proxyPath, proxyRefId, cachedProps),
|
||||||
|
});
|
||||||
|
return deserialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deserialize(response.result, {
|
||||||
|
callbackResolver: (id) => {
|
||||||
|
const cb = callbackRegistry.get(id);
|
||||||
|
if (!cb) throw new Error(`Callback not found: ${id}`);
|
||||||
|
return cb;
|
||||||
|
},
|
||||||
|
proxyCreator: (proxyPath, proxyRefId, cachedProps) => createProxyAtPath(proxyPath, proxyRefId, cachedProps),
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return resultPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建一个可链式访问的代理
|
||||||
|
const target = function () { } as unknown as Record<PropertyKey, unknown>;
|
||||||
|
|
||||||
|
return new Proxy(target, {
|
||||||
|
get (_target, prop) {
|
||||||
|
if (prop === 'then') {
|
||||||
|
return (resolve: (value: unknown) => void, reject: (error: unknown) => void) => {
|
||||||
|
getResult().then(resolve, reject);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prop === 'catch') {
|
||||||
|
return (reject: (error: unknown) => void) => {
|
||||||
|
getResult().catch(reject);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prop === 'finally') {
|
||||||
|
return (callback: () => void) => {
|
||||||
|
getResult().finally(callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prop === PROXY_META) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 链式访问:等待结果后访问其属性
|
||||||
|
return createChainedProxy(getResult(), [prop]);
|
||||||
|
},
|
||||||
|
|
||||||
|
apply (_target, _thisArg, args) {
|
||||||
|
// 等待结果后调用
|
||||||
|
return getResult().then(result => {
|
||||||
|
if (typeof result === 'function') {
|
||||||
|
return result(...args);
|
||||||
|
}
|
||||||
|
throw new Error('Result is not callable');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建链式代理
|
||||||
|
* 用于处理 await result.prop.method() 这样的链式调用
|
||||||
|
*/
|
||||||
|
function createChainedProxy (parentPromise: Promise<unknown>, path: PropertyKey[]): unknown {
|
||||||
|
const target = function () { } as unknown as Record<PropertyKey, unknown>;
|
||||||
|
|
||||||
|
return new Proxy(target, {
|
||||||
|
get (_target, prop) {
|
||||||
|
if (prop === 'then') {
|
||||||
|
return (resolve: (value: unknown) => void, reject: (error: unknown) => void) => {
|
||||||
|
parentPromise
|
||||||
|
.then(parent => {
|
||||||
|
let value: unknown = parent;
|
||||||
|
for (const key of path) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
value = (value as Record<PropertyKey, unknown>)[key];
|
||||||
|
}
|
||||||
|
resolve(value);
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prop === 'catch') {
|
||||||
|
return (reject: (error: unknown) => void) => {
|
||||||
|
parentPromise.catch(reject);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prop === 'finally') {
|
||||||
|
return (callback: () => void) => {
|
||||||
|
parentPromise.finally(callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return createChainedProxy(parentPromise, [...path, prop]);
|
||||||
|
},
|
||||||
|
|
||||||
|
apply (_target, _thisArg, args) {
|
||||||
|
return parentPromise.then(parent => {
|
||||||
|
let value: unknown = parent;
|
||||||
|
const pathToMethod = path.slice(0, -1);
|
||||||
|
const methodName = path[path.length - 1];
|
||||||
|
|
||||||
|
for (const key of pathToMethod) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
throw new Error(`Cannot access property '${String(key)}' of ${value}`);
|
||||||
|
}
|
||||||
|
value = (value as Record<PropertyKey, unknown>)[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = (value as Record<PropertyKey, unknown>)[methodName!];
|
||||||
|
if (typeof method !== 'function') {
|
||||||
|
throw new Error(`${String(methodName)} is not a function`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return method.call(value, ...args);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return createProxyAtPath(rootPath, rootRefId) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取代理的元数据
|
||||||
|
*/
|
||||||
|
export function getProxyMeta (proxy: unknown): ProxyMeta | undefined {
|
||||||
|
if (proxy != null && (typeof proxy === 'object' || typeof proxy === 'function')) {
|
||||||
|
try {
|
||||||
|
// 直接访问 Symbol 属性,代理的 get 陷阱会返回元数据
|
||||||
|
const meta = (proxy as Record<symbol, ProxyMeta | undefined>)[PROXY_META];
|
||||||
|
if (meta && meta.isProxy === true) {
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略访问错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为 RPC 代理
|
||||||
|
*/
|
||||||
|
export function isRpcProxy (value: unknown): boolean {
|
||||||
|
return getProxyMeta(value) !== undefined;
|
||||||
|
}
|
||||||
130
packages/napcat-rpc/src/easy.ts
Normal file
130
packages/napcat-rpc/src/easy.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* 简化版 RPC API
|
||||||
|
*
|
||||||
|
* 提供一键创建完全隔离的 client/server 对
|
||||||
|
* 在 client 端操作就像直接操作 server 端的变量一样
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { LocalTransport } from './transport.js';
|
||||||
|
import { createDeepProxy, getProxyMeta, isRpcProxy } from './client.js';
|
||||||
|
import { RpcServer } from './server.js';
|
||||||
|
import type { ProxyMeta } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 配对结果
|
||||||
|
*/
|
||||||
|
export interface RpcPair<T> {
|
||||||
|
/** 客户端代理 - 在这里操作就像直接操作服务端的变量 */
|
||||||
|
client: T;
|
||||||
|
/** 服务端原始对象 */
|
||||||
|
server: T;
|
||||||
|
/** 关闭连接 */
|
||||||
|
close (): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 RPC 配对
|
||||||
|
*
|
||||||
|
* 快速创建完全隔离的 client/server 对,client 端的所有操作都会通过 RPC 传递到 server 端执行
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const { client, server } = createRpcPair({
|
||||||
|
* name: 'test',
|
||||||
|
* greet: (msg: string) => `Hello, ${msg}!`,
|
||||||
|
* register: (handlers: { onSuccess: Function, onError: Function }) => {
|
||||||
|
* handlers.onSuccess('done');
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // 在 client 端操作,就像直接操作 server 端的变量
|
||||||
|
* await client.greet('world'); // 返回 'Hello, world!'
|
||||||
|
*
|
||||||
|
* // 支持包含多个回调的对象
|
||||||
|
* await client.register({
|
||||||
|
* onSuccess: (result) => console.log(result),
|
||||||
|
* onError: (err) => console.error(err)
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createRpcPair<T extends object> (target: T): RpcPair<T> {
|
||||||
|
const transport = new LocalTransport(target);
|
||||||
|
const client = createDeepProxy<T>({ transport });
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
server: target,
|
||||||
|
close: () => transport.close(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟远程变量
|
||||||
|
*
|
||||||
|
* 将一个本地变量包装成"看起来像远程变量"的代理,所有操作都通过 RPC 隔离
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const remoteApi = mockRemote({
|
||||||
|
* counter: 0,
|
||||||
|
* increment() { return ++this.counter; },
|
||||||
|
* async fetchData(id: number) { return { id, data: 'test' }; }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // 所有操作都是异步的,通过 RPC 隔离
|
||||||
|
* await remoteApi.increment(); // 1
|
||||||
|
* await remoteApi.fetchData(123); // { id: 123, data: 'test' }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function mockRemote<T extends object> (target: T): T {
|
||||||
|
return createRpcPair(target).client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 RPC 服务端
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const server = createServer({
|
||||||
|
* users: new Map(),
|
||||||
|
* addUser(id: string, name: string) {
|
||||||
|
* this.users.set(id, { name });
|
||||||
|
* return true;
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // 获取传输层供客户端连接
|
||||||
|
* const transport = server.getTransport();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createServer<T extends object> (target: T): {
|
||||||
|
target: T;
|
||||||
|
handler: RpcServer;
|
||||||
|
getTransport (): LocalTransport;
|
||||||
|
} {
|
||||||
|
const handler = new RpcServer({ target });
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
handler,
|
||||||
|
getTransport: () => new LocalTransport(target),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建指向服务端的客户端
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const server = createServer(myApi);
|
||||||
|
* const client = createClient<typeof myApi>(server.getTransport());
|
||||||
|
*
|
||||||
|
* await client.someMethod();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createClient<T extends object> (transport: LocalTransport): T {
|
||||||
|
return createDeepProxy<T>({ transport });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新导出常用工具
|
||||||
|
export { getProxyMeta, isRpcProxy };
|
||||||
|
export type { ProxyMeta };
|
||||||
60
packages/napcat-rpc/src/index.ts
Normal file
60
packages/napcat-rpc/src/index.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* napcat-rpc
|
||||||
|
*
|
||||||
|
* 深层 RPC 代理库 - 将对象的所有层级操作转换为 RPC 调用
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 简化 API(推荐使用)
|
||||||
|
export {
|
||||||
|
createRpcPair,
|
||||||
|
mockRemote,
|
||||||
|
createServer,
|
||||||
|
createClient,
|
||||||
|
} from './easy.js';
|
||||||
|
|
||||||
|
// 类型导出
|
||||||
|
export {
|
||||||
|
RpcOperationType,
|
||||||
|
SerializedValueType,
|
||||||
|
PROXY_META,
|
||||||
|
type RpcRequest,
|
||||||
|
type RpcResponse,
|
||||||
|
type SerializedValue,
|
||||||
|
type RpcTransport,
|
||||||
|
type RpcServerHandler,
|
||||||
|
type RpcServerOptions,
|
||||||
|
type DeepProxyOptions,
|
||||||
|
type ProxyMeta,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
// 序列化工具
|
||||||
|
export {
|
||||||
|
serialize,
|
||||||
|
deserialize,
|
||||||
|
extractCallbackIds,
|
||||||
|
SimpleCallbackRegistry,
|
||||||
|
type CallbackRegistry,
|
||||||
|
type SerializeContext,
|
||||||
|
type DeserializeContext,
|
||||||
|
} from './serializer.js';
|
||||||
|
|
||||||
|
// 客户端代理
|
||||||
|
export {
|
||||||
|
createDeepProxy,
|
||||||
|
getProxyMeta,
|
||||||
|
isRpcProxy,
|
||||||
|
} from './client.js';
|
||||||
|
|
||||||
|
// 服务端
|
||||||
|
export {
|
||||||
|
RpcServer,
|
||||||
|
createRpcServer,
|
||||||
|
} from './server.js';
|
||||||
|
|
||||||
|
// 传输层
|
||||||
|
export {
|
||||||
|
LocalTransport,
|
||||||
|
MessageTransport,
|
||||||
|
createMessageServerHandler,
|
||||||
|
type MessageTransportOptions,
|
||||||
|
} from './transport.js';
|
||||||
410
packages/napcat-rpc/src/serializer.ts
Normal file
410
packages/napcat-rpc/src/serializer.ts
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
import {
|
||||||
|
SerializedValue,
|
||||||
|
SerializedValueType,
|
||||||
|
PROXY_META,
|
||||||
|
type ProxyMeta,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回调注册器接口
|
||||||
|
*/
|
||||||
|
export interface CallbackRegistry {
|
||||||
|
register (fn: Function): string;
|
||||||
|
get (id: string): Function | undefined;
|
||||||
|
remove (id: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简单的回调注册器实现
|
||||||
|
*/
|
||||||
|
export class SimpleCallbackRegistry implements CallbackRegistry {
|
||||||
|
private callbacks = new Map<string, Function>();
|
||||||
|
private counter = 0;
|
||||||
|
|
||||||
|
register (fn: Function): string {
|
||||||
|
const id = `cb_${++this.counter}_${Date.now()}`;
|
||||||
|
this.callbacks.set(id, fn);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get (id: string): Function | undefined {
|
||||||
|
return this.callbacks.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove (id: string): void {
|
||||||
|
this.callbacks.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear (): void {
|
||||||
|
this.callbacks.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化上下文
|
||||||
|
*/
|
||||||
|
export interface SerializeContext {
|
||||||
|
/** 回调注册器 */
|
||||||
|
callbackRegistry?: CallbackRegistry;
|
||||||
|
/** 已序列化对象映射(用于循环引用检测) */
|
||||||
|
seen?: WeakMap<object, SerializedValue>;
|
||||||
|
/** 深度限制 */
|
||||||
|
maxDepth?: number;
|
||||||
|
/** 当前深度 */
|
||||||
|
currentDepth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反序列化上下文
|
||||||
|
*/
|
||||||
|
export interface DeserializeContext {
|
||||||
|
/** 回调解析器 */
|
||||||
|
callbackResolver?: (id: string) => Function;
|
||||||
|
/** 代理创建器 */
|
||||||
|
proxyCreator?: (path: PropertyKey[], refId?: string, cachedProps?: Record<string, unknown>) => unknown;
|
||||||
|
/** 对象引用解析器 */
|
||||||
|
refResolver?: (refId: string) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将值序列化为可传输格式
|
||||||
|
*/
|
||||||
|
export function serialize (value: unknown, context: SerializeContext = {}): SerializedValue {
|
||||||
|
const {
|
||||||
|
callbackRegistry,
|
||||||
|
seen = new WeakMap(),
|
||||||
|
maxDepth = 50,
|
||||||
|
currentDepth = 0,
|
||||||
|
} = context;
|
||||||
|
|
||||||
|
// 深度检查
|
||||||
|
if (currentDepth > maxDepth) {
|
||||||
|
return { type: SerializedValueType.STRING, value: '[Max depth exceeded]' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基本类型处理
|
||||||
|
if (value === undefined) {
|
||||||
|
return { type: SerializedValueType.UNDEFINED };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return { type: SerializedValueType.NULL };
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueType = typeof value;
|
||||||
|
|
||||||
|
if (valueType === 'boolean') {
|
||||||
|
return { type: SerializedValueType.BOOLEAN, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueType === 'number') {
|
||||||
|
const numValue = value as number;
|
||||||
|
if (Number.isNaN(numValue)) {
|
||||||
|
return { type: SerializedValueType.NUMBER, value: 'NaN' };
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(numValue)) {
|
||||||
|
return { type: SerializedValueType.NUMBER, value: numValue > 0 ? 'Infinity' : '-Infinity' };
|
||||||
|
}
|
||||||
|
return { type: SerializedValueType.NUMBER, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueType === 'bigint') {
|
||||||
|
return { type: SerializedValueType.BIGINT, value: (value as bigint).toString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueType === 'string') {
|
||||||
|
return { type: SerializedValueType.STRING, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueType === 'symbol') {
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.SYMBOL,
|
||||||
|
value: (value as symbol).description ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueType === 'function') {
|
||||||
|
const fn = value as Function;
|
||||||
|
if (callbackRegistry) {
|
||||||
|
const callbackId = callbackRegistry.register(fn);
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.FUNCTION,
|
||||||
|
callbackId,
|
||||||
|
className: fn.name || 'anonymous',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.FUNCTION,
|
||||||
|
className: fn.name || 'anonymous',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对象类型处理
|
||||||
|
const obj = value as object;
|
||||||
|
|
||||||
|
// 检查是否为代理对象
|
||||||
|
if (PROXY_META in obj) {
|
||||||
|
const meta = (obj as Record<symbol, ProxyMeta | undefined>)[PROXY_META];
|
||||||
|
if (meta) {
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.PROXY_REF,
|
||||||
|
proxyPath: meta.path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 循环引用检测
|
||||||
|
if (seen.has(obj)) {
|
||||||
|
return seen.get(obj)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date
|
||||||
|
if (obj instanceof Date) {
|
||||||
|
return { type: SerializedValueType.DATE, value: obj.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegExp
|
||||||
|
if (obj instanceof RegExp) {
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.REGEXP,
|
||||||
|
value: { source: obj.source, flags: obj.flags },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error
|
||||||
|
if (obj instanceof Error) {
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.ERROR,
|
||||||
|
value: obj.message,
|
||||||
|
className: obj.constructor.name,
|
||||||
|
properties: {
|
||||||
|
stack: serialize(obj.stack, { ...context, seen, currentDepth: currentDepth + 1 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer / Uint8Array
|
||||||
|
if (obj instanceof Uint8Array) {
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.BUFFER,
|
||||||
|
value: Array.from(obj as Uint8Array),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node.js Buffer
|
||||||
|
if (typeof globalThis !== 'undefined' && 'Buffer' in globalThis) {
|
||||||
|
const BufferClass = (globalThis as unknown as { Buffer: { isBuffer (obj: unknown): boolean; }; }).Buffer;
|
||||||
|
if (BufferClass.isBuffer(obj)) {
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.BUFFER,
|
||||||
|
value: Array.from(obj as unknown as Uint8Array),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map
|
||||||
|
if (obj instanceof Map) {
|
||||||
|
const entries: SerializedValue[] = [];
|
||||||
|
const nextContext = { ...context, seen, currentDepth: currentDepth + 1 };
|
||||||
|
for (const [k, v] of obj) {
|
||||||
|
entries.push(serialize([k, v], nextContext));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.MAP,
|
||||||
|
elements: entries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set
|
||||||
|
if (obj instanceof Set) {
|
||||||
|
const elements: SerializedValue[] = [];
|
||||||
|
const nextContext = { ...context, seen, currentDepth: currentDepth + 1 };
|
||||||
|
for (const v of obj) {
|
||||||
|
elements.push(serialize(v, nextContext));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.SET,
|
||||||
|
elements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise
|
||||||
|
if (obj instanceof Promise) {
|
||||||
|
return { type: SerializedValueType.PROMISE };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
const result: SerializedValue = {
|
||||||
|
type: SerializedValueType.ARRAY,
|
||||||
|
elements: [],
|
||||||
|
};
|
||||||
|
seen.set(obj, result);
|
||||||
|
const nextContext = { ...context, seen, currentDepth: currentDepth + 1 };
|
||||||
|
result.elements = obj.map(item => serialize(item, nextContext));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通对象
|
||||||
|
const result: SerializedValue = {
|
||||||
|
type: SerializedValueType.OBJECT,
|
||||||
|
className: obj.constructor?.name ?? 'Object',
|
||||||
|
properties: {},
|
||||||
|
};
|
||||||
|
seen.set(obj, result);
|
||||||
|
|
||||||
|
const nextContext = { ...context, seen, currentDepth: currentDepth + 1 };
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
result.properties![key] = serialize((obj as Record<string, unknown>)[key], nextContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将序列化数据还原为值
|
||||||
|
*/
|
||||||
|
export function deserialize (data: SerializedValue, context: DeserializeContext = {}): unknown {
|
||||||
|
const { callbackResolver, proxyCreator, refResolver } = context;
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case SerializedValueType.UNDEFINED:
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
case SerializedValueType.NULL:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case SerializedValueType.BOOLEAN:
|
||||||
|
return data.value;
|
||||||
|
|
||||||
|
case SerializedValueType.NUMBER:
|
||||||
|
if (data.value === 'NaN') return NaN;
|
||||||
|
if (data.value === 'Infinity') return Infinity;
|
||||||
|
if (data.value === '-Infinity') return -Infinity;
|
||||||
|
return data.value;
|
||||||
|
|
||||||
|
case SerializedValueType.BIGINT:
|
||||||
|
return BigInt(data.value as string);
|
||||||
|
|
||||||
|
case SerializedValueType.STRING:
|
||||||
|
return data.value;
|
||||||
|
|
||||||
|
case SerializedValueType.SYMBOL:
|
||||||
|
return Symbol(data.value as string);
|
||||||
|
|
||||||
|
case SerializedValueType.FUNCTION:
|
||||||
|
if (data.callbackId && callbackResolver) {
|
||||||
|
return callbackResolver(data.callbackId);
|
||||||
|
}
|
||||||
|
// 返回一个占位函数
|
||||||
|
return function placeholder () {
|
||||||
|
throw new Error('Remote function cannot be called without callback resolver');
|
||||||
|
};
|
||||||
|
|
||||||
|
case SerializedValueType.DATE:
|
||||||
|
return new Date(data.value as string);
|
||||||
|
|
||||||
|
case SerializedValueType.REGEXP: {
|
||||||
|
const { source, flags } = data.value as { source: string; flags: string; };
|
||||||
|
return new RegExp(source, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
case SerializedValueType.ERROR: {
|
||||||
|
const error = new Error(data.value as string);
|
||||||
|
if (data.properties?.['stack']) {
|
||||||
|
error.stack = deserialize(data.properties['stack'], context) as string;
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SerializedValueType.BUFFER: {
|
||||||
|
const arr = data.value as number[];
|
||||||
|
if (typeof globalThis !== 'undefined' && 'Buffer' in globalThis) {
|
||||||
|
const BufferClass = (globalThis as unknown as { Buffer: { from (arr: number[]): Uint8Array; }; }).Buffer;
|
||||||
|
return BufferClass.from(arr);
|
||||||
|
}
|
||||||
|
return new Uint8Array(arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
case SerializedValueType.MAP: {
|
||||||
|
const map = new Map();
|
||||||
|
if (data.elements) {
|
||||||
|
for (const element of data.elements) {
|
||||||
|
const [k, v] = deserialize(element, context) as [unknown, unknown];
|
||||||
|
map.set(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SerializedValueType.SET: {
|
||||||
|
const set = new Set();
|
||||||
|
if (data.elements) {
|
||||||
|
for (const element of data.elements) {
|
||||||
|
set.add(deserialize(element, context));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SerializedValueType.PROMISE:
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
|
||||||
|
case SerializedValueType.ARRAY:
|
||||||
|
return (data.elements ?? []).map(elem => deserialize(elem, context));
|
||||||
|
|
||||||
|
case SerializedValueType.PROXY_REF:
|
||||||
|
if (data.proxyPath && proxyCreator) {
|
||||||
|
return proxyCreator(data.proxyPath);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
|
||||||
|
case SerializedValueType.OBJECT_REF:
|
||||||
|
// 对象引用:在客户端创建代理,在服务端解析为实际对象
|
||||||
|
if (data.refId) {
|
||||||
|
// 优先使用 refResolver(服务端场景)
|
||||||
|
if (refResolver) {
|
||||||
|
return refResolver(data.refId);
|
||||||
|
}
|
||||||
|
// 否则创建代理(客户端场景)
|
||||||
|
if (proxyCreator) {
|
||||||
|
// 反序列化缓存的属性
|
||||||
|
let cachedValues: Record<string, unknown> | undefined;
|
||||||
|
if (data.cachedProps) {
|
||||||
|
cachedValues = {};
|
||||||
|
for (const [key, val] of Object.entries(data.cachedProps)) {
|
||||||
|
cachedValues[key] = deserialize(val, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return proxyCreator([], data.refId, cachedValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
|
||||||
|
case SerializedValueType.OBJECT: {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
if (data.properties) {
|
||||||
|
for (const [key, val] of Object.entries(data.properties)) {
|
||||||
|
obj[key] = deserialize(val, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取序列化参数中的回调ID映射
|
||||||
|
*/
|
||||||
|
export function extractCallbackIds (args: SerializedValue[]): Record<number, string> {
|
||||||
|
const result: Record<number, string> = {};
|
||||||
|
args.forEach((arg, index) => {
|
||||||
|
if (arg.type === SerializedValueType.FUNCTION && arg.callbackId) {
|
||||||
|
result[index] = arg.callbackId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
577
packages/napcat-rpc/src/server.ts
Normal file
577
packages/napcat-rpc/src/server.ts
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
import {
|
||||||
|
type RpcRequest,
|
||||||
|
type RpcResponse,
|
||||||
|
type RpcServerOptions,
|
||||||
|
type SerializedValue,
|
||||||
|
RpcOperationType,
|
||||||
|
SerializedValueType,
|
||||||
|
} from './types.js';
|
||||||
|
import { serialize, deserialize, SimpleCallbackRegistry } from './serializer.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成唯一引用 ID
|
||||||
|
*/
|
||||||
|
function generateRefId (): string {
|
||||||
|
return `ref_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认的代理判断函数
|
||||||
|
* 判断返回值是否应该保持代理引用(而非完全序列化)
|
||||||
|
* 策略:class 实例和有方法的对象保持代理,普通对象直接序列化
|
||||||
|
*/
|
||||||
|
function defaultShouldProxyResult (value: unknown): boolean {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'object' && typeof value !== 'function') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 函数保持代理
|
||||||
|
if (typeof value === 'function') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 可安全序列化的内置类型不代理
|
||||||
|
if (value instanceof Date || value instanceof RegExp || value instanceof Error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value instanceof Map || value instanceof Set) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 数组不代理
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 检查对象原型是否为 Object.prototype(普通对象)
|
||||||
|
const proto = Object.getPrototypeOf(value);
|
||||||
|
if (proto === Object.prototype || proto === null) {
|
||||||
|
// 普通对象检查是否有方法
|
||||||
|
const hasMethod = Object.values(value as object).some(v => typeof v === 'function');
|
||||||
|
return hasMethod;
|
||||||
|
}
|
||||||
|
// 非普通对象(class 实例)- 保持代理
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 服务端
|
||||||
|
*
|
||||||
|
* 处理来自客户端的 RPC 请求,在目标对象上执行操作
|
||||||
|
*/
|
||||||
|
export class RpcServer {
|
||||||
|
private target: unknown;
|
||||||
|
private callbackInvoker?: (callbackId: string, args: unknown[]) => Promise<unknown>;
|
||||||
|
private localCallbacks = new SimpleCallbackRegistry();
|
||||||
|
/** 对象引用存储 */
|
||||||
|
private objectRefs = new Map<string, unknown>();
|
||||||
|
/** 代理判断函数 */
|
||||||
|
private shouldProxyResult: (value: unknown) => boolean;
|
||||||
|
|
||||||
|
constructor (options: RpcServerOptions) {
|
||||||
|
this.target = options.target;
|
||||||
|
this.callbackInvoker = options.callbackInvoker;
|
||||||
|
this.shouldProxyResult = options.shouldProxyResult ?? defaultShouldProxyResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 RPC 请求
|
||||||
|
*/
|
||||||
|
async handleRequest (request: RpcRequest): Promise<RpcResponse> {
|
||||||
|
try {
|
||||||
|
switch (request.type) {
|
||||||
|
case RpcOperationType.GET:
|
||||||
|
return this.handleGet(request);
|
||||||
|
|
||||||
|
case RpcOperationType.SET:
|
||||||
|
return this.handleSet(request);
|
||||||
|
|
||||||
|
case RpcOperationType.APPLY:
|
||||||
|
return await this.handleApply(request);
|
||||||
|
|
||||||
|
case RpcOperationType.CONSTRUCT:
|
||||||
|
return await this.handleConstruct(request);
|
||||||
|
|
||||||
|
case RpcOperationType.HAS:
|
||||||
|
return this.handleHas(request);
|
||||||
|
|
||||||
|
case RpcOperationType.OWNKEYS:
|
||||||
|
return this.handleOwnKeys(request);
|
||||||
|
|
||||||
|
case RpcOperationType.DELETE:
|
||||||
|
return this.handleDelete(request);
|
||||||
|
|
||||||
|
case RpcOperationType.GET_DESCRIPTOR:
|
||||||
|
return this.handleGetDescriptor(request);
|
||||||
|
|
||||||
|
case RpcOperationType.GET_PROTOTYPE:
|
||||||
|
return this.handleGetPrototype(request);
|
||||||
|
|
||||||
|
case RpcOperationType.RELEASE:
|
||||||
|
return this.handleRelease(request);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: false,
|
||||||
|
error: `Unknown operation type: ${request.type}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return this.createErrorResponse(request.id, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析路径获取目标值,支持 refId
|
||||||
|
*/
|
||||||
|
private resolvePath (path: PropertyKey[], refId?: string): { parent: unknown; key: PropertyKey | undefined; value: unknown; } {
|
||||||
|
// 如果有 refId,从引用存储中获取根对象
|
||||||
|
let current = refId ? this.objectRefs.get(refId) : this.target;
|
||||||
|
|
||||||
|
if (refId && current === undefined) {
|
||||||
|
throw new Error(`Object reference not found: ${refId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent: unknown = null;
|
||||||
|
let key: PropertyKey | undefined;
|
||||||
|
|
||||||
|
for (let i = 0; i < path.length; i++) {
|
||||||
|
parent = current;
|
||||||
|
key = path[i];
|
||||||
|
if (key === undefined) {
|
||||||
|
throw new Error('Path contains undefined key');
|
||||||
|
}
|
||||||
|
if (current === null || current === undefined) {
|
||||||
|
throw new Error(`Cannot access property '${String(key)}' of ${current}`);
|
||||||
|
}
|
||||||
|
current = (current as Record<PropertyKey, unknown>)[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { parent, key, value: current };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储对象引用并返回序列化的引用
|
||||||
|
* 同时序列化可序列化的属性值,避免属性访问需要额外 RPC
|
||||||
|
*/
|
||||||
|
private storeObjectRef (value: unknown): SerializedValue {
|
||||||
|
const refId = generateRefId();
|
||||||
|
this.objectRefs.set(refId, value);
|
||||||
|
const className = value?.constructor?.name;
|
||||||
|
|
||||||
|
// 序列化非函数属性
|
||||||
|
const cachedProps: Record<string, SerializedValue> = {};
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
const propValue = (value as Record<string, unknown>)[key];
|
||||||
|
// 跳过函数(方法需要远程调用)
|
||||||
|
if (typeof propValue === 'function') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 序列化属性值
|
||||||
|
try {
|
||||||
|
cachedProps[key] = serialize(propValue, { callbackRegistry: this.localCallbacks });
|
||||||
|
} catch {
|
||||||
|
// 序列化失败的属性跳过,让客户端通过 RPC 获取
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: SerializedValueType.OBJECT_REF,
|
||||||
|
refId,
|
||||||
|
className: className !== 'Object' ? className : undefined,
|
||||||
|
cachedProps: Object.keys(cachedProps).length > 0 ? cachedProps : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化结果值,如果需要代理则存储引用
|
||||||
|
*/
|
||||||
|
private serializeResult (value: unknown): { result: SerializedValue; isProxyable: boolean; refId?: string; } {
|
||||||
|
const shouldProxy = this.shouldProxyResult(value);
|
||||||
|
|
||||||
|
if (shouldProxy) {
|
||||||
|
const ref = this.storeObjectRef(value);
|
||||||
|
return {
|
||||||
|
result: ref,
|
||||||
|
isProxyable: true,
|
||||||
|
refId: ref.refId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: serialize(value, { callbackRegistry: this.localCallbacks }),
|
||||||
|
isProxyable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 GET 操作
|
||||||
|
*/
|
||||||
|
private handleGet (request: RpcRequest): RpcResponse {
|
||||||
|
const { value } = this.resolvePath(request.path, request.refId);
|
||||||
|
const { result, isProxyable, refId } = this.serializeResult(value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result,
|
||||||
|
isProxyable,
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 SET 操作
|
||||||
|
*/
|
||||||
|
private handleSet (request: RpcRequest): RpcResponse {
|
||||||
|
const path = request.path;
|
||||||
|
if (path.length === 0 && !request.refId) {
|
||||||
|
throw new Error('Cannot set root object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = path.slice(0, -1);
|
||||||
|
const key = path[path.length - 1]!;
|
||||||
|
const { value: parent } = this.resolvePath(parentPath, request.refId);
|
||||||
|
|
||||||
|
if (parent === null || parent === undefined) {
|
||||||
|
throw new Error(`Cannot set property '${String(key)}' of ${parent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValue = request.args?.[0]
|
||||||
|
? deserialize(request.args[0], {
|
||||||
|
callbackResolver: this.createCallbackResolver(request),
|
||||||
|
refResolver: (refId) => this.objectRefs.get(refId),
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
(parent as Record<PropertyKey, unknown>)[key] = newValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 APPLY 操作
|
||||||
|
*/
|
||||||
|
private async handleApply (request: RpcRequest): Promise<RpcResponse> {
|
||||||
|
const path = request.path;
|
||||||
|
|
||||||
|
// 如果有 refId 且 path 为空,说明引用对象本身是函数
|
||||||
|
if (path.length === 0 && request.refId) {
|
||||||
|
const func = this.objectRefs.get(request.refId);
|
||||||
|
if (typeof func !== 'function') {
|
||||||
|
throw new Error('Referenced object is not callable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = (request.args ?? []).map(arg =>
|
||||||
|
deserialize(arg, {
|
||||||
|
callbackResolver: this.createCallbackResolver(request),
|
||||||
|
refResolver: (refId) => this.objectRefs.get(refId),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = func(...args);
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
result = await result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { result: serializedResult, isProxyable, refId } = this.serializeResult(result);
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serializedResult,
|
||||||
|
isProxyable,
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.length === 0) {
|
||||||
|
throw new Error('Cannot call root object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodPath = path.slice(0, -1);
|
||||||
|
const methodName = path[path.length - 1]!;
|
||||||
|
const { value: parent } = this.resolvePath(methodPath, request.refId);
|
||||||
|
|
||||||
|
if (parent === null || parent === undefined) {
|
||||||
|
throw new Error(`Cannot call method on ${parent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = (parent as Record<PropertyKey, unknown>)[methodName];
|
||||||
|
if (typeof method !== 'function') {
|
||||||
|
throw new Error(`${String(methodName)} is not a function`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = (request.args ?? []).map(arg =>
|
||||||
|
deserialize(arg, {
|
||||||
|
callbackResolver: this.createCallbackResolver(request),
|
||||||
|
refResolver: (refId) => this.objectRefs.get(refId),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = method.call(parent, ...args);
|
||||||
|
|
||||||
|
// 处理 Promise
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
result = await result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { result: serializedResult, isProxyable, refId } = this.serializeResult(result);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serializedResult,
|
||||||
|
isProxyable,
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 CONSTRUCT 操作
|
||||||
|
*/
|
||||||
|
private async handleConstruct (request: RpcRequest): Promise<RpcResponse> {
|
||||||
|
const { value: Constructor } = this.resolvePath(request.path, request.refId);
|
||||||
|
|
||||||
|
if (typeof Constructor !== 'function') {
|
||||||
|
throw new Error('Target is not a constructor');
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = (request.args ?? []).map(arg =>
|
||||||
|
deserialize(arg, {
|
||||||
|
callbackResolver: this.createCallbackResolver(request),
|
||||||
|
refResolver: (refId) => this.objectRefs.get(refId),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const instance = new (Constructor as new (...args: unknown[]) => unknown)(...args);
|
||||||
|
const { result, isProxyable, refId } = this.serializeResult(instance);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result,
|
||||||
|
isProxyable,
|
||||||
|
refId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 HAS 操作
|
||||||
|
*/
|
||||||
|
private handleHas (request: RpcRequest): RpcResponse {
|
||||||
|
const path = request.path;
|
||||||
|
if (path.length === 0) {
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize(true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = path.slice(0, -1);
|
||||||
|
const key = path[path.length - 1]!;
|
||||||
|
const { value: parent } = this.resolvePath(parentPath, request.refId);
|
||||||
|
|
||||||
|
const has = parent !== null && parent !== undefined && key in (parent as object);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize(has),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 OWNKEYS 操作
|
||||||
|
*/
|
||||||
|
private handleOwnKeys (request: RpcRequest): RpcResponse {
|
||||||
|
const { value } = this.resolvePath(request.path, request.refId);
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize([]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Reflect.ownKeys(value as object);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize(keys.map(k => (typeof k === 'symbol' ? k.description ?? '' : String(k)))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 DELETE 操作
|
||||||
|
*/
|
||||||
|
private handleDelete (request: RpcRequest): RpcResponse {
|
||||||
|
const path = request.path;
|
||||||
|
if (path.length === 0 && !request.refId) {
|
||||||
|
throw new Error('Cannot delete root object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = path.slice(0, -1);
|
||||||
|
const key = path[path.length - 1]!;
|
||||||
|
const { value: parent } = this.resolvePath(parentPath, request.refId);
|
||||||
|
|
||||||
|
if (parent === null || parent === undefined) {
|
||||||
|
throw new Error(`Cannot delete property from ${parent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = delete (parent as Record<PropertyKey, unknown>)[key];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize(deleted),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 GET_DESCRIPTOR 操作
|
||||||
|
*/
|
||||||
|
private handleGetDescriptor (request: RpcRequest): RpcResponse {
|
||||||
|
const path = request.path;
|
||||||
|
if (path.length === 0) {
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = path.slice(0, -1);
|
||||||
|
const key = path[path.length - 1]!;
|
||||||
|
const { value: parent } = this.resolvePath(parentPath, request.refId);
|
||||||
|
|
||||||
|
if (parent === null || parent === undefined) {
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(parent as object, key);
|
||||||
|
|
||||||
|
if (!descriptor) {
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列化描述符(排除 value 和 get/set 函数)
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize({
|
||||||
|
configurable: descriptor.configurable,
|
||||||
|
enumerable: descriptor.enumerable,
|
||||||
|
writable: descriptor.writable,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 GET_PROTOTYPE 操作
|
||||||
|
*/
|
||||||
|
private handleGetPrototype (request: RpcRequest): RpcResponse {
|
||||||
|
const { value } = this.resolvePath(request.path, request.refId);
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize(null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto = Object.getPrototypeOf(value);
|
||||||
|
const name = proto?.constructor?.name ?? 'Object';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
result: serialize({ name }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 RELEASE 操作
|
||||||
|
*/
|
||||||
|
private handleRelease (request: RpcRequest): RpcResponse {
|
||||||
|
// 如果有 refId,释放该引用
|
||||||
|
if (request.refId) {
|
||||||
|
this.objectRefs.delete(request.refId);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建回调解析器
|
||||||
|
*/
|
||||||
|
private createCallbackResolver (_request: RpcRequest): (id: string) => Function {
|
||||||
|
return (callbackId: string) => {
|
||||||
|
// 创建一个代理函数,调用时会通过 callbackInvoker 发送回客户端
|
||||||
|
return async (...args: unknown[]) => {
|
||||||
|
if (!this.callbackInvoker) {
|
||||||
|
throw new Error('Callback invoker not configured');
|
||||||
|
}
|
||||||
|
return this.callbackInvoker(callbackId, args);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建错误响应
|
||||||
|
*/
|
||||||
|
private createErrorResponse (requestId: string, error: unknown): RpcResponse {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return {
|
||||||
|
id: requestId,
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: requestId,
|
||||||
|
success: false,
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用客户端回调
|
||||||
|
*/
|
||||||
|
async invokeCallback (callbackId: string, args: unknown[]): Promise<SerializedValue> {
|
||||||
|
if (!this.callbackInvoker) {
|
||||||
|
throw new Error('Callback invoker not configured');
|
||||||
|
}
|
||||||
|
const result = await this.callbackInvoker(callbackId, args);
|
||||||
|
return serialize(result, { callbackRegistry: this.localCallbacks });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 RPC 服务端
|
||||||
|
*/
|
||||||
|
export function createRpcServer (options: RpcServerOptions): RpcServer {
|
||||||
|
return new RpcServer(options);
|
||||||
|
}
|
||||||
204
packages/napcat-rpc/src/transport.ts
Normal file
204
packages/napcat-rpc/src/transport.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import {
|
||||||
|
type RpcTransport,
|
||||||
|
type RpcRequest,
|
||||||
|
type RpcResponse,
|
||||||
|
type SerializedValue,
|
||||||
|
} from './types.js';
|
||||||
|
import { RpcServer } from './server.js';
|
||||||
|
import { serialize, deserialize, SimpleCallbackRegistry } from './serializer.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地传输层
|
||||||
|
*
|
||||||
|
* 用于在同一进程内进行 RPC 调用,主要用于测试
|
||||||
|
*/
|
||||||
|
export class LocalTransport implements RpcTransport {
|
||||||
|
private server: RpcServer;
|
||||||
|
private callbackHandler?: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>;
|
||||||
|
private clientCallbacks = new SimpleCallbackRegistry();
|
||||||
|
|
||||||
|
constructor (target: unknown) {
|
||||||
|
this.server = new RpcServer({
|
||||||
|
target,
|
||||||
|
callbackInvoker: async (callbackId, args) => {
|
||||||
|
if (!this.callbackHandler) {
|
||||||
|
throw new Error('Callback handler not registered');
|
||||||
|
}
|
||||||
|
const serializedArgs = args.map(arg => serialize(arg, { callbackRegistry: this.clientCallbacks }));
|
||||||
|
const result = await this.callbackHandler(callbackId, serializedArgs);
|
||||||
|
return deserialize(result);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async send (request: RpcRequest): Promise<RpcResponse> {
|
||||||
|
// 模拟网络延迟(可选)
|
||||||
|
// await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
return this.server.handleRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCallback (handler: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>): void {
|
||||||
|
this.callbackHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
close (): void {
|
||||||
|
this.clientCallbacks.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息传输层接口
|
||||||
|
*/
|
||||||
|
export interface MessageTransportOptions {
|
||||||
|
/** 发送消息 */
|
||||||
|
sendMessage: (message: string) => void | Promise<void>;
|
||||||
|
/** 接收消息时的回调 */
|
||||||
|
onMessage: (handler: (message: string) => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于消息的传输层
|
||||||
|
*
|
||||||
|
* 可用于跨进程/网络通信
|
||||||
|
*/
|
||||||
|
export class MessageTransport implements RpcTransport {
|
||||||
|
private pendingRequests = new Map<string, {
|
||||||
|
resolve: (response: RpcResponse) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}>();
|
||||||
|
private callbackHandler?: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>;
|
||||||
|
private sendMessage: (message: string) => void | Promise<void>;
|
||||||
|
|
||||||
|
constructor (options: MessageTransportOptions) {
|
||||||
|
this.sendMessage = options.sendMessage;
|
||||||
|
|
||||||
|
options.onMessage(async (message) => {
|
||||||
|
const data = JSON.parse(message) as {
|
||||||
|
type: 'response' | 'callback' | 'callback_response';
|
||||||
|
id: string;
|
||||||
|
response?: RpcResponse;
|
||||||
|
callbackId?: string;
|
||||||
|
args?: SerializedValue[];
|
||||||
|
result?: SerializedValue;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.type === 'response') {
|
||||||
|
const pending = this.pendingRequests.get(data.id);
|
||||||
|
if (pending && data.response) {
|
||||||
|
this.pendingRequests.delete(data.id);
|
||||||
|
pending.resolve(data.response);
|
||||||
|
}
|
||||||
|
} else if (data.type === 'callback') {
|
||||||
|
// 处理来自服务端的回调调用
|
||||||
|
if (this.callbackHandler && data.callbackId && data.args) {
|
||||||
|
try {
|
||||||
|
const result = await this.callbackHandler(data.callbackId, data.args);
|
||||||
|
await this.sendMessage(JSON.stringify({
|
||||||
|
type: 'callback_response',
|
||||||
|
id: data.id,
|
||||||
|
result,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await this.sendMessage(JSON.stringify({
|
||||||
|
type: 'callback_response',
|
||||||
|
id: data.id,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async send (request: RpcRequest): Promise<RpcResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pendingRequests.set(request.id, { resolve, reject });
|
||||||
|
|
||||||
|
const message = JSON.stringify({
|
||||||
|
type: 'request',
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.resolve(this.sendMessage(message)).catch(reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onCallback (handler: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>): void {
|
||||||
|
this.callbackHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
close (): void {
|
||||||
|
for (const [, pending] of this.pendingRequests) {
|
||||||
|
pending.reject(new Error('Transport closed'));
|
||||||
|
}
|
||||||
|
this.pendingRequests.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建消息传输层的服务端处理器
|
||||||
|
*/
|
||||||
|
export function createMessageServerHandler (target: unknown, options: {
|
||||||
|
sendMessage: (message: string) => void | Promise<void>;
|
||||||
|
onMessage: (handler: (message: string) => void) => void;
|
||||||
|
}): void {
|
||||||
|
const pendingCallbacks = new Map<string, {
|
||||||
|
resolve: (result: SerializedValue) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let callbackIdCounter = 0;
|
||||||
|
|
||||||
|
const server = new RpcServer({
|
||||||
|
target,
|
||||||
|
callbackInvoker: async (callbackId, args) => {
|
||||||
|
const id = `cb_call_${++callbackIdCounter}`;
|
||||||
|
const serializedArgs = args.map(arg => serialize(arg));
|
||||||
|
|
||||||
|
return new Promise<unknown>((resolve, reject) => {
|
||||||
|
pendingCallbacks.set(id, {
|
||||||
|
resolve: (result) => resolve(deserialize(result)),
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
|
||||||
|
options.sendMessage(JSON.stringify({
|
||||||
|
type: 'callback',
|
||||||
|
id,
|
||||||
|
callbackId,
|
||||||
|
args: serializedArgs,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
options.onMessage(async (message) => {
|
||||||
|
const data = JSON.parse(message) as {
|
||||||
|
type: 'request' | 'callback_response';
|
||||||
|
id: string;
|
||||||
|
request?: RpcRequest;
|
||||||
|
result?: SerializedValue;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.type === 'request' && data.request) {
|
||||||
|
const response = await server.handleRequest(data.request);
|
||||||
|
await options.sendMessage(JSON.stringify({
|
||||||
|
type: 'response',
|
||||||
|
id: data.request.id,
|
||||||
|
response,
|
||||||
|
}));
|
||||||
|
} else if (data.type === 'callback_response') {
|
||||||
|
const pending = pendingCallbacks.get(data.id);
|
||||||
|
if (pending) {
|
||||||
|
pendingCallbacks.delete(data.id);
|
||||||
|
if (data.error) {
|
||||||
|
pending.reject(new Error(data.error));
|
||||||
|
} else if (data.result) {
|
||||||
|
pending.resolve(data.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
195
packages/napcat-rpc/src/types.ts
Normal file
195
packages/napcat-rpc/src/types.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* RPC 操作类型
|
||||||
|
*/
|
||||||
|
export enum RpcOperationType {
|
||||||
|
/** 获取属性 */
|
||||||
|
GET = 'get',
|
||||||
|
/** 设置属性 */
|
||||||
|
SET = 'set',
|
||||||
|
/** 调用方法 */
|
||||||
|
APPLY = 'apply',
|
||||||
|
/** 构造函数调用 */
|
||||||
|
CONSTRUCT = 'construct',
|
||||||
|
/** 检查属性是否存在 */
|
||||||
|
HAS = 'has',
|
||||||
|
/** 获取所有键 */
|
||||||
|
OWNKEYS = 'ownKeys',
|
||||||
|
/** 删除属性 */
|
||||||
|
DELETE = 'deleteProperty',
|
||||||
|
/** 获取属性描述符 */
|
||||||
|
GET_DESCRIPTOR = 'getOwnPropertyDescriptor',
|
||||||
|
/** 获取原型 */
|
||||||
|
GET_PROTOTYPE = 'getPrototypeOf',
|
||||||
|
/** 回调调用 */
|
||||||
|
CALLBACK = 'callback',
|
||||||
|
/** 释放资源 */
|
||||||
|
RELEASE = 'release',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 请求消息
|
||||||
|
*/
|
||||||
|
export interface RpcRequest {
|
||||||
|
/** 请求 ID */
|
||||||
|
id: string;
|
||||||
|
/** 操作类型 */
|
||||||
|
type: RpcOperationType;
|
||||||
|
/** 访问路径 (从根对象开始) */
|
||||||
|
path: PropertyKey[];
|
||||||
|
/** 参数 (用于 set, apply, construct) */
|
||||||
|
args?: SerializedValue[];
|
||||||
|
/** 回调 ID 映射 (参数索引 -> 回调 ID) */
|
||||||
|
callbackIds?: Record<number, string>;
|
||||||
|
/** 远程对象引用 ID(用于对引用对象的操作) */
|
||||||
|
refId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 响应消息
|
||||||
|
*/
|
||||||
|
export interface RpcResponse {
|
||||||
|
/** 请求 ID */
|
||||||
|
id: string;
|
||||||
|
/** 是否成功 */
|
||||||
|
success: boolean;
|
||||||
|
/** 返回值 */
|
||||||
|
result?: SerializedValue;
|
||||||
|
/** 错误信息 */
|
||||||
|
error?: string;
|
||||||
|
/** 错误堆栈 */
|
||||||
|
stack?: string;
|
||||||
|
/** 结果是否为可代理对象 */
|
||||||
|
isProxyable?: boolean;
|
||||||
|
/** 远程对象引用 ID(用于深层对象代理) */
|
||||||
|
refId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化后的值
|
||||||
|
*/
|
||||||
|
export interface SerializedValue {
|
||||||
|
/** 值类型 */
|
||||||
|
type: SerializedValueType;
|
||||||
|
/** 原始值(用于基本类型) */
|
||||||
|
value?: unknown;
|
||||||
|
/** 对象类型名称 */
|
||||||
|
className?: string;
|
||||||
|
/** 回调 ID(用于函数) */
|
||||||
|
callbackId?: string;
|
||||||
|
/** 代理路径(用于可代理对象) */
|
||||||
|
proxyPath?: PropertyKey[];
|
||||||
|
/** 数组元素或对象属性 */
|
||||||
|
properties?: Record<string, SerializedValue>;
|
||||||
|
/** 数组元素 */
|
||||||
|
elements?: SerializedValue[];
|
||||||
|
/** 远程对象引用 ID(用于保持代理能力) */
|
||||||
|
refId?: string;
|
||||||
|
/** 缓存的属性值(OBJECT_REF 时使用,避免属性访问需要 RPC) */
|
||||||
|
cachedProps?: Record<string, SerializedValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化值类型
|
||||||
|
*/
|
||||||
|
export enum SerializedValueType {
|
||||||
|
UNDEFINED = 'undefined',
|
||||||
|
NULL = 'null',
|
||||||
|
BOOLEAN = 'boolean',
|
||||||
|
NUMBER = 'number',
|
||||||
|
BIGINT = 'bigint',
|
||||||
|
STRING = 'string',
|
||||||
|
SYMBOL = 'symbol',
|
||||||
|
FUNCTION = 'function',
|
||||||
|
OBJECT = 'object',
|
||||||
|
ARRAY = 'array',
|
||||||
|
DATE = 'date',
|
||||||
|
REGEXP = 'regexp',
|
||||||
|
ERROR = 'error',
|
||||||
|
PROMISE = 'promise',
|
||||||
|
PROXY_REF = 'proxyRef',
|
||||||
|
BUFFER = 'buffer',
|
||||||
|
MAP = 'map',
|
||||||
|
SET = 'set',
|
||||||
|
/** 远程对象引用 - 保持代理能力 */
|
||||||
|
OBJECT_REF = 'objectRef',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对象引用信息
|
||||||
|
*/
|
||||||
|
export interface ObjectRef {
|
||||||
|
/** 引用 ID */
|
||||||
|
refId: string;
|
||||||
|
/** 对象类型名称 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 传输层接口
|
||||||
|
*/
|
||||||
|
export interface RpcTransport {
|
||||||
|
/** 发送请求并等待响应 */
|
||||||
|
send (request: RpcRequest): Promise<RpcResponse>;
|
||||||
|
/** 注册回调处理器 */
|
||||||
|
onCallback?(handler: (callbackId: string, args: SerializedValue[]) => Promise<SerializedValue>): void;
|
||||||
|
/** 关闭连接 */
|
||||||
|
close?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 服务端处理器接口
|
||||||
|
*/
|
||||||
|
export interface RpcServerHandler {
|
||||||
|
/** 处理请求 */
|
||||||
|
handleRequest (request: RpcRequest): Promise<RpcResponse>;
|
||||||
|
/** 调用客户端回调 */
|
||||||
|
invokeCallback?(callbackId: string, args: unknown[]): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深层代理选项
|
||||||
|
*/
|
||||||
|
export interface DeepProxyOptions {
|
||||||
|
/** 传输层 */
|
||||||
|
transport: RpcTransport;
|
||||||
|
/** 根路径 */
|
||||||
|
rootPath?: PropertyKey[];
|
||||||
|
/** 是否缓存属性 */
|
||||||
|
cacheProperties?: boolean;
|
||||||
|
/** 回调超时时间 (ms) */
|
||||||
|
callbackTimeout?: number;
|
||||||
|
/** 远程对象引用 ID(用于引用对象的代理) */
|
||||||
|
refId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC 服务端选项
|
||||||
|
*/
|
||||||
|
export interface RpcServerOptions {
|
||||||
|
/** 目标对象 */
|
||||||
|
target: unknown;
|
||||||
|
/** 回调调用器 */
|
||||||
|
callbackInvoker?: (callbackId: string, args: unknown[]) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* 判断返回值是否应保持代理引用(而非完全序列化)
|
||||||
|
* 默认对 class 实例和包含方法的对象返回 true
|
||||||
|
*/
|
||||||
|
shouldProxyResult?: (value: unknown) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理元数据符号
|
||||||
|
*/
|
||||||
|
export const PROXY_META = Symbol('PROXY_META');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理元数据
|
||||||
|
*/
|
||||||
|
export interface ProxyMeta {
|
||||||
|
/** 访问路径 */
|
||||||
|
path: PropertyKey[];
|
||||||
|
/** 是否为代理 */
|
||||||
|
isProxy: true;
|
||||||
|
/** 远程对象引用 ID */
|
||||||
|
refId?: string;
|
||||||
|
}
|
||||||
21
packages/napcat-rpc/tsconfig.json
Normal file
21
packages/napcat-rpc/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"noEmit": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"../*/"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -15,11 +15,13 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
conditions: ['node', 'default'],
|
conditions: ['node', 'default'],
|
||||||
alias: {
|
alias: {
|
||||||
|
'@/napcat-rpc': resolve(__dirname, '../napcat-rpc'),
|
||||||
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
||||||
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
||||||
'@/napcat-schema': resolve(__dirname, './src'),
|
'@/napcat-schema': resolve(__dirname, './src'),
|
||||||
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
||||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||||
|
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ async function initializeLoginService (
|
|||||||
commonPath: dataPathGlobal,
|
commonPath: dataPathGlobal,
|
||||||
clientVer: basicInfoWrapper.getFullQQVersion(),
|
clientVer: basicInfoWrapper.getFullQQVersion(),
|
||||||
hostName: hostname,
|
hostName: hostname,
|
||||||
|
externalVersion: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +221,52 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 注册密码登录回调
|
||||||
|
WebUiDataRuntime.setPasswordLoginCall(async (uin: string, passwordMd5: string) => {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
if (uin && passwordMd5) {
|
||||||
|
logger.log('正在密码登录 ', uin);
|
||||||
|
loginService.passwordLogin({
|
||||||
|
uin,
|
||||||
|
passwordMd5,
|
||||||
|
step: 0,
|
||||||
|
newDeviceLoginSig: '',
|
||||||
|
proofWaterSig: '',
|
||||||
|
proofWaterRand: '',
|
||||||
|
proofWaterSid: '',
|
||||||
|
}).then(res => {
|
||||||
|
if (res.result === '140022008') {
|
||||||
|
const errMsg = '需要验证码,暂不支持';
|
||||||
|
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||||
|
loginService.getQRCodePicture();
|
||||||
|
resolve({ result: false, message: errMsg });
|
||||||
|
} else if (res.result === '140022010') {
|
||||||
|
const errMsg = '新设备需要扫码登录,暂不支持';
|
||||||
|
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||||
|
loginService.getQRCodePicture();
|
||||||
|
resolve({ result: false, message: errMsg });
|
||||||
|
} else if (res.result !== '0') {
|
||||||
|
const errMsg = res.loginErrorInfo?.errMsg || '密码登录失败';
|
||||||
|
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||||
|
loginService.getQRCodePicture();
|
||||||
|
resolve({ result: false, message: errMsg });
|
||||||
|
} else {
|
||||||
|
WebUiDataRuntime.setQQLoginStatus(true);
|
||||||
|
WebUiDataRuntime.setQQLoginError('');
|
||||||
|
resolve({ result: true, message: '' });
|
||||||
|
}
|
||||||
|
}).catch((e) => {
|
||||||
|
logger.logError(e);
|
||||||
|
WebUiDataRuntime.setQQLoginError('密码登录发生错误');
|
||||||
|
loginService.getQRCodePicture();
|
||||||
|
resolve({ result: false, message: '密码登录发生错误' });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve({ result: false, message: '密码登录失败:参数不完整' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
if (quickLoginUin) {
|
if (quickLoginUin) {
|
||||||
if (historyLoginList.some(u => u.uin === quickLoginUin)) {
|
if (historyLoginList.some(u => u.uin === quickLoginUin)) {
|
||||||
logger.log('正在快速登录 ', quickLoginUin);
|
logger.log('正在快速登录 ', quickLoginUin);
|
||||||
|
|||||||
@@ -7,11 +7,33 @@ import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
|
|||||||
import { webUiRuntimePort } from '@/napcat-webui-backend/index';
|
import { webUiRuntimePort } from '@/napcat-webui-backend/index';
|
||||||
import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api';
|
import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
// ES 模块中获取 __dirname
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
const pathWrapper = new NapCatPathWrapper();
|
||||||
|
const envPath = path.join(__dirname, 'config', '.env');
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(envPath, 'utf8');
|
||||||
|
let loadedCount = 0;
|
||||||
|
data.split(/\r?\n/).forEach(line => {
|
||||||
|
line = line.trim();
|
||||||
|
if (line && !line.startsWith('#')) {
|
||||||
|
const parts = line.split('=');
|
||||||
|
const key = parts[0]?.trim();
|
||||||
|
const value = parts.slice(1).join('=').trim();
|
||||||
|
if (key && value) {
|
||||||
|
process.env[key] = value;
|
||||||
|
loadedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[NapCat] Failed to load .env file:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 环境变量配置
|
// 环境变量配置
|
||||||
const ENV = {
|
const ENV = {
|
||||||
@@ -20,6 +42,7 @@ const ENV = {
|
|||||||
isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1',
|
isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
||||||
// Worker 消息类型
|
// Worker 消息类型
|
||||||
interface WorkerMessage {
|
interface WorkerMessage {
|
||||||
type: 'restart' | 'restart-prepare' | 'shutdown';
|
type: 'restart' | 'restart-prepare' | 'shutdown';
|
||||||
@@ -27,8 +50,7 @@ interface WorkerMessage {
|
|||||||
port?: number;
|
port?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化日志
|
|
||||||
const pathWrapper = new NapCatPathWrapper();
|
|
||||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||||
|
|
||||||
// 进程管理器和当前 Worker 进程引用
|
// 进程管理器和当前 Worker 进程引用
|
||||||
@@ -223,21 +245,21 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
|
|||||||
// 如果不是由于主动重启或关闭引起的退出,尝试自动重新拉起
|
// 如果不是由于主动重启或关闭引起的退出,尝试自动重新拉起
|
||||||
if (!isRestarting && !isShuttingDown) {
|
if (!isRestarting && !isShuttingDown) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// 清理超出时间窗口的崩溃记录
|
// 清理超出时间窗口的崩溃记录
|
||||||
while (recentCrashTimestamps.length > 0 && now - recentCrashTimestamps[0]! > CRASH_TIME_WINDOW) {
|
while (recentCrashTimestamps.length > 0 && now - recentCrashTimestamps[0]! > CRASH_TIME_WINDOW) {
|
||||||
recentCrashTimestamps.shift();
|
recentCrashTimestamps.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录本次崩溃
|
// 记录本次崩溃
|
||||||
recentCrashTimestamps.push(now);
|
recentCrashTimestamps.push(now);
|
||||||
|
|
||||||
// 检查是否超过崩溃阈值
|
// 检查是否超过崩溃阈值
|
||||||
if (recentCrashTimestamps.length >= MAX_CRASHES_IN_WINDOW) {
|
if (recentCrashTimestamps.length >= MAX_CRASHES_IN_WINDOW) {
|
||||||
logger.logError(`[NapCat] [${processType}] Worker进程在 ${CRASH_TIME_WINDOW / 1000} 秒内异常退出 ${MAX_CRASHES_IN_WINDOW} 次,主进程退出`);
|
logger.logError(`[NapCat] [${processType}] Worker进程在 ${CRASH_TIME_WINDOW / 1000} 秒内异常退出 ${MAX_CRASHES_IN_WINDOW} 次,主进程退出`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出 (${recentCrashTimestamps.length}/${MAX_CRASHES_IN_WINDOW}),正在尝试重新拉起...`);
|
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出 (${recentCrashTimestamps.length}/${MAX_CRASHES_IN_WINDOW}),正在尝试重新拉起...`);
|
||||||
startWorker(true).catch(e => {
|
startWorker(true).catch(e => {
|
||||||
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
|
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const ShellBaseConfig = (source_map: boolean = false) =>
|
|||||||
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
||||||
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
||||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||||
'@/image-size': resolve(__dirname, '../image-size'),
|
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||||
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
346
packages/napcat-test/imageSize.test.ts
Normal file
346
packages/napcat-test/imageSize.test.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
detectImageTypeFromBuffer,
|
||||||
|
imageSizeFromBuffer,
|
||||||
|
imageSizeFromBufferFallBack,
|
||||||
|
imageSizeFromFile,
|
||||||
|
matchMagic,
|
||||||
|
ImageType,
|
||||||
|
} from '@/napcat-image-size/src';
|
||||||
|
|
||||||
|
// resource 目录路径
|
||||||
|
const resourceDir = path.resolve(__dirname, '../napcat-image-size/resource');
|
||||||
|
|
||||||
|
// 测试用的 Buffer 数据
|
||||||
|
const testBuffers = {
|
||||||
|
// PNG 测试图片 (100x200)
|
||||||
|
png: Buffer.from([
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||||
|
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||||
|
0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0xC8,
|
||||||
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
]),
|
||||||
|
|
||||||
|
// JPEG 测试图片 (320x240)
|
||||||
|
jpeg: Buffer.from([
|
||||||
|
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10,
|
||||||
|
0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||||
|
0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
|
||||||
|
0xFF, 0xC0, 0x00, 0x0B, 0x08,
|
||||||
|
0x00, 0xF0, 0x01, 0x40, 0x03, 0x01, 0x22, 0x00,
|
||||||
|
]),
|
||||||
|
|
||||||
|
// BMP 测试图片 (640x480)
|
||||||
|
bmp: (() => {
|
||||||
|
const buf = Buffer.alloc(54);
|
||||||
|
buf.write('BM', 0);
|
||||||
|
buf.writeUInt32LE(54, 2);
|
||||||
|
buf.writeUInt32LE(0, 6);
|
||||||
|
buf.writeUInt32LE(54, 10);
|
||||||
|
buf.writeUInt32LE(40, 14);
|
||||||
|
buf.writeUInt32LE(640, 18);
|
||||||
|
buf.writeUInt32LE(480, 22);
|
||||||
|
buf.writeUInt16LE(1, 26);
|
||||||
|
buf.writeUInt16LE(24, 28);
|
||||||
|
return buf;
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// GIF87a 测试图片 (800x600)
|
||||||
|
gif87a: Buffer.from([
|
||||||
|
0x47, 0x49, 0x46, 0x38, 0x37, 0x61,
|
||||||
|
0x20, 0x03, 0x58, 0x02, 0x00, 0x00, 0x00,
|
||||||
|
]),
|
||||||
|
|
||||||
|
// GIF89a 测试图片 (1024x768)
|
||||||
|
gif89a: Buffer.from([
|
||||||
|
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
|
||||||
|
0x00, 0x04, 0x00, 0x03, 0x00, 0x00, 0x00,
|
||||||
|
]),
|
||||||
|
|
||||||
|
// WebP VP8 测试图片 (1920x1080)
|
||||||
|
webpVP8: (() => {
|
||||||
|
const buf = Buffer.alloc(32);
|
||||||
|
buf.write('RIFF', 0);
|
||||||
|
buf.writeUInt32LE(24, 4);
|
||||||
|
buf.write('WEBP', 8);
|
||||||
|
buf.write('VP8 ', 12);
|
||||||
|
buf.writeUInt32LE(14, 16);
|
||||||
|
buf.writeUInt8(0x9D, 20);
|
||||||
|
buf.writeUInt8(0x01, 21);
|
||||||
|
buf.writeUInt8(0x2A, 22);
|
||||||
|
buf.writeUInt16LE(1920 & 0x3FFF, 26);
|
||||||
|
buf.writeUInt16LE(1080 & 0x3FFF, 28);
|
||||||
|
return buf;
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// WebP VP8L 测试图片 (256x128)
|
||||||
|
webpVP8L: (() => {
|
||||||
|
const buf = Buffer.alloc(32);
|
||||||
|
buf.write('RIFF', 0);
|
||||||
|
buf.writeUInt32LE(24, 4);
|
||||||
|
buf.write('WEBP', 8);
|
||||||
|
buf.write('VP8L', 12);
|
||||||
|
buf.writeUInt32LE(10, 16);
|
||||||
|
buf.writeUInt8(0x2F, 20);
|
||||||
|
const vp8lBits = (256 - 1) | ((128 - 1) << 14);
|
||||||
|
buf.writeUInt32LE(vp8lBits, 21);
|
||||||
|
return buf;
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// WebP VP8X 测试图片 (512x384)
|
||||||
|
webpVP8X: (() => {
|
||||||
|
const buf = Buffer.alloc(32);
|
||||||
|
buf.write('RIFF', 0);
|
||||||
|
buf.writeUInt32LE(24, 4);
|
||||||
|
buf.write('WEBP', 8);
|
||||||
|
buf.write('VP8X', 12);
|
||||||
|
buf.writeUInt32LE(10, 16);
|
||||||
|
buf.writeUInt8((512 - 1) & 0xFF, 24);
|
||||||
|
buf.writeUInt8(((512 - 1) >> 8) & 0xFF, 25);
|
||||||
|
buf.writeUInt8(((512 - 1) >> 16) & 0xFF, 26);
|
||||||
|
buf.writeUInt8((384 - 1) & 0xFF, 27);
|
||||||
|
buf.writeUInt8(((384 - 1) >> 8) & 0xFF, 28);
|
||||||
|
buf.writeUInt8(((384 - 1) >> 16) & 0xFF, 29);
|
||||||
|
return buf;
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// TIFF Little Endian 测试图片
|
||||||
|
tiffLE: Buffer.from([
|
||||||
|
0x49, 0x49, 0x2A, 0x00, // II + magic
|
||||||
|
0x08, 0x00, 0x00, 0x00, // IFD offset = 8
|
||||||
|
0x02, 0x00, // 2 entries
|
||||||
|
// Entry 1: ImageWidth = 100
|
||||||
|
0x00, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00,
|
||||||
|
// Entry 2: ImageHeight = 200
|
||||||
|
0x01, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0xC8, 0x00, 0x00, 0x00,
|
||||||
|
]),
|
||||||
|
|
||||||
|
// TIFF Big Endian 测试图片
|
||||||
|
tiffBE: Buffer.from([
|
||||||
|
0x4D, 0x4D, 0x00, 0x2A, // MM + magic
|
||||||
|
0x00, 0x00, 0x00, 0x08, // IFD offset = 8
|
||||||
|
0x00, 0x02, // 2 entries
|
||||||
|
// Entry 1: ImageWidth = 100
|
||||||
|
0x01, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x64, 0x00, 0x00,
|
||||||
|
// Entry 2: ImageHeight = 200
|
||||||
|
0x01, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0xC8, 0x00, 0x00,
|
||||||
|
]),
|
||||||
|
|
||||||
|
invalid: Buffer.from('This is not an image file'),
|
||||||
|
empty: Buffer.alloc(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('napcat-image-size', () => {
|
||||||
|
describe('matchMagic', () => {
|
||||||
|
it('should match magic bytes at the beginning', () => {
|
||||||
|
const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||||
|
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match magic bytes at offset', () => {
|
||||||
|
const buffer = Buffer.from([0x00, 0x00, 0x89, 0x50, 0x4E, 0x47]);
|
||||||
|
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47], 2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-matching magic', () => {
|
||||||
|
const buffer = Buffer.from([0x00, 0x00, 0x00, 0x00]);
|
||||||
|
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for buffer too short', () => {
|
||||||
|
const buffer = Buffer.from([0x89, 0x50]);
|
||||||
|
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for offset beyond buffer', () => {
|
||||||
|
const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]);
|
||||||
|
expect(matchMagic(buffer, [0x89, 0x50], 10)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectImageTypeFromBuffer', () => {
|
||||||
|
it('should detect PNG image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.png)).toBe(ImageType.PNG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect JPEG image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.jpeg)).toBe(ImageType.JPEG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect BMP image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.bmp)).toBe(ImageType.BMP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect GIF87a image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.gif87a)).toBe(ImageType.GIF);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect GIF89a image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.gif89a)).toBe(ImageType.GIF);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect WebP VP8 image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.webpVP8)).toBe(ImageType.WEBP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect WebP VP8L image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.webpVP8L)).toBe(ImageType.WEBP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect WebP VP8X image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.webpVP8X)).toBe(ImageType.WEBP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect TIFF Little Endian image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.tiffLE)).toBe(ImageType.TIFF);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect TIFF Big Endian image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.tiffBE)).toBe(ImageType.TIFF);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return UNKNOWN for invalid data', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.invalid)).toBe(ImageType.UNKNOWN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return UNKNOWN for empty buffer', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.empty)).toBe(ImageType.UNKNOWN);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('imageSizeFromBuffer', () => {
|
||||||
|
it('should parse PNG image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.png)).toEqual({ width: 100, height: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse JPEG image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.jpeg)).toEqual({ width: 320, height: 240 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse BMP image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.bmp)).toEqual({ width: 640, height: 480 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse GIF87a image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.gif87a)).toEqual({ width: 800, height: 600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse GIF89a image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.gif89a)).toEqual({ width: 1024, height: 768 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse WebP VP8 image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.webpVP8)).toEqual({ width: 1920, height: 1080 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse WebP VP8L image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.webpVP8L)).toEqual({ width: 256, height: 128 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse WebP VP8X image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.webpVP8X)).toEqual({ width: 512, height: 384 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse TIFF Little Endian image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.tiffLE)).toEqual({ width: 100, height: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse TIFF Big Endian image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.tiffBE)).toEqual({ width: 100, height: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for invalid data', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.invalid)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for empty buffer', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.empty)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('imageSizeFromBufferFallBack', () => {
|
||||||
|
it('should return actual size for valid image', async () => {
|
||||||
|
expect(await imageSizeFromBufferFallBack(testBuffers.png)).toEqual({ width: 100, height: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default fallback for invalid data', async () => {
|
||||||
|
expect(await imageSizeFromBufferFallBack(testBuffers.invalid)).toEqual({ width: 1024, height: 1024 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return custom fallback for invalid data', async () => {
|
||||||
|
expect(await imageSizeFromBufferFallBack(testBuffers.invalid, { width: 500, height: 300 })).toEqual({ width: 500, height: 300 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default fallback for empty buffer', async () => {
|
||||||
|
expect(await imageSizeFromBufferFallBack(testBuffers.empty)).toEqual({ width: 1024, height: 1024 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return custom fallback for empty buffer', async () => {
|
||||||
|
expect(await imageSizeFromBufferFallBack(testBuffers.empty, { width: 800, height: 600 })).toEqual({ width: 800, height: 600 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ImageType enum', () => {
|
||||||
|
it('should have correct enum values', () => {
|
||||||
|
expect(ImageType.JPEG).toBe('jpeg');
|
||||||
|
expect(ImageType.PNG).toBe('png');
|
||||||
|
expect(ImageType.BMP).toBe('bmp');
|
||||||
|
expect(ImageType.GIF).toBe('gif');
|
||||||
|
expect(ImageType.WEBP).toBe('webp');
|
||||||
|
expect(ImageType.TIFF).toBe('tiff');
|
||||||
|
expect(ImageType.UNKNOWN).toBe('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Real image files from resource directory', () => {
|
||||||
|
it('should detect and parse test-20x20.jpg', async () => {
|
||||||
|
const filePath = path.join(resourceDir, 'test-20x20.jpg');
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.JPEG);
|
||||||
|
const size = await imageSizeFromBuffer(buffer);
|
||||||
|
expect(size).toEqual({ width: 20, height: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect and parse test-20x20.png', async () => {
|
||||||
|
const filePath = path.join(resourceDir, 'test-20x20.png');
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.PNG);
|
||||||
|
const size = await imageSizeFromBuffer(buffer);
|
||||||
|
expect(size).toEqual({ width: 20, height: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect and parse test-20x20.tiff', async () => {
|
||||||
|
const filePath = path.join(resourceDir, 'test-20x20.tiff');
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.TIFF);
|
||||||
|
const size = await imageSizeFromBuffer(buffer);
|
||||||
|
expect(size).toEqual({ width: 20, height: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect and parse test-20x20.webp', async () => {
|
||||||
|
const filePath = path.join(resourceDir, 'test-20x20.webp');
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.WEBP);
|
||||||
|
const size = await imageSizeFromBuffer(buffer);
|
||||||
|
expect(size).toEqual({ width: 20, height: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect and parse test-490x498.gif', async () => {
|
||||||
|
const filePath = path.join(resourceDir, 'test-490x498.gif');
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.GIF);
|
||||||
|
const size = await imageSizeFromBuffer(buffer);
|
||||||
|
expect(size).toEqual({ width: 490, height: 498 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse real images using imageSizeFromFile', async () => {
|
||||||
|
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.jpg'))).toEqual({ width: 20, height: 20 });
|
||||||
|
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.png'))).toEqual({ width: 20, height: 20 });
|
||||||
|
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.tiff'))).toEqual({ width: 20, height: 20 });
|
||||||
|
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.webp'))).toEqual({ width: 20, height: 20 });
|
||||||
|
expect(await imageSizeFromFile(path.join(resourceDir, 'test-490x498.gif'))).toEqual({ width: 490, height: 498 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
"vitest": "^4.0.9"
|
"vitest": "^4.0.9"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"napcat-core": "workspace:*"
|
"napcat-core": "workspace:*",
|
||||||
|
"napcat-rpc": "workspace:*",
|
||||||
|
"napcat-image-size": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1468
packages/napcat-test/rpc.test.ts
Normal file
1468
packages/napcat-test/rpc.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, '../../'),
|
'@/napcat-rpc': resolve(__dirname, '../napcat-rpc'),
|
||||||
|
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||||
|
'@/napcat-test': resolve(__dirname, '.'),
|
||||||
|
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
||||||
|
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "napcat-types",
|
"name": "napcat-types",
|
||||||
"version": "0.0.14",
|
"version": "0.0.16",
|
||||||
"private": false,
|
"private": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"types": "./napcat-types/index.d.ts",
|
"types": "./napcat-types/index.d.ts",
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import compression from 'compression';
|
|||||||
import { napCatVersion } from 'napcat-common/src/version';
|
import { napCatVersion } from 'napcat-common/src/version';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||||
|
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
@@ -123,9 +125,14 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查并更新默认密码(仅在启用WebUI时)
|
// 优先使用环境变量覆盖 Token
|
||||||
if (config.token === 'napcat' || !config.token) {
|
if (process.env['NAPCAT_WEBUI_SECRET_KEY'] && config.token !== process.env['NAPCAT_WEBUI_SECRET_KEY']) {
|
||||||
const randomToken = process.env['NAPCAT_WEBUI_SECRET_KEY'] || getRandomToken(8);
|
await WebUiConfig.UpdateWebUIConfig({ token: process.env['NAPCAT_WEBUI_SECRET_KEY'] });
|
||||||
|
logger.log(`[NapCat] [WebUi] 检测到环境变量配置,已更新 WebUI Token 为 ${process.env['NAPCAT_WEBUI_SECRET_KEY']}`);
|
||||||
|
config = await WebUiConfig.GetWebUIConfig();
|
||||||
|
} else if (config.token === 'napcat' || !config.token) {
|
||||||
|
// 只有没设置环境变量,且是默认密码时,才生成随机密码
|
||||||
|
const randomToken = getRandomToken(8);
|
||||||
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
|
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
|
||||||
logger.log('[NapCat] [WebUi] 检测到默认密码,已自动更新为安全密码');
|
logger.log('[NapCat] [WebUi] 检测到默认密码,已自动更新为安全密码');
|
||||||
|
|
||||||
@@ -226,10 +233,13 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
|||||||
// 添加字体变量
|
// 添加字体变量
|
||||||
if (fontMode === 'aacute') {
|
if (fontMode === 'aacute') {
|
||||||
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||||||
|
css += "--font-family-mono: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||||||
} else if (fontMode === 'custom') {
|
} else if (fontMode === 'custom') {
|
||||||
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
|
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||||||
|
css += "--font-family-mono: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||||||
} else {
|
} else {
|
||||||
css += '--font-family-base: var(--font-family-fallbacks) !important;';
|
css += '--font-family-base: var(--font-family-fallbacks) !important;';
|
||||||
|
css += '--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;';
|
||||||
}
|
}
|
||||||
css += '}';
|
css += '}';
|
||||||
|
|
||||||
@@ -240,10 +250,13 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
|||||||
// 添加字体变量
|
// 添加字体变量
|
||||||
if (fontMode === 'aacute') {
|
if (fontMode === 'aacute') {
|
||||||
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||||||
|
css += "--font-family-mono: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||||||
} else if (fontMode === 'custom') {
|
} else if (fontMode === 'custom') {
|
||||||
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
|
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||||||
|
css += "--font-family-mono: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||||||
} else {
|
} else {
|
||||||
css += '--font-family-base: var(--font-family-fallbacks) !important;';
|
css += '--font-family-base: var(--font-family-fallbacks) !important;';
|
||||||
|
css += '--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;';
|
||||||
}
|
}
|
||||||
css += '}';
|
css += '}';
|
||||||
|
|
||||||
@@ -283,6 +296,130 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
|||||||
app.use('/webui', express.static(pathWrapper.staticPath, {
|
app.use('/webui', express.static(pathWrapper.staticPath, {
|
||||||
maxAge: '1d',
|
maxAge: '1d',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 插件内存静态资源路由(不需要鉴权)
|
||||||
|
// 路径格式: /plugin/:pluginId/mem/:urlPath/*
|
||||||
|
app.use('/plugin/:pluginId/mem', async (req, res) => {
|
||||||
|
const { pluginId } = req.params;
|
||||||
|
if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' });
|
||||||
|
|
||||||
|
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null;
|
||||||
|
if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' });
|
||||||
|
|
||||||
|
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined;
|
||||||
|
if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' });
|
||||||
|
|
||||||
|
const routerRegistry = pluginManager.getPluginRouter(pluginId);
|
||||||
|
const memoryRoutes = routerRegistry?.getMemoryStaticRoutes() || [];
|
||||||
|
|
||||||
|
for (const { urlPath, files } of memoryRoutes) {
|
||||||
|
const prefix = urlPath.startsWith('/') ? urlPath : '/' + urlPath;
|
||||||
|
if (req.path.startsWith(prefix)) {
|
||||||
|
const filePath = '/' + (req.path.substring(prefix.length).replace(/^\//, '') || '');
|
||||||
|
const memFile = files.find(f => ('/' + f.path.replace(/^\//, '')) === filePath);
|
||||||
|
if (memFile) {
|
||||||
|
try {
|
||||||
|
const content = typeof memFile.content === 'function' ? await memFile.content() : memFile.content;
|
||||||
|
res.setHeader('Content-Type', memFile.contentType || 'application/octet-stream');
|
||||||
|
return res.send(content);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Plugin: ${pluginId}] Error serving memory file:`, err);
|
||||||
|
return res.status(500).json({ code: -1, message: 'Error serving memory file' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.status(404).json({ code: -1, message: 'Memory file not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 插件无认证 API 路由(不需要鉴权)
|
||||||
|
// 路径格式: /plugin/:pluginId/api/*
|
||||||
|
app.use('/plugin/:pluginId/api', (req, res, next) => {
|
||||||
|
const { pluginId } = req.params;
|
||||||
|
if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' });
|
||||||
|
|
||||||
|
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null;
|
||||||
|
if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' });
|
||||||
|
|
||||||
|
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined;
|
||||||
|
if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' });
|
||||||
|
|
||||||
|
const routerRegistry = pluginManager.getPluginRouter(pluginId);
|
||||||
|
if (!routerRegistry || !routerRegistry.hasApiNoAuthRoutes()) {
|
||||||
|
return res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered no-auth API routes` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建并执行插件无认证 API 路由
|
||||||
|
const pluginRouter = routerRegistry.buildApiNoAuthRouter();
|
||||||
|
return pluginRouter(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 插件页面路由(不需要鉴权)
|
||||||
|
// 路径格式: /plugin/:pluginId/page/:pagePath
|
||||||
|
app.get('/plugin/:pluginId/page/:pagePath', (req, res) => {
|
||||||
|
const { pluginId, pagePath } = req.params;
|
||||||
|
if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' });
|
||||||
|
|
||||||
|
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null;
|
||||||
|
if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' });
|
||||||
|
|
||||||
|
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined;
|
||||||
|
if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' });
|
||||||
|
|
||||||
|
const routerRegistry = pluginManager.getPluginRouter(pluginId);
|
||||||
|
if (!routerRegistry || !routerRegistry.hasPages()) {
|
||||||
|
return res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered pages` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = routerRegistry.getPages();
|
||||||
|
const page = pages.find(p => p.path === '/' + pagePath || p.path === pagePath);
|
||||||
|
if (!page) {
|
||||||
|
return res.status(404).json({ code: -1, message: `Page '${pagePath}' not found in plugin '${pluginId}'` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginPath = routerRegistry.getPluginPath();
|
||||||
|
if (!pluginPath) {
|
||||||
|
return res.status(500).json({ code: -1, message: 'Plugin path not available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlFilePath = join(pluginPath, page.htmlFile);
|
||||||
|
if (!existsSync(htmlFilePath)) {
|
||||||
|
return res.status(404).json({ code: -1, message: `HTML file not found: ${page.htmlFile}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.sendFile(htmlFilePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 插件文件系统静态资源路由(不需要鉴权)
|
||||||
|
// 路径格式: /plugin/:pluginId/files/*
|
||||||
|
app.use('/plugin/:pluginId/files', (req, res, next) => {
|
||||||
|
const { pluginId } = req.params;
|
||||||
|
if (!pluginId) return res.status(400).json({ code: -1, message: 'Plugin ID is required' });
|
||||||
|
|
||||||
|
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter | null;
|
||||||
|
if (!ob11) return res.status(503).json({ code: -1, message: 'OneBot context not available' });
|
||||||
|
|
||||||
|
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter | undefined;
|
||||||
|
if (!pluginManager) return res.status(503).json({ code: -1, message: 'Plugin manager not available' });
|
||||||
|
|
||||||
|
const routerRegistry = pluginManager.getPluginRouter(pluginId);
|
||||||
|
const staticRoutes = routerRegistry?.getStaticRoutes() || [];
|
||||||
|
|
||||||
|
for (const { urlPath, localPath } of staticRoutes) {
|
||||||
|
const prefix = urlPath.startsWith('/') ? urlPath : '/' + urlPath;
|
||||||
|
if (req.path.startsWith(prefix) || req.path === prefix.slice(0, -1)) {
|
||||||
|
const staticMiddleware = express.static(localPath, { maxAge: '1d' });
|
||||||
|
const originalUrl = req.url;
|
||||||
|
req.url = '/' + (req.path.substring(prefix.length).replace(/^\//, '') || '');
|
||||||
|
return staticMiddleware(req, res, (err) => {
|
||||||
|
req.url = originalUrl;
|
||||||
|
err ? next(err) : next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.status(404).json({ code: -1, message: 'Static resource not found' });
|
||||||
|
});
|
||||||
|
|
||||||
// 初始化WebSocket服务器
|
// 初始化WebSocket服务器
|
||||||
const sslCerts = await checkCertificates(logger);
|
const sslCerts = await checkCertificates(logger);
|
||||||
const isHttps = !!sslCerts;
|
const isHttps = !!sslCerts;
|
||||||
|
|||||||
151
packages/napcat-webui-backend/src/api/BackupConfig.ts
Normal file
151
packages/napcat-webui-backend/src/api/BackupConfig.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { RequestHandler } from 'express';
|
||||||
|
import { existsSync, readdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||||
|
import { join, normalize } from 'node:path';
|
||||||
|
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||||
|
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||||
|
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||||
|
import compressing from 'compressing';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
|
||||||
|
// 使用compressing库进行流式压缩导出
|
||||||
|
export const BackupExportConfigHandler: RequestHandler = async (_req, res) => {
|
||||||
|
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||||
|
if (!isLogin) {
|
||||||
|
return sendError(res, 'Not Login');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configPath = webUiPathWrapper.configPath;
|
||||||
|
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
return sendError(res, '配置目录不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return date.toISOString().replace(/[:.]/g, '-');
|
||||||
|
};
|
||||||
|
const zipFileName = `config_backup_${formatDate(new Date())}.zip`;
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
|
||||||
|
|
||||||
|
// 使用compressing的Stream API进行流式压缩
|
||||||
|
const stream = new compressing.zip.Stream();
|
||||||
|
|
||||||
|
// 添加目录下的所有文件到压缩流(单层平坦结构)
|
||||||
|
const entries = readdirSync(configPath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isFile()) {
|
||||||
|
const entryPath = join(configPath, entry.name);
|
||||||
|
stream.addEntry(entryPath, { relativePath: entry.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管道传输到响应
|
||||||
|
stream.pipe(res);
|
||||||
|
|
||||||
|
// 处理流错误
|
||||||
|
stream.on('error', (err) => {
|
||||||
|
console.error('压缩流错误:', err);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
sendError(res, '流式压缩失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message;
|
||||||
|
console.error('导出配置失败:', error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return sendError(res, `导出配置失败: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从内存Buffer流式解压,返回文件名和内容的映射
|
||||||
|
async function extractZipToMemory (buffer: Buffer): Promise<Map<string, Buffer>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const files = new Map<string, Buffer>();
|
||||||
|
const bufferStream = Readable.from(buffer);
|
||||||
|
const uncompressStream = new compressing.zip.UncompressStream();
|
||||||
|
|
||||||
|
uncompressStream.on('entry', (header, stream, next) => {
|
||||||
|
// 只处理文件,忽略目录
|
||||||
|
if (header.type === 'file') {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||||
|
stream.on('end', () => {
|
||||||
|
// 取文件名(忽略路径中的目录部分)
|
||||||
|
const fileName = header.name.split('/').pop() || header.name;
|
||||||
|
files.set(fileName, Buffer.concat(chunks));
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
stream.on('error', (err) => {
|
||||||
|
console.error(`读取文件失败: ${header.name}`, err);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
stream.resume();
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
uncompressStream.on('finish', () => resolve(files));
|
||||||
|
uncompressStream.on('error', reject);
|
||||||
|
|
||||||
|
bufferStream.pipe(uncompressStream);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入配置 - 流式处理,完全在内存中解压
|
||||||
|
export const BackupImportConfigHandler: RequestHandler = async (req, res) => {
|
||||||
|
// 检查是否有文件上传(multer memoryStorage 模式下文件在 req.file.buffer)
|
||||||
|
if (!req.file || !req.file.buffer) {
|
||||||
|
return sendError(res, '请选择要导入的配置文件');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configPath = webUiPathWrapper.configPath;
|
||||||
|
|
||||||
|
// 从内存中解压zip
|
||||||
|
const extractedFiles = await extractZipToMemory(req.file.buffer);
|
||||||
|
|
||||||
|
if (extractedFiles.size === 0) {
|
||||||
|
return sendError(res, '配置文件为空或格式不正确');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备份当前配置到内存
|
||||||
|
const backupFiles = new Map<string, Buffer>();
|
||||||
|
if (existsSync(configPath)) {
|
||||||
|
const currentFiles = readdirSync(configPath, { withFileTypes: true });
|
||||||
|
for (const entry of currentFiles) {
|
||||||
|
if (entry.isFile()) {
|
||||||
|
const filePath = join(configPath, entry.name);
|
||||||
|
backupFiles.set(entry.name, readFileSync(filePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入新的配置文件
|
||||||
|
for (const [fileName, content] of extractedFiles) {
|
||||||
|
// 防止路径穿越攻击
|
||||||
|
const destPath = join(configPath, fileName);
|
||||||
|
const normalizedPath = normalize(destPath);
|
||||||
|
if (!normalizedPath.startsWith(normalize(configPath))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
writeFileSync(destPath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendSuccess(res, {
|
||||||
|
message: '配置导入成功,重启后生效~',
|
||||||
|
filesImported: extractedFiles.size,
|
||||||
|
filesBackedUp: backupFiles.size
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导入配置失败:', error);
|
||||||
|
const msg = (error as Error).message;
|
||||||
|
return sendError(res, `导入配置失败: ${msg}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -653,3 +653,13 @@ export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => {
|
|||||||
return sendError(res, '删除字体文件失败');
|
return sendError(res, '删除字体文件失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 检查WebUI字体文件是否存在
|
||||||
|
export const CheckWebUIFontExistHandler: RequestHandler = async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const exists = await WebUiConfig.CheckWebUIFontExist();
|
||||||
|
return sendSuccess(res, exists);
|
||||||
|
} catch (_error) {
|
||||||
|
return sendError(res, '检查字体文件失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ const CACHE_TTL = 10 * 60 * 1000; // 10分钟缓存
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 从多个源获取插件列表,使用镜像系统
|
* 从多个源获取插件列表,使用镜像系统
|
||||||
* 带10分钟缓存
|
* 带10分钟缓存,支持强制刷新
|
||||||
*/
|
*/
|
||||||
async function fetchPluginList (): Promise<PluginStoreList> {
|
async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginStoreList> {
|
||||||
// 检查缓存
|
// 检查缓存(如果不是强制刷新)
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
|
if (!forceRefresh && pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
|
||||||
//console.log('Using cached plugin list');
|
//console.log('Using cached plugin list');
|
||||||
return pluginListCache;
|
return pluginListCache;
|
||||||
}
|
}
|
||||||
@@ -157,6 +157,8 @@ async function downloadFile (url: string, destPath: string, customMirror?: strin
|
|||||||
async function extractPlugin (zipPath: string, pluginId: string): Promise<void> {
|
async function extractPlugin (zipPath: string, pluginId: string): Promise<void> {
|
||||||
const PLUGINS_DIR = getPluginsDir();
|
const PLUGINS_DIR = getPluginsDir();
|
||||||
const pluginDir = path.join(PLUGINS_DIR, pluginId);
|
const pluginDir = path.join(PLUGINS_DIR, pluginId);
|
||||||
|
const dataDir = path.join(pluginDir, 'data');
|
||||||
|
const tempDataDir = path.join(PLUGINS_DIR, `${pluginId}.data.backup`);
|
||||||
|
|
||||||
console.log(`[extractPlugin] PLUGINS_DIR: ${PLUGINS_DIR}`);
|
console.log(`[extractPlugin] PLUGINS_DIR: ${PLUGINS_DIR}`);
|
||||||
console.log(`[extractPlugin] pluginId: ${pluginId}`);
|
console.log(`[extractPlugin] pluginId: ${pluginId}`);
|
||||||
@@ -169,8 +171,19 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
|
|||||||
console.log(`[extractPlugin] Created plugins root directory: ${PLUGINS_DIR}`);
|
console.log(`[extractPlugin] Created plugins root directory: ${PLUGINS_DIR}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果目录已存在,先删除
|
// 如果目录已存在,先备份 data 文件夹,再删除
|
||||||
|
let hasDataBackup = false;
|
||||||
if (fs.existsSync(pluginDir)) {
|
if (fs.existsSync(pluginDir)) {
|
||||||
|
// 备份 data 文件夹
|
||||||
|
if (fs.existsSync(dataDir)) {
|
||||||
|
console.log(`[extractPlugin] Backing up data directory: ${dataDir}`);
|
||||||
|
if (fs.existsSync(tempDataDir)) {
|
||||||
|
fs.rmSync(tempDataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
fs.renameSync(dataDir, tempDataDir);
|
||||||
|
hasDataBackup = true;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[extractPlugin] Directory exists, removing: ${pluginDir}`);
|
console.log(`[extractPlugin] Directory exists, removing: ${pluginDir}`);
|
||||||
fs.rmSync(pluginDir, { recursive: true, force: true });
|
fs.rmSync(pluginDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -179,10 +192,35 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
|
|||||||
fs.mkdirSync(pluginDir, { recursive: true });
|
fs.mkdirSync(pluginDir, { recursive: true });
|
||||||
console.log(`[extractPlugin] Created directory: ${pluginDir}`);
|
console.log(`[extractPlugin] Created directory: ${pluginDir}`);
|
||||||
|
|
||||||
// 解压
|
try {
|
||||||
await compressing.zip.uncompress(zipPath, pluginDir);
|
// 解压
|
||||||
|
await compressing.zip.uncompress(zipPath, pluginDir);
|
||||||
|
|
||||||
console.log(`[extractPlugin] Plugin extracted to: ${pluginDir}`);
|
console.log(`[extractPlugin] Plugin extracted to: ${pluginDir}`);
|
||||||
|
|
||||||
|
// 恢复 data 文件夹
|
||||||
|
if (hasDataBackup && fs.existsSync(tempDataDir)) {
|
||||||
|
// 如果新版本也有 data 文件夹,先删除
|
||||||
|
if (fs.existsSync(dataDir)) {
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
console.log(`[extractPlugin] Restoring data directory: ${dataDir}`);
|
||||||
|
fs.renameSync(tempDataDir, dataDir);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 解压失败时,尝试恢复 data 文件夹
|
||||||
|
if (hasDataBackup && fs.existsSync(tempDataDir)) {
|
||||||
|
console.log(`[extractPlugin] Extract failed, restoring data directory`);
|
||||||
|
if (!fs.existsSync(pluginDir)) {
|
||||||
|
fs.mkdirSync(pluginDir, { recursive: true });
|
||||||
|
}
|
||||||
|
if (fs.existsSync(dataDir)) {
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
fs.renameSync(tempDataDir, dataDir);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
// 列出解压后的文件
|
// 列出解压后的文件
|
||||||
const files = fs.readdirSync(pluginDir);
|
const files = fs.readdirSync(pluginDir);
|
||||||
@@ -192,9 +230,11 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
|
|||||||
/**
|
/**
|
||||||
* 获取插件商店列表
|
* 获取插件商店列表
|
||||||
*/
|
*/
|
||||||
export const GetPluginStoreListHandler: RequestHandler = async (_req, res) => {
|
export const GetPluginStoreListHandler: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchPluginList();
|
// 支持 forceRefresh 查询参数强制刷新缓存
|
||||||
|
const forceRefresh = req.query['forceRefresh'] === 'true';
|
||||||
|
const data = await fetchPluginList(forceRefresh);
|
||||||
return sendSuccess(res, data);
|
return sendSuccess(res, data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return sendError(res, 'Failed to fetch plugin store list: ' + e.message);
|
return sendError(res, 'Failed to fetch plugin store list: ' + e.message);
|
||||||
@@ -252,11 +292,13 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
|
|||||||
// 删除临时文件
|
// 删除临时文件
|
||||||
fs.unlinkSync(tempZipPath);
|
fs.unlinkSync(tempZipPath);
|
||||||
|
|
||||||
// 如果 pluginManager 存在,立即注册插件
|
// 如果 pluginManager 存在,立即注册或重载插件
|
||||||
const pluginManager = getPluginManager();
|
const pluginManager = getPluginManager();
|
||||||
if (pluginManager) {
|
if (pluginManager) {
|
||||||
// 检查是否已注册,避免重复注册
|
// 如果插件已存在,则重载以刷新版本信息;否则注册新插件
|
||||||
if (!pluginManager.getPluginInfo(id)) {
|
if (pluginManager.getPluginInfo(id)) {
|
||||||
|
await pluginManager.reloadPlugin(id);
|
||||||
|
} else {
|
||||||
await pluginManager.loadPluginById(id);
|
await pluginManager.loadPluginById(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,11 +376,14 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
|||||||
sendProgress('解压完成,正在清理...', 90);
|
sendProgress('解压完成,正在清理...', 90);
|
||||||
fs.unlinkSync(tempZipPath);
|
fs.unlinkSync(tempZipPath);
|
||||||
|
|
||||||
// 如果 pluginManager 存在,立即注册插件
|
// 如果 pluginManager 存在,立即注册或重载插件
|
||||||
const pluginManager = getPluginManager();
|
const pluginManager = getPluginManager();
|
||||||
if (pluginManager) {
|
if (pluginManager) {
|
||||||
// 检查是否已注册,避免重复注册
|
// 如果插件已存在,则重载以刷新版本信息;否则注册新插件
|
||||||
if (!pluginManager.getPluginInfo(id)) {
|
if (pluginManager.getPluginInfo(id)) {
|
||||||
|
sendProgress('正在刷新插件信息...', 95);
|
||||||
|
await pluginManager.reloadPlugin(id);
|
||||||
|
} else {
|
||||||
sendProgress('正在注册插件...', 95);
|
sendProgress('正在注册插件...', 95);
|
||||||
await pluginManager.loadPluginById(id);
|
await pluginManager.loadPluginById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,3 +108,29 @@ export const QQRefreshQRcodeHandler: RequestHandler = async (_, res) => {
|
|||||||
await WebUiDataRuntime.refreshQRCode();
|
await WebUiDataRuntime.refreshQRCode();
|
||||||
return sendSuccess(res, null);
|
return sendSuccess(res, null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 密码登录
|
||||||
|
export const QQPasswordLoginHandler: RequestHandler = async (req, res) => {
|
||||||
|
// 获取QQ号和密码MD5
|
||||||
|
const { uin, passwordMd5 } = req.body;
|
||||||
|
// 判断是否已经登录
|
||||||
|
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||||
|
if (isLogin) {
|
||||||
|
return sendError(res, 'QQ Is Logined');
|
||||||
|
}
|
||||||
|
// 判断QQ号是否为空
|
||||||
|
if (isEmpty(uin)) {
|
||||||
|
return sendError(res, 'uin is empty');
|
||||||
|
}
|
||||||
|
// 判断密码MD5是否为空
|
||||||
|
if (isEmpty(passwordMd5)) {
|
||||||
|
return sendError(res, 'passwordMd5 is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行密码登录
|
||||||
|
const { result, message } = await WebUiDataRuntime.requestPasswordLogin(uin, passwordMd5);
|
||||||
|
if (!result) {
|
||||||
|
return sendError(res, message);
|
||||||
|
}
|
||||||
|
return sendSuccess(res, null);
|
||||||
|
};
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper,
|
|||||||
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
|
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
|
||||||
|
|
||||||
if (!fs.existsSync(configPath)) {
|
if (!fs.existsSync(configPath)) {
|
||||||
logger.log('[NapCat Update] No pending updates found');
|
//logger.log('[NapCat Update] No pending updates found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,157 @@
|
|||||||
const CACHE_NAME = 'napcat-webui-v{{VERSION}}';
|
/**
|
||||||
const ASSETS_TO_CACHE = [
|
* NapCat WebUI Service Worker
|
||||||
'/webui/'
|
*
|
||||||
];
|
* 路由缓存策略设计:
|
||||||
|
*
|
||||||
|
* 【永不缓存 - Network Only】
|
||||||
|
* - /api/* WebUI API
|
||||||
|
* - /plugin/:id/api/* 插件 API
|
||||||
|
* - /files/theme.css 动态主题 CSS
|
||||||
|
* - /webui/fonts/CustomFont.woff 用户自定义字体
|
||||||
|
* - WebSocket / SSE 连接
|
||||||
|
*
|
||||||
|
* 【强缓存 - Cache First】
|
||||||
|
* - /webui/assets/* 前端静态资源(带 hash)
|
||||||
|
* - /webui/fonts/* 内置字体(排除 CustomFont)
|
||||||
|
* - q1.qlogo.cn QQ 头像
|
||||||
|
*
|
||||||
|
* 【网络优先 - Network First】
|
||||||
|
* - /webui/* (HTML 导航) SPA 页面
|
||||||
|
* - /plugin/:id/page/* 插件页面
|
||||||
|
* - /plugin/:id/files/* 插件文件系统静态资源
|
||||||
|
*
|
||||||
|
* 【后台更新 - Stale-While-Revalidate】
|
||||||
|
* - /plugin/:id/mem/* 插件内存静态资源
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CACHE_NAME = 'napcat-webui-v{{VERSION}}';
|
||||||
|
|
||||||
|
// 缓存配置
|
||||||
|
const CACHE_CONFIG = {
|
||||||
|
// 静态资源缓存最大条目数
|
||||||
|
MAX_STATIC_ENTRIES: 200,
|
||||||
|
// QQ 头像缓存最大条目数
|
||||||
|
MAX_AVATAR_ENTRIES: 100,
|
||||||
|
// 插件资源缓存最大条目数
|
||||||
|
MAX_PLUGIN_ENTRIES: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 路由匹配辅助函数 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为永不缓存的请求
|
||||||
|
*/
|
||||||
|
function isNeverCache (url, request) {
|
||||||
|
// WebUI API
|
||||||
|
if (url.pathname.startsWith('/api/')) return true;
|
||||||
|
|
||||||
|
// 插件 API: /plugin/:id/api/*
|
||||||
|
if (/^\/plugin\/[^/]+\/api(\/|$)/.test(url.pathname)) return true;
|
||||||
|
|
||||||
|
// 动态主题 CSS
|
||||||
|
if (url.pathname === '/files/theme.css' || url.pathname.endsWith('/files/theme.css')) return true;
|
||||||
|
|
||||||
|
// 用户自定义字体
|
||||||
|
if (url.pathname.includes('/webui/fonts/CustomFont.woff')) return true;
|
||||||
|
|
||||||
|
// WebSocket 升级请求
|
||||||
|
if (request.headers.get('Upgrade') === 'websocket') return true;
|
||||||
|
|
||||||
|
// SSE 请求
|
||||||
|
if (request.headers.get('Accept') === 'text/event-stream') return true;
|
||||||
|
|
||||||
|
// Socket 相关
|
||||||
|
if (url.pathname.includes('/socket')) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为 WebUI 静态资源(强缓存)
|
||||||
|
*/
|
||||||
|
function isWebUIStaticAsset (url) {
|
||||||
|
// /webui/assets/* - 前端构建产物(带 hash)
|
||||||
|
if (url.pathname.startsWith('/webui/assets/')) return true;
|
||||||
|
|
||||||
|
// /webui/fonts/* - 内置字体(排除 CustomFont)
|
||||||
|
if (url.pathname.startsWith('/webui/fonts/') &&
|
||||||
|
!url.pathname.includes('CustomFont.woff')) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为外部头像(强缓存)
|
||||||
|
*/
|
||||||
|
function isQLogoAvatar (url) {
|
||||||
|
return url.hostname === 'q1.qlogo.cn' || url.hostname === 'q2.qlogo.cn';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为插件文件系统静态资源(网络优先)
|
||||||
|
*/
|
||||||
|
function isPluginStaticFiles (url) {
|
||||||
|
// /plugin/:id/files/*
|
||||||
|
return /^\/plugin\/[^/]+\/files(\/|$)/.test(url.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为插件内存静态资源(Stale-While-Revalidate)
|
||||||
|
*/
|
||||||
|
function isPluginMemoryAsset (url) {
|
||||||
|
// /plugin/:id/mem/*
|
||||||
|
return /^\/plugin\/[^/]+\/mem(\/|$)/.test(url.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为插件页面(Network First)
|
||||||
|
*/
|
||||||
|
function isPluginPage (url) {
|
||||||
|
// /plugin/:id/page/*
|
||||||
|
return /^\/plugin\/[^/]+\/page(\/|$)/.test(url.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 缓存管理函数 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 限制缓存条目数量
|
||||||
|
*/
|
||||||
|
async function trimCache (cacheName, maxEntries) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
if (keys.length > maxEntries) {
|
||||||
|
// 删除最早的条目
|
||||||
|
const deleteCount = keys.length - maxEntries;
|
||||||
|
for (let i = 0; i < deleteCount; i++) {
|
||||||
|
await cache.delete(keys[i]);
|
||||||
|
}
|
||||||
|
console.log(`[SW] Trimmed ${deleteCount} entries from cache`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按类型获取缓存限制
|
||||||
|
*/
|
||||||
|
function getCacheLimitForRequest (url) {
|
||||||
|
if (isQLogoAvatar(url)) return CACHE_CONFIG.MAX_AVATAR_ENTRIES;
|
||||||
|
if (isPluginStaticFiles(url) || isPluginMemoryAsset(url)) return CACHE_CONFIG.MAX_PLUGIN_ENTRIES;
|
||||||
|
return CACHE_CONFIG.MAX_STATIC_ENTRIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Service Worker 生命周期 ============
|
||||||
|
|
||||||
// 安装阶段:预缓存核心文件
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
self.skipWaiting(); // 强制立即接管
|
console.log('[SW] Installing new version:', CACHE_NAME);
|
||||||
event.waitUntil(
|
self.skipWaiting();
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
// 这里的资源如果加载失败不应该阻断 SW 安装
|
|
||||||
return cache.addAll(ASSETS_TO_CACHE).catch(err => console.warn('Failed to cache core assets', err));
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 激活阶段:清理旧缓存
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
|
console.log('[SW] Activating new version:', CACHE_NAME);
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((cacheNames) => {
|
(async () => {
|
||||||
return Promise.all(
|
// 清理所有旧版本缓存
|
||||||
|
const cacheNames = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
cacheNames.map((cacheName) => {
|
cacheNames.map((cacheName) => {
|
||||||
if (cacheName.startsWith('napcat-webui-') && cacheName !== CACHE_NAME) {
|
if (cacheName.startsWith('napcat-webui-') && cacheName !== CACHE_NAME) {
|
||||||
console.log('[SW] Deleting old cache:', cacheName);
|
console.log('[SW] Deleting old cache:', cacheName);
|
||||||
@@ -26,107 +159,178 @@ self.addEventListener('activate', (event) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
})
|
// 立即接管所有客户端
|
||||||
|
await self.clients.claim();
|
||||||
|
})()
|
||||||
);
|
);
|
||||||
self.clients.claim(); // 立即控制所有客户端
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 拦截请求
|
// ============ 请求拦截 ============
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
|
const request = event.request;
|
||||||
|
|
||||||
// 1. API 请求:仅网络 (Network Only)
|
// 1. 永不缓存的请求 - Network Only
|
||||||
if (url.pathname.startsWith('/api/') || url.pathname.includes('/socket')) {
|
if (isNeverCache(url, request)) {
|
||||||
|
// 不调用 respondWith,让请求直接穿透到网络
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 强缓存策略 (Cache First)
|
// 2. WebUI 静态资源 - Cache First
|
||||||
// - 外部 QQ 头像 (q1.qlogo.cn)
|
if (isWebUIStaticAsset(url)) {
|
||||||
// - 静态资源 (assets, fonts)
|
event.respondWith(cacheFirst(request, url));
|
||||||
// - 常见静态文件后缀
|
|
||||||
const isQLogo = url.hostname === 'q1.qlogo.cn';
|
|
||||||
const isCustomFont = url.pathname.includes('CustomFont.woff'); // 用户自定义字体,不强缓存
|
|
||||||
const isThemeCss = url.pathname.includes('files/theme.css'); // 主题 CSS,不强缓存
|
|
||||||
const isStaticAsset = url.pathname.includes('/webui/assets/') ||
|
|
||||||
url.pathname.includes('/webui/fonts/');
|
|
||||||
const isStaticFile = /\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|ico)$/i.test(url.pathname);
|
|
||||||
|
|
||||||
if (!isCustomFont && !isThemeCss && (isQLogo || isStaticAsset || isStaticFile)) {
|
|
||||||
event.respondWith(
|
|
||||||
caches.match(event.request).then((response) => {
|
|
||||||
if (response) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跨域请求 (qlogo) 需要 mode: 'no-cors' 才能缓存 opaque response,
|
|
||||||
// 但 fetch(event.request) 默认会继承 request 的 mode。
|
|
||||||
// 如果是 img标签发起的请求,通常 mode 是 no-cors 或 cors。
|
|
||||||
// 对于 opaque response (status 0), cache API 允许缓存。
|
|
||||||
return fetch(event.request).then((response) => {
|
|
||||||
// 对 qlogo 允许 status 0 (opaque)
|
|
||||||
// 对其他资源要求 status 200
|
|
||||||
const isValidResponse = response && (
|
|
||||||
response.status === 200 ||
|
|
||||||
response.type === 'basic' ||
|
|
||||||
(isQLogo && response.type === 'opaque')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isValidResponse) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseToCache = response.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
cache.put(event.request, responseToCache);
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. HTML 页面 / 导航请求 -> 网络优先 (Network First)
|
// 3. QQ 头像 - Cache First(支持 opaque response)
|
||||||
if (event.request.mode === 'navigate') {
|
if (isQLogoAvatar(url)) {
|
||||||
event.respondWith(
|
event.respondWith(cacheFirstWithOpaque(request, url));
|
||||||
fetch(event.request)
|
|
||||||
.then((response) => {
|
|
||||||
const responseToCache = response.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
cache.put(event.request, responseToCache);
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
return caches.match(event.request);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 其他 Same-Origin 请求 -> Stale-While-Revalidate
|
// 4. 插件文件系统静态资源 - Network First
|
||||||
// 优先返回缓存,同时后台更新缓存,保证下次访问是新的
|
if (isPluginStaticFiles(url)) {
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 插件内存静态资源 - Stale-While-Revalidate
|
||||||
|
if (isPluginMemoryAsset(url)) {
|
||||||
|
event.respondWith(staleWhileRevalidate(request, url));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 插件页面 - Network First
|
||||||
|
if (isPluginPage(url)) {
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. HTML 导航请求 - Network First
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 其他同源请求 - Network Only(避免意外缓存)
|
||||||
if (url.origin === self.location.origin) {
|
if (url.origin === self.location.origin) {
|
||||||
event.respondWith(
|
// 不缓存,直接穿透
|
||||||
caches.match(event.request).then((cachedResponse) => {
|
|
||||||
const fetchPromise = fetch(event.request).then((networkResponse) => {
|
|
||||||
if (networkResponse && networkResponse.status === 200) {
|
|
||||||
const responseToCache = networkResponse.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
cache.put(event.request, responseToCache);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return networkResponse;
|
|
||||||
});
|
|
||||||
// 如果有缓存,返回缓存;否则等待网络
|
|
||||||
return cachedResponse || fetchPromise;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认:网络优先
|
// 9. 其他外部请求 - Network Only
|
||||||
event.respondWith(
|
return;
|
||||||
fetch(event.request).catch(() => caches.match(event.request))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ 缓存策略实现 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache First 策略
|
||||||
|
* 优先从缓存返回,缓存未命中则从网络获取并缓存
|
||||||
|
*/
|
||||||
|
async function cacheFirst (request, url) {
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
// 异步清理缓存
|
||||||
|
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SW] Cache First fetch failed:', error);
|
||||||
|
return new Response('Network error', { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache First 策略(支持 opaque response,用于跨域头像)
|
||||||
|
*/
|
||||||
|
async function cacheFirstWithOpaque (request, url) {
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
// opaque response 的 status 是 0,但仍可缓存
|
||||||
|
const isValidResponse = networkResponse && (
|
||||||
|
networkResponse.status === 200 ||
|
||||||
|
networkResponse.type === 'opaque'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isValidResponse) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SW] Cache First (opaque) fetch failed:', error);
|
||||||
|
return new Response('Network error', { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network First 策略
|
||||||
|
* 优先从网络获取,网络失败则返回缓存
|
||||||
|
*/
|
||||||
|
async function networkFirst (request) {
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[SW] Network First: network failed, trying cache');
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
return new Response('Offline', { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stale-While-Revalidate 策略
|
||||||
|
* 立即返回缓存(如果有),同时后台更新缓存
|
||||||
|
*/
|
||||||
|
async function staleWhileRevalidate (request, url) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
const cachedResponse = await cache.match(request);
|
||||||
|
|
||||||
|
// 后台刷新缓存
|
||||||
|
const fetchPromise = fetch(request).then((networkResponse) => {
|
||||||
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
}).catch((error) => {
|
||||||
|
console.log('[SW] SWR background fetch failed:', error);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果有缓存,立即返回缓存
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有缓存,等待网络
|
||||||
|
const networkResponse = await fetchPromise;
|
||||||
|
if (networkResponse) {
|
||||||
|
return networkResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Network error', { status: 503 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ const LoginRuntime: LoginRuntimeType = {
|
|||||||
onQuickLoginRequested: async () => {
|
onQuickLoginRequested: async () => {
|
||||||
return { result: false, message: '' };
|
return { result: false, message: '' };
|
||||||
},
|
},
|
||||||
|
onPasswordLoginRequested: async () => {
|
||||||
|
return { result: false, message: '密码登录功能未初始化' };
|
||||||
|
},
|
||||||
onRestartProcessRequested: async () => {
|
onRestartProcessRequested: async () => {
|
||||||
return { result: false, message: '重启功能未初始化' };
|
return { result: false, message: '重启功能未初始化' };
|
||||||
},
|
},
|
||||||
@@ -136,6 +139,14 @@ export const WebUiDataRuntime = {
|
|||||||
return LoginRuntime.NapCatHelper.onQuickLoginRequested(uin);
|
return LoginRuntime.NapCatHelper.onQuickLoginRequested(uin);
|
||||||
} as LoginRuntimeType['NapCatHelper']['onQuickLoginRequested'],
|
} as LoginRuntimeType['NapCatHelper']['onQuickLoginRequested'],
|
||||||
|
|
||||||
|
setPasswordLoginCall (func: LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested']): void {
|
||||||
|
LoginRuntime.NapCatHelper.onPasswordLoginRequested = func;
|
||||||
|
},
|
||||||
|
|
||||||
|
requestPasswordLogin: function (uin: string, passwordMd5: string) {
|
||||||
|
return LoginRuntime.NapCatHelper.onPasswordLoginRequested(uin, passwordMd5);
|
||||||
|
} as LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested'],
|
||||||
|
|
||||||
setOnOB11ConfigChanged (func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
|
setOnOB11ConfigChanged (func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
|
||||||
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
|
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
UploadHandler,
|
UploadHandler,
|
||||||
UploadWebUIFontHandler,
|
UploadWebUIFontHandler,
|
||||||
DeleteWebUIFontHandler, // 添加上传处理器
|
DeleteWebUIFontHandler, // 添加上传处理器
|
||||||
|
CheckWebUIFontExistHandler, // Add this
|
||||||
} from '../api/File';
|
} from '../api/File';
|
||||||
|
|
||||||
const router: Router = Router();
|
const router: Router = Router();
|
||||||
@@ -46,4 +47,5 @@ router.post('/upload', UploadHandler);
|
|||||||
|
|
||||||
router.post('/font/upload/webui', UploadWebUIFontHandler);
|
router.post('/font/upload/webui', UploadWebUIFontHandler);
|
||||||
router.post('/font/delete/webui', DeleteWebUIFontHandler);
|
router.post('/font/delete/webui', DeleteWebUIFontHandler);
|
||||||
|
router.get('/font/exists/webui', CheckWebUIFontExistHandler); // Add this
|
||||||
export { router as FileRouter };
|
export { router as FileRouter };
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
|
||||||
import { OB11GetConfigHandler, OB11SetConfigHandler } from '@/napcat-webui-backend/src/api/OB11Config';
|
import { OB11GetConfigHandler, OB11SetConfigHandler } from '@/napcat-webui-backend/src/api/OB11Config';
|
||||||
|
import { BackupExportConfigHandler, BackupImportConfigHandler } from '@/napcat-webui-backend/src/api/BackupConfig';
|
||||||
|
|
||||||
const router: Router = Router();
|
const router: Router = Router();
|
||||||
|
|
||||||
|
// 使用内存存储,配合流式处理
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage()
|
||||||
|
});
|
||||||
|
|
||||||
// router:读取配置
|
// router:读取配置
|
||||||
router.post('/GetConfig', OB11GetConfigHandler);
|
router.post('/GetConfig', OB11GetConfigHandler);
|
||||||
// router:写入配置
|
// router:写入配置
|
||||||
router.post('/SetConfig', OB11SetConfigHandler);
|
router.post('/SetConfig', OB11SetConfigHandler);
|
||||||
|
// router:导出配置
|
||||||
|
router.get('/ExportConfig', BackupExportConfigHandler);
|
||||||
|
// router:导入配置
|
||||||
|
router.post('/ImportConfig', upload.single('configFile'), BackupImportConfigHandler);
|
||||||
|
|
||||||
export { router as OB11ConfigRouter };
|
export { router as OB11ConfigRouter };
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getAutoLoginAccountHandler,
|
getAutoLoginAccountHandler,
|
||||||
setAutoLoginAccountHandler,
|
setAutoLoginAccountHandler,
|
||||||
QQRefreshQRcodeHandler,
|
QQRefreshQRcodeHandler,
|
||||||
|
QQPasswordLoginHandler,
|
||||||
} from '@/napcat-webui-backend/src/api/QQLogin';
|
} from '@/napcat-webui-backend/src/api/QQLogin';
|
||||||
|
|
||||||
const router: Router = Router();
|
const router: Router = Router();
|
||||||
@@ -31,5 +32,7 @@ router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler);
|
|||||||
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
|
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
|
||||||
// router:刷新QQ登录二维码
|
// router:刷新QQ登录二维码
|
||||||
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
||||||
|
// router:密码登录
|
||||||
|
router.post('/PasswordLogin', QQPasswordLoginHandler);
|
||||||
|
|
||||||
export { router as QQLoginRouter };
|
export { router as QQLoginRouter };
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export interface LoginRuntimeType {
|
|||||||
OneBotContext: any | null; // OneBot 上下文,用于调试功能
|
OneBotContext: any | null; // OneBot 上下文,用于调试功能
|
||||||
NapCatHelper: {
|
NapCatHelper: {
|
||||||
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
|
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
|
||||||
|
onPasswordLoginRequested: (uin: string, passwordMd5: string) => Promise<{ result: boolean; message: string; }>;
|
||||||
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
||||||
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
|
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
|
||||||
QQLoginList: string[];
|
QQLoginList: string[];
|
||||||
|
|||||||
@@ -9,15 +9,30 @@ const SUPPORTED_FONT_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf'];
|
|||||||
|
|
||||||
// 清理旧的字体文件
|
// 清理旧的字体文件
|
||||||
const cleanOldFontFiles = (fontsPath: string) => {
|
const cleanOldFontFiles = (fontsPath: string) => {
|
||||||
for (const ext of SUPPORTED_FONT_EXTENSIONS) {
|
try {
|
||||||
const fontPath = path.join(fontsPath, `webui${ext}`);
|
// 确保字体目录存在
|
||||||
try {
|
if (!fs.existsSync(fontsPath)) {
|
||||||
if (fs.existsSync(fontPath)) {
|
return;
|
||||||
fs.unlinkSync(fontPath);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 忽略删除失败
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 遍历目录下所有文件
|
||||||
|
const files = fs.readdirSync(fontsPath);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// 检查文件名是否以 webui 或 CustomFont 开头,且是支持的字体扩展名
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
const name = path.basename(file, ext);
|
||||||
|
|
||||||
|
if (SUPPORTED_FONT_EXTENSIONS.includes(ext) && (name === 'webui' || name === 'CustomFont')) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(path.join(fontsPath, file));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to delete old font file ${file}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to clean old font files:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,9 +51,9 @@ export const webUIFontStorage = multer.diskStorage({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
filename: (_, file, cb) => {
|
filename: (_, file, cb) => {
|
||||||
// 保留原始扩展名,统一文件名为 webui
|
// 强制文件名为 CustomFont,保留原始扩展名
|
||||||
const ext = path.extname(file.originalname).toLowerCase();
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
cb(null, `webui${ext}`);
|
cb(null, `CustomFont${ext}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,6 @@
|
|||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"quill": "^2.0.3",
|
"quill": "^2.0.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-color": "^2.19.3",
|
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-dropzone": "^14.3.5",
|
"react-dropzone": "^14.3.5",
|
||||||
"react-error-boundary": "^5.0.0",
|
"react-error-boundary": "^5.0.0",
|
||||||
|
|||||||
@@ -1,36 +1,409 @@
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
import { Input } from "@heroui/input";
|
||||||
import React from 'react';
|
import { Popover, PopoverContent, PopoverTrigger } from "@heroui/popover";
|
||||||
import { ColorResult, SketchPicker } from 'react-color';
|
import React, { useCallback, useEffect, useRef, useState, memo } from "react";
|
||||||
|
|
||||||
// 假定 heroui 提供的 Popover组件
|
|
||||||
|
|
||||||
interface ColorPickerProps {
|
interface ColorPickerProps {
|
||||||
color: string
|
color: string;
|
||||||
onChange: (color: ColorResult) => void
|
onChange: (color: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 转换 HSL 字符串到对象
|
||||||
|
const parseHsl = (hslStr: string) => {
|
||||||
|
const match = hslStr.match(/hsl\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)%,\s*(\d+(?:\.\d+)?)%\)/);
|
||||||
|
if (match) {
|
||||||
|
return { h: parseFloat(match[1]), s: parseFloat(match[2]), l: parseFloat(match[3]) };
|
||||||
|
}
|
||||||
|
return { h: 0, s: 0, l: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 转换 HEX 到 HSL
|
||||||
|
const hexToHsl = (hex: string) => {
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
if (hex.length === 4) {
|
||||||
|
r = parseInt("0x" + hex[1] + hex[1]);
|
||||||
|
g = parseInt("0x" + hex[2] + hex[2]);
|
||||||
|
b = parseInt("0x" + hex[3] + hex[3]);
|
||||||
|
} else if (hex.length === 7) {
|
||||||
|
r = parseInt("0x" + hex[1] + hex[2]);
|
||||||
|
g = parseInt("0x" + hex[3] + hex[4]);
|
||||||
|
b = parseInt("0x" + hex[5] + hex[6]);
|
||||||
|
}
|
||||||
|
r /= 255;
|
||||||
|
g /= 255;
|
||||||
|
b /= 255;
|
||||||
|
const cmin = Math.min(r, g, b),
|
||||||
|
cmax = Math.max(r, g, b),
|
||||||
|
delta = cmax - cmin;
|
||||||
|
let h = 0,
|
||||||
|
s = 0,
|
||||||
|
l = 0;
|
||||||
|
|
||||||
|
if (delta === 0) h = 0;
|
||||||
|
else if (cmax === r) h = ((g - b) / delta) % 6;
|
||||||
|
else if (cmax === g) h = (b - r) / delta + 2;
|
||||||
|
else h = (r - g) / delta + 4;
|
||||||
|
|
||||||
|
h = Math.round(h * 60);
|
||||||
|
if (h < 0) h += 360;
|
||||||
|
|
||||||
|
l = (cmax + cmin) / 2;
|
||||||
|
s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
|
||||||
|
s = +(s * 100).toFixed(1);
|
||||||
|
l = +(l * 100).toFixed(1);
|
||||||
|
|
||||||
|
return { h, s, l };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 转换 HSL 到 HEX
|
||||||
|
const hslToHex = (h: number, s: number, l: number) => {
|
||||||
|
s /= 100;
|
||||||
|
l /= 100;
|
||||||
|
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
|
const m = l - c / 2;
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
|
||||||
|
if (0 <= h && h < 60) {
|
||||||
|
r = c; g = x; b = 0;
|
||||||
|
} else if (60 <= h && h < 120) {
|
||||||
|
r = x; g = c; b = 0;
|
||||||
|
} else if (120 <= h && h < 180) {
|
||||||
|
r = 0; g = c; b = x;
|
||||||
|
} else if (180 <= h && h < 240) {
|
||||||
|
r = 0; g = x; b = c;
|
||||||
|
} else if (240 <= h && h < 300) {
|
||||||
|
r = x; g = 0; b = c;
|
||||||
|
} else if (300 <= h && h < 360) {
|
||||||
|
r = c; g = 0; b = x;
|
||||||
|
}
|
||||||
|
r = Math.round((r + m) * 255);
|
||||||
|
g = Math.round((g + m) * 255);
|
||||||
|
b = Math.round((b + m) * 255);
|
||||||
|
|
||||||
|
const toHex = (n: number) => {
|
||||||
|
const hex = n.toString(16);
|
||||||
|
return hex.length === 1 ? "0" + hex : hex;
|
||||||
|
};
|
||||||
|
return "#" + toHex(r) + toHex(g) + toHex(b);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PanelProps {
|
||||||
|
hsl: { h: number, s: number, l: number; };
|
||||||
|
onChange: (newHsl: { h: number, s: number, l: number; }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 饱和度/亮度面板
|
||||||
|
const SatLightPanel = memo(({ hsl, onChange }: PanelProps) => {
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const hslRef = useRef(hsl);
|
||||||
|
useEffect(() => { hslRef.current = hsl; }, [hsl]);
|
||||||
|
|
||||||
|
const updateColor = useCallback((clientX: number, clientY: number) => {
|
||||||
|
if (!panelRef.current) return;
|
||||||
|
const rect = panelRef.current.getBoundingClientRect();
|
||||||
|
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||||
|
const y = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
||||||
|
|
||||||
|
const s_hsv = x;
|
||||||
|
const v_hsv = 1 - y;
|
||||||
|
|
||||||
|
let l_hsl = v_hsv * (1 - s_hsv / 2);
|
||||||
|
let s_hsl = 0;
|
||||||
|
if (l_hsl === 0 || l_hsl === 1) {
|
||||||
|
s_hsl = 0;
|
||||||
|
} else {
|
||||||
|
s_hsl = (v_hsv - l_hsl) / Math.min(l_hsl, 1 - l_hsl);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({ h: hslRef.current.h, s: s_hsl * 100, l: l_hsl * 100 });
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
const handleStart = (clientX: number, clientY: number) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
updateColor(clientX, clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleStart(e.clientX, e.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
handleStart(touch.clientX, touch.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (isDragging) {
|
||||||
|
e.preventDefault();
|
||||||
|
updateColor(e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
|
if (isDragging) {
|
||||||
|
e.preventDefault();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
updateColor(touch.clientX, touch.clientY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
window.addEventListener("mouseup", handleEnd);
|
||||||
|
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||||
|
window.addEventListener("touchend", handleEnd);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
window.removeEventListener("mouseup", handleEnd);
|
||||||
|
window.removeEventListener("touchmove", handleTouchMove);
|
||||||
|
window.removeEventListener("touchend", handleEnd);
|
||||||
|
};
|
||||||
|
}, [isDragging, updateColor]);
|
||||||
|
|
||||||
|
const l_val = hsl.l / 100;
|
||||||
|
const s_val = hsl.s / 100;
|
||||||
|
const v_hsv = l_val + s_val * Math.min(l_val, 1 - l_val);
|
||||||
|
const s_hsv = v_hsv === 0 ? 0 : 2 * (1 - l_val / v_hsv);
|
||||||
|
|
||||||
|
const markerX = s_hsv * 100;
|
||||||
|
const markerY = (1 - v_hsv) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="w-full h-40 rounded-lg relative cursor-crosshair overflow-hidden shadow-inner touch-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "hsl(" + hsl.h + ", 100%, 50%)",
|
||||||
|
backgroundImage: "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)"
|
||||||
|
}}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full border-2 border-white shadow-md absolute transform -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: markerX + "%",
|
||||||
|
top: markerY + "%",
|
||||||
|
backgroundColor: "hsl(" + hsl.h + ", " + hsl.s + "%, " + hsl.l + "%)"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
SatLightPanel.displayName = "SatLightPanel";
|
||||||
|
|
||||||
|
const HueSlider = memo(({ hsl, onChange }: PanelProps) => {
|
||||||
|
const sliderRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const hslRef = useRef(hsl);
|
||||||
|
useEffect(() => { hslRef.current = hsl; }, [hsl]);
|
||||||
|
|
||||||
|
const updateHue = useCallback((clientX: number) => {
|
||||||
|
if (!sliderRef.current) return;
|
||||||
|
const rect = sliderRef.current.getBoundingClientRect();
|
||||||
|
let x = (clientX - rect.left) / rect.width;
|
||||||
|
x = Math.max(0, Math.min(1, x));
|
||||||
|
onChange({ ...hslRef.current, h: x * 360 });
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
const handleStart = (clientX: number) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
updateHue(clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleStart(e.clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
handleStart(touch.clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (isDragging) {
|
||||||
|
e.preventDefault();
|
||||||
|
updateHue(e.clientX);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
|
if (isDragging) {
|
||||||
|
e.preventDefault();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
updateHue(touch.clientX);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
window.addEventListener("mouseup", handleEnd);
|
||||||
|
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||||
|
window.addEventListener("touchend", handleEnd);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
window.removeEventListener("mouseup", handleEnd);
|
||||||
|
window.removeEventListener("touchmove", handleTouchMove);
|
||||||
|
window.removeEventListener("touchend", handleEnd);
|
||||||
|
};
|
||||||
|
}, [isDragging, updateHue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sliderRef}
|
||||||
|
className="w-full h-4 rounded-full relative cursor-pointer mt-3 shadow-inner touch-none"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)"
|
||||||
|
}}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full border-2 border-white shadow-md absolute top-0 transform -translate-x-1/2 pointer-events-none bg-white"
|
||||||
|
style={{ left: (hsl.h / 360) * 100 + "%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
HueSlider.displayName = "HueSlider";
|
||||||
|
|
||||||
const ColorPicker: React.FC<ColorPickerProps> = ({ color, onChange }) => {
|
const ColorPicker: React.FC<ColorPickerProps> = ({ color, onChange }) => {
|
||||||
const handleChange = (colorResult: ColorResult) => {
|
const [hsl, setHsl] = useState(parseHsl(color));
|
||||||
onChange(colorResult);
|
const [hex, setHex] = useState(hslToHex(hsl.h, hsl.s, hsl.l));
|
||||||
|
const isDraggingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDraggingRef.current) return;
|
||||||
|
const newHsl = parseHsl(color);
|
||||||
|
if (Math.abs(newHsl.h - hsl.h) > 0.1 || Math.abs(newHsl.s - hsl.s) > 0.1 || Math.abs(newHsl.l - hsl.l) > 0.1) {
|
||||||
|
setHsl(newHsl);
|
||||||
|
setHex(hslToHex(newHsl.h, newHsl.s, newHsl.l));
|
||||||
|
}
|
||||||
|
}, [color]);
|
||||||
|
|
||||||
|
const handleHslChange = useCallback((newHsl: { h: number, s: number, l: number; }) => {
|
||||||
|
setHsl(newHsl);
|
||||||
|
setHex(hslToHex(newHsl.h, newHsl.s, newHsl.l));
|
||||||
|
onChange("hsl(" + Math.round(newHsl.h) + ", " + Math.round(newHsl.s) + "%, " + Math.round(newHsl.l) + "%)");
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
const handleHexChange = (value: string) => {
|
||||||
|
setHex(value);
|
||||||
|
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||||
|
const newHsl = hexToHsl(value);
|
||||||
|
handleHslChange(newHsl);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover triggerScaleOnOpen={false}>
|
<Popover triggerScaleOnOpen={false} placement="bottom">
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<div
|
<div className="flex items-center gap-2 cursor-pointer group">
|
||||||
className='w-36 h-8 rounded-md cursor-pointer border border-content4'
|
<div
|
||||||
style={{ background: color }}
|
className="w-10 h-10 rounded-lg shadow-sm border-2 border-default-200 transition-transform group-hover:scale-105"
|
||||||
/>
|
style={{ background: color }}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-mono text-default-500">{hex}</span>
|
||||||
|
<span className="text-xs font-mono text-default-400">HSL({Math.round(hsl.h)}, {Math.round(hsl.s)}%, {Math.round(hsl.l)}%)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent>
|
<PopoverContent className="w-72 p-4 bg-background/80 backdrop-blur-xl border border-default-200 shadow-2xl rounded-2xl"
|
||||||
<SketchPicker
|
onMouseDownCapture={() => { isDraggingRef.current = true; }}
|
||||||
color={color}
|
onMouseUpCapture={() => { isDraggingRef.current = false; }}
|
||||||
onChange={handleChange}
|
onTouchStartCapture={() => { isDraggingRef.current = true; }}
|
||||||
className='!bg-transparent !shadow-none'
|
onTouchEndCapture={() => { isDraggingRef.current = false; }}
|
||||||
/>
|
>
|
||||||
|
<div className="flex flex-col w-full gap-2">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm font-bold text-default-700">选择颜色</span>
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full border border-default-200 shadow-sm"
|
||||||
|
style={{ background: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SatLightPanel hsl={hsl} onChange={handleHslChange} />
|
||||||
|
<HueSlider hsl={hsl} onChange={handleHslChange} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-2 mt-2 items-center">
|
||||||
|
<span className="text-xs text-default-500 col-span-1">HEX</span>
|
||||||
|
<Input
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
value={hex}
|
||||||
|
onChange={(e) => handleHexChange(e.target.value)}
|
||||||
|
className="col-span-3 font-mono"
|
||||||
|
classNames={{
|
||||||
|
input: "text-xs uppercase",
|
||||||
|
inputWrapper: "h-8 min-h-8"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-2 items-center">
|
||||||
|
<span className="text-xs text-default-500 col-span-1">HSL</span>
|
||||||
|
<div className="col-span-3 flex gap-1">
|
||||||
|
<Input
|
||||||
|
size="sm" variant="flat" type="number"
|
||||||
|
value={Math.round(hsl.h).toString()}
|
||||||
|
onChange={(e) => handleHslChange({ ...hsl, h: Number(e.target.value) })}
|
||||||
|
endContent={<span className="text-xs text-default-400">H</span>}
|
||||||
|
classNames={{ input: "text-xs", inputWrapper: "h-8 min-h-8 px-1" }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
size="sm" variant="flat" type="number"
|
||||||
|
value={Math.round(hsl.s).toString()}
|
||||||
|
onChange={(e) => handleHslChange({ ...hsl, s: Number(e.target.value) })}
|
||||||
|
endContent={<span className="text-xs text-default-400">S</span>}
|
||||||
|
classNames={{ input: "text-xs", inputWrapper: "h-8 min-h-8 px-1" }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
size="sm" variant="flat" type="number"
|
||||||
|
value={Math.round(hsl.l).toString()}
|
||||||
|
onChange={(e) => handleHslChange({ ...hsl, l: Number(e.target.value) })}
|
||||||
|
endContent={<span className="text-xs text-default-400">L</span>}
|
||||||
|
classNames={{ input: "text-xs", inputWrapper: "h-8 min-h-8 px-1" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 mt-2 flex-wrap justify-between">
|
||||||
|
{["#006FEE", "#17C964", "#F5A524", "#F31260", "#7828C8", "#000000", "#FFFFFF"].map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
className="w-6 h-6 rounded-full border border-default-200 shadow-sm transition-transform hover:scale-110 active:scale-95"
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
onClick={() => handleHexChange(c)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ColorPicker;
|
export default ColorPicker;
|
||||||
@@ -4,7 +4,7 @@ import clsx from 'clsx';
|
|||||||
import key from '@/const/key';
|
import key from '@/const/key';
|
||||||
|
|
||||||
export interface ContainerProps {
|
export interface ContainerProps {
|
||||||
title: string;
|
title: React.ReactNode;
|
||||||
tag?: React.ReactNode;
|
tag?: React.ReactNode;
|
||||||
action: React.ReactNode;
|
action: React.ReactNode;
|
||||||
enableSwitch: React.ReactNode;
|
enableSwitch: React.ReactNode;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button } from '@heroui/button';
|
import { Button } from '@heroui/button';
|
||||||
import { Chip } from '@heroui/chip';
|
import { Chip } from '@heroui/chip';
|
||||||
|
import { Tooltip } from '@heroui/tooltip';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { IoMdDownload, IoMdRefresh, IoMdCheckmarkCircle } from 'react-icons/io';
|
import { IoMdDownload, IoMdRefresh, IoMdCheckmarkCircle } from 'react-icons/io';
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
|||||||
onInstall,
|
onInstall,
|
||||||
installStatus = 'not-installed',
|
installStatus = 'not-installed',
|
||||||
}) => {
|
}) => {
|
||||||
const { name, version, author, description, tags, id } = data;
|
const { name, version, author, description, tags, id, homepage } = data;
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
|
|
||||||
const handleInstall = () => {
|
const handleInstall = () => {
|
||||||
@@ -41,7 +42,7 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
|||||||
return {
|
return {
|
||||||
text: '更新',
|
text: '更新',
|
||||||
icon: <IoMdDownload size={16} />,
|
icon: <IoMdDownload size={16} />,
|
||||||
color: 'success' as const,
|
color: 'default' as const,
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
@@ -53,11 +54,31 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buttonConfig = getButtonConfig();
|
const buttonConfig = getButtonConfig();
|
||||||
|
const titleContent = homepage ? (
|
||||||
|
<Tooltip
|
||||||
|
content="跳转到插件主页"
|
||||||
|
placement="top"
|
||||||
|
showArrow
|
||||||
|
offset={8}
|
||||||
|
delay={200}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={homepage}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-inherit inline-block bg-no-repeat bg-left-bottom [background-image:repeating-linear-gradient(90deg,currentColor_0_2px,transparent_2px_5px)] [background-size:0%_2px] hover:[background-size:100%_2px] transition-[background-size] duration-200 ease-out"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
name
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisplayCardContainer
|
<DisplayCardContainer
|
||||||
className='w-full max-w-[420px]'
|
className='w-full max-w-[420px]'
|
||||||
title={name}
|
title={titleContent}
|
||||||
tag={
|
tag={
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
{installStatus === 'installed' && (
|
{installStatus === 'installed' && (
|
||||||
|
|||||||
122
packages/napcat-webui-frontend/src/components/password_login.tsx
Normal file
122
packages/napcat-webui-frontend/src/components/password_login.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { Avatar } from '@heroui/avatar';
|
||||||
|
import { Button } from '@heroui/button';
|
||||||
|
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@heroui/dropdown';
|
||||||
|
import { Image } from '@heroui/image';
|
||||||
|
import { Input } from '@heroui/input';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import { IoChevronDown } from 'react-icons/io5';
|
||||||
|
|
||||||
|
import type { QQItem } from '@/components/quick_login';
|
||||||
|
import { isQQQuickNewItem } from '@/utils/qq';
|
||||||
|
|
||||||
|
interface PasswordLoginProps {
|
||||||
|
onSubmit: (uin: string, password: string) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
qqList: (QQItem | LoginListItem)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqList }) => {
|
||||||
|
const [uin, setUin] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!uin) {
|
||||||
|
toast.error('请输入QQ号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
toast.error('请输入密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit(uin, password);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-8'>
|
||||||
|
<div className='flex justify-center'>
|
||||||
|
<Image
|
||||||
|
className='shadow-lg'
|
||||||
|
height={100}
|
||||||
|
radius='full'
|
||||||
|
src={`https://q1.qlogo.cn/g?b=qq&nk=${uin || '0'}&s=100`}
|
||||||
|
width={100}
|
||||||
|
alt="QQ Avatar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="QQ账号"
|
||||||
|
placeholder="请输入QQ号"
|
||||||
|
value={uin}
|
||||||
|
onValueChange={setUin}
|
||||||
|
variant="bordered"
|
||||||
|
size='lg'
|
||||||
|
autoComplete="off"
|
||||||
|
endContent={
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownTrigger>
|
||||||
|
<Button isIconOnly variant="light" size="sm" radius="full">
|
||||||
|
<IoChevronDown size={16} />
|
||||||
|
</Button>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownMenu
|
||||||
|
aria-label="QQ Login History"
|
||||||
|
items={qqList}
|
||||||
|
onAction={(key) => setUin(key.toString())}
|
||||||
|
>
|
||||||
|
{(item) => (
|
||||||
|
<DropdownItem key={item.uin} textValue={item.uin}>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Avatar
|
||||||
|
alt={item.uin}
|
||||||
|
className='flex-shrink-0'
|
||||||
|
size='sm'
|
||||||
|
src={
|
||||||
|
isQQQuickNewItem(item)
|
||||||
|
? item.faceUrl
|
||||||
|
: `https://q1.qlogo.cn/g?b=qq&nk=${item.uin}&s=1`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
{isQQQuickNewItem(item)
|
||||||
|
? `${item.nickName}(${item.uin})`
|
||||||
|
: item.uin}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
label="密码"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
value={password}
|
||||||
|
onValueChange={setPassword}
|
||||||
|
variant="bordered"
|
||||||
|
size='lg'
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex justify-center mt-5'>
|
||||||
|
<Button
|
||||||
|
className='w-64 max-w-full'
|
||||||
|
color='primary'
|
||||||
|
isLoading={isLoading}
|
||||||
|
radius='full'
|
||||||
|
size='lg'
|
||||||
|
variant='shadow'
|
||||||
|
onPress={handleSubmit}
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PasswordLogin;
|
||||||
@@ -260,14 +260,14 @@ const NewVersionTip = (props: NewVersionTipProps) => {
|
|||||||
<div className="cursor-pointer flex items-center justify-center" onClick={updateStatus === 'updating' ? undefined : showUpdateDialog}>
|
<div className="cursor-pointer flex items-center justify-center" onClick={updateStatus === 'updating' ? undefined : showUpdateDialog}>
|
||||||
<Chip
|
<Chip
|
||||||
size="sm"
|
size="sm"
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
classNames={{
|
classNames={{
|
||||||
content: "font-bold text-[10px] px-1 flex items-center justify-center",
|
content: "font-bold text-[10px] px-1 flex items-center justify-center",
|
||||||
base: "h-5 min-h-5 min-w-[42px]"
|
base: "h-5 min-h-5 min-w-[42px]"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{updateStatus === 'updating' ? <Spinner size="sm" color="danger" classNames={{ wrapper: "w-3 h-3" }} /> : 'New'}
|
{updateStatus === 'updating' ? <Spinner size="sm" color="primary" classNames={{ wrapper: "w-3 h-3" }} /> : 'New'}
|
||||||
</Chip>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -218,4 +218,11 @@ export default class FileManager {
|
|||||||
);
|
);
|
||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async checkWebUIFontExists () {
|
||||||
|
const { data } = await serverRequest.get<ServerResponse<boolean>>(
|
||||||
|
'/File/font/exists/webui'
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,9 +140,11 @@ export default class PluginManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取插件商店列表
|
* 获取插件商店列表
|
||||||
|
* @param forceRefresh 是否强制刷新(跳过服务端缓存)
|
||||||
*/
|
*/
|
||||||
public static async getPluginStoreList (): Promise<PluginStoreList> {
|
public static async getPluginStoreList (forceRefresh: boolean = false): Promise<PluginStoreList> {
|
||||||
const { data } = await serverRequest.get<ServerResponse<PluginStoreList>>('/Plugin/Store/List');
|
const params = forceRefresh ? { forceRefresh: 'true' } : {};
|
||||||
|
const { data } = await serverRequest.get<ServerResponse<PluginStoreList>>('/Plugin/Store/List', { params });
|
||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,4 +93,11 @@ export default class QQManager {
|
|||||||
uin,
|
uin,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async passwordLogin (uin: string, passwordMd5: string) {
|
||||||
|
await serverRequest.post<ServerResponse<null>>('/QQLogin/PasswordLogin', {
|
||||||
|
uin,
|
||||||
|
passwordMd5,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Button } from '@heroui/button';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { LuDownload, LuUpload } from 'react-icons/lu';
|
||||||
|
import { requestServerWithFetch } from '@/utils/request';
|
||||||
|
|
||||||
|
// 导入配置
|
||||||
|
const handleImportConfig = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// 检查文件类型
|
||||||
|
if (!file.name.endsWith('.zip')) {
|
||||||
|
toast.error('请选择zip格式的配置文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('configFile', file);
|
||||||
|
|
||||||
|
const response = await requestServerWithFetch('/OB11Config/ImportConfig', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || '导入配置失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
// 检查是否成功导入
|
||||||
|
if (result.code === 0) {
|
||||||
|
toast.success(result.data?.message || '配置导入成功。');
|
||||||
|
} else {
|
||||||
|
toast.error(`配置导入失败: ${result.data?.message || '未知错误'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message;
|
||||||
|
toast.error(`导入配置失败: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
// 重置文件输入
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出配置
|
||||||
|
const handleExportConfig = async () => {
|
||||||
|
try {
|
||||||
|
const response = await requestServerWithFetch('/OB11Config/ExportConfig', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('导出配置失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建下载链接
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
const fileName = response.headers.get('Content-Disposition')?.split('=')[1]?.replace(/"/g, '') || 'config_backup.zip';
|
||||||
|
a.download = fileName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
|
||||||
|
toast.success('配置导出成功');
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message;
|
||||||
|
|
||||||
|
toast.error(`导出配置失败: ${msg}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const BackupConfigCard: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<div>
|
||||||
|
<h3 className='text-lg font-medium mb-4'>备份与恢复</h3>
|
||||||
|
<p className='text-sm text-default-500 mb-4'>
|
||||||
|
您可以通过导入/导出配置文件来备份和恢复NapCat的所有设置
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='flex flex-wrap gap-3'>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className="bg-primary hover:bg-primary/90 text-white"
|
||||||
|
radius='full'
|
||||||
|
onPress={handleExportConfig}
|
||||||
|
title="导出配置"
|
||||||
|
>
|
||||||
|
<LuDownload size={20} />
|
||||||
|
</Button>
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
onChange={handleImportConfig}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className="bg-primary hover:bg-primary/90 text-white"
|
||||||
|
radius='full'
|
||||||
|
as="span"
|
||||||
|
title="导入配置"
|
||||||
|
>
|
||||||
|
<LuUpload size={20} />
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-4 p-3 bg-warning/10 border border-warning/20 rounded-lg'>
|
||||||
|
<div className='flex items-start gap-2'>
|
||||||
|
<p className='text-sm text-warning'>
|
||||||
|
导入配置会覆盖当前所有设置,请谨慎操作。导入前建议先导出当前配置作为备份。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BackupConfigCard;
|
||||||
@@ -13,6 +13,7 @@ import ServerConfigCard from './server';
|
|||||||
import SSLConfigCard from './ssl';
|
import SSLConfigCard from './ssl';
|
||||||
import ThemeConfigCard from './theme';
|
import ThemeConfigCard from './theme';
|
||||||
import WebUIConfigCard from './webui';
|
import WebUIConfigCard from './webui';
|
||||||
|
import BackupConfigCard from './backup';
|
||||||
|
|
||||||
export interface ConfigPageProps {
|
export interface ConfigPageProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -108,6 +109,11 @@ export default function ConfigPage () {
|
|||||||
<ThemeConfigCard />
|
<ThemeConfigCard />
|
||||||
</ConfigPageItem>
|
</ConfigPageItem>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab title='备份与恢复' key='backup'>
|
||||||
|
<ConfigPageItem>
|
||||||
|
<BackupConfigCard />
|
||||||
|
</ConfigPageItem>
|
||||||
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Accordion, AccordionItem } from '@heroui/accordion';
|
|
||||||
import { Button } from '@heroui/button';
|
import { Button } from '@heroui/button';
|
||||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||||
import { Select, SelectItem } from '@heroui/select';
|
import { Select, SelectItem } from '@heroui/select';
|
||||||
import { Chip } from '@heroui/chip';
|
import { Chip } from '@heroui/chip';
|
||||||
|
import { Tab, Tabs } from '@heroui/tabs';
|
||||||
import { useRequest } from 'ahooks';
|
import { useRequest } from 'ahooks';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||||
@@ -162,6 +162,7 @@ const ThemeConfigCard = () => {
|
|||||||
|
|
||||||
const [dataLoaded, setDataLoaded] = useState(false);
|
const [dataLoaded, setDataLoaded] = useState(false);
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
const [customFontExists, setCustomFontExists] = useState(false);
|
||||||
|
|
||||||
// 使用 useRef 存储 style 标签引用和状态
|
// 使用 useRef 存储 style 标签引用和状态
|
||||||
const styleTagRef = useRef<HTMLStyleElement | null>(null);
|
const styleTagRef = useRef<HTMLStyleElement | null>(null);
|
||||||
@@ -213,6 +214,10 @@ const ThemeConfigCard = () => {
|
|||||||
}
|
}
|
||||||
setDataLoaded(true);
|
setDataLoaded(true);
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
|
// 检查自定义字体是否存在
|
||||||
|
FileManager.checkWebUIFontExists().then(exists => {
|
||||||
|
setCustomFontExists(exists);
|
||||||
|
}).catch(err => console.error('Failed to check custom font:', err));
|
||||||
}, [data, setOnebotValue]);
|
}, [data, setOnebotValue]);
|
||||||
|
|
||||||
// 实时应用字体预设(预览)
|
// 实时应用字体预设(预览)
|
||||||
@@ -293,138 +298,180 @@ const ThemeConfigCard = () => {
|
|||||||
<title>主题配置 - NapCat WebUI</title>
|
<title>主题配置 - NapCat WebUI</title>
|
||||||
|
|
||||||
{/* 顶部操作栏 */}
|
{/* 顶部操作栏 */}
|
||||||
<div className='sticky top-0 z-20 bg-background/80 backdrop-blur-md border-b border-divider'>
|
<div className='w-full px-4 pt-4 pb-2'>
|
||||||
<div className='flex items-center justify-between p-4'>
|
<div className='flex items-center justify-between'>
|
||||||
<div className='flex items-center gap-3 flex-wrap'>
|
<div className='flex flex-col gap-1'>
|
||||||
<div className='flex items-center gap-2 text-sm'>
|
<h1 className='text-xl font-bold text-default-900 tracking-tight'>外观设置</h1>
|
||||||
<span className='text-default-400'>当前主题:</span>
|
<div className='flex items-center gap-3 text-tiny text-default-500'>
|
||||||
<Chip size='sm' color='primary' variant='flat'>
|
<div className='flex items-center gap-1.5'>
|
||||||
{savedThemeName || '加载中...'}
|
<IoIosColorPalette className='text-primary' size={16} />
|
||||||
</Chip>
|
<span className='font-medium text-default-700'>{savedThemeName || '加载中...'}</span>
|
||||||
|
</div>
|
||||||
|
<div className='w-px h-2.5 bg-default-300' />
|
||||||
|
<div className='flex items-center gap-1.5'>
|
||||||
|
<FaFont className='text-secondary' size={12} />
|
||||||
|
<span className='font-medium text-default-700'>{savedFontModeDisplayName}</span>
|
||||||
|
</div>
|
||||||
|
{hasUnsavedChanges && (
|
||||||
|
<>
|
||||||
|
<div className='w-px h-2.5 bg-default-300' />
|
||||||
|
<div className='flex items-center gap-1'>
|
||||||
|
<div className='w-1.5 h-1.5 rounded-full bg-warning animate-pulse' />
|
||||||
|
<span className='text-warning font-semibold'>待保存</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-2 text-sm'>
|
|
||||||
<span className='text-default-400'>字体:</span>
|
|
||||||
<Chip size='sm' color='secondary' variant='flat'>
|
|
||||||
{savedFontModeDisplayName}
|
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
{hasUnsavedChanges && (
|
|
||||||
<Chip size='sm' color='warning' variant='solid'>
|
|
||||||
有未保存的更改
|
|
||||||
</Chip>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
radius='full'
|
|
||||||
variant='flat'
|
variant='flat'
|
||||||
className='font-medium bg-default-100 text-default-600 dark:bg-default-50/50'
|
color='default'
|
||||||
|
className='font-medium bg-default-100 hover:bg-default-200 h-9'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
reset();
|
reset();
|
||||||
toast.success('已重置');
|
toast.success('已重置');
|
||||||
}}
|
}}
|
||||||
isDisabled={!hasUnsavedChanges}
|
isDisabled={!hasUnsavedChanges}
|
||||||
>
|
>
|
||||||
取消更改
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
color='primary'
|
color='primary'
|
||||||
radius='full'
|
className='font-medium shadow-lg shadow-primary/20 px-6 h-9'
|
||||||
className='font-medium shadow-md shadow-primary/20'
|
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
onPress={() => onSubmit()}
|
onPress={() => onSubmit()}
|
||||||
isDisabled={!hasUnsavedChanges}
|
isDisabled={!hasUnsavedChanges}
|
||||||
>
|
>
|
||||||
保存
|
保存应用
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className='w-px h-6 bg-divider mx-1 hidden sm:block'></div>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
isIconOnly
|
isIconOnly
|
||||||
radius='full'
|
variant='light'
|
||||||
variant='flat'
|
className='text-default-500 hover:text-default-900 hidden sm:flex'
|
||||||
className='text-default-500 bg-default-100 dark:bg-default-50/50'
|
|
||||||
onPress={onRefresh}
|
onPress={onRefresh}
|
||||||
>
|
>
|
||||||
<IoMdRefresh size={18} />
|
<IoMdRefresh size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='p-4'>
|
<div className='px-4 pt-0 pb-4 w-full h-full'>
|
||||||
<Accordion variant='splitted' defaultExpandedKeys={['font', 'select']}>
|
<Tabs
|
||||||
<AccordionItem
|
aria-label="Theme Config Options"
|
||||||
key='font'
|
color="primary"
|
||||||
aria-label='Font Settings'
|
variant="underlined"
|
||||||
title='字体设置'
|
disableAnimation
|
||||||
subtitle='自定义WebUI显示的字体'
|
classNames={{
|
||||||
className='shadow-small'
|
tabList: "gap-8 w-full relative rounded-none p-0 border-b border-divider overflow-x-auto no-scrollbar",
|
||||||
startContent={<FaFont />}
|
cursor: "w-full bg-primary h-[3px] -bottom-[1.5px]",
|
||||||
>
|
tab: "max-w-fit px-0 h-12 hover:opacity-100 opacity-70 data-[selected=true]:opacity-100",
|
||||||
<div className='flex flex-col gap-4'>
|
tabContent: "font-semibold py-2",
|
||||||
<Controller
|
panel: "py-4"
|
||||||
control={control}
|
}}
|
||||||
name='theme.fontMode'
|
>
|
||||||
render={({ field }) => (
|
<Tab
|
||||||
<Select
|
key="font"
|
||||||
label='字体预设'
|
title={
|
||||||
selectedKeys={field.value ? [field.value] : ['aacute']}
|
<div className="flex items-center space-x-2">
|
||||||
onChange={(e) => field.onChange(e.target.value)}
|
<FaFont />
|
||||||
className='max-w-xs'
|
<span>字体设置</span>
|
||||||
disallowEmptySelection
|
|
||||||
>
|
|
||||||
<SelectItem key='aacute'>Aa 偷吃可爱长大的</SelectItem>
|
|
||||||
<SelectItem key='system'>系统默认</SelectItem>
|
|
||||||
<SelectItem key='custom'>自定义字体</SelectItem>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className='p-3 rounded-lg bg-default-100 dark:bg-default-50/30'>
|
|
||||||
<div className='text-sm text-default-500 mb-2'>
|
|
||||||
上传自定义字体(仅在选择"自定义字体"时生效)
|
|
||||||
</div>
|
|
||||||
<FileInput
|
|
||||||
label='上传字体文件'
|
|
||||||
placeholder='选择字体文件 (.woff/.woff2/.ttf/.otf)'
|
|
||||||
accept='.ttf,.otf,.woff,.woff2'
|
|
||||||
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>
|
}
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem
|
|
||||||
key='select'
|
|
||||||
aria-label='Pick Color'
|
|
||||||
title='选择主题'
|
|
||||||
subtitle='点击主题卡片即可预览,记得保存'
|
|
||||||
className='shadow-small'
|
|
||||||
startContent={<IoIosColorPalette />}
|
|
||||||
>
|
>
|
||||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3'>
|
<Card className='shadow-sm border border-default-100 bg-background/60 backdrop-blur-md w-full'>
|
||||||
|
<CardBody className='p-6'>
|
||||||
|
<div className='flex flex-col gap-6 w-full'>
|
||||||
|
<div>
|
||||||
|
<h3 className='text-lg font-medium mb-1'>WebUI 字体</h3>
|
||||||
|
<p className='text-sm text-default-500 mb-4'>自定义界面显示的字体风格</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name='theme.fontMode'
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
label='选择字体'
|
||||||
|
variant='bordered'
|
||||||
|
selectedKeys={field.value ? [field.value] : ['aacute']}
|
||||||
|
onChange={(e) => field.onChange(e.target.value)}
|
||||||
|
className='max-w-xs'
|
||||||
|
disallowEmptySelection
|
||||||
|
>
|
||||||
|
<SelectItem key='aacute'>Aa 偷吃可爱长大的</SelectItem>
|
||||||
|
<SelectItem key='system'>系统默认</SelectItem>
|
||||||
|
<SelectItem key='custom'>自定义字体</SelectItem>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{theme.fontMode === 'custom' && (
|
||||||
|
<div className='p-4 rounded-xl bg-default-50 border border-default-100'>
|
||||||
|
<div className='flex items-center justify-between mb-4'>
|
||||||
|
<div className='text-sm font-medium'>自定义字体文件</div>
|
||||||
|
{customFontExists && (
|
||||||
|
<Chip size='sm' color='success' variant='flat' startContent={<FaCheck size={10} />}>
|
||||||
|
已上传
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileInput
|
||||||
|
label='上传字体文件'
|
||||||
|
placeholder='拖拽或点击上传 (.woff/.woff2/.ttf/.otf)'
|
||||||
|
accept='.ttf,.otf,.woff,.woff2'
|
||||||
|
onChange={async (file) => {
|
||||||
|
try {
|
||||||
|
if (customFontExists) {
|
||||||
|
try {
|
||||||
|
await FileManager.deleteWebUIFont();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to delete existing font before upload:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className='text-xs text-default-400 mt-2'>
|
||||||
|
注意:上传新字体会覆盖旧字体文件,更改后需要刷新页面生效。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</Tab>
|
||||||
|
|
||||||
|
<Tab
|
||||||
|
key="theme"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<IoIosColorPalette size={18} />
|
||||||
|
<span>选择主题</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4'>
|
||||||
{themes.map((t) => (
|
{themes.map((t) => (
|
||||||
<PreviewThemeCard
|
<PreviewThemeCard
|
||||||
key={t.name}
|
key={t.name}
|
||||||
@@ -436,64 +483,77 @@ const ThemeConfigCard = () => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AccordionItem>
|
</Tab>
|
||||||
|
|
||||||
<AccordionItem
|
<Tab
|
||||||
key='pick'
|
key="custom-color"
|
||||||
aria-label='Pick Color'
|
title={
|
||||||
title='自定义配色'
|
<div className="flex items-center space-x-2">
|
||||||
subtitle='精细调整每个颜色变量'
|
<FaPaintbrush />
|
||||||
className='shadow-small'
|
<span>自定义配色</span>
|
||||||
startContent={<FaPaintbrush />}
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className='space-y-4'>
|
<div className='grid grid-cols-1 lg:grid-cols-2 gap-6'>
|
||||||
{(['light', 'dark'] as const).map((mode) => (
|
{(['light', 'dark'] as const).map((mode) => (
|
||||||
<div
|
<Card key={mode} className={clsx('border shadow-sm', mode === 'dark' ? 'bg-[#18181b] border-zinc-800' : 'bg-white border-zinc-200')}>
|
||||||
key={mode}
|
<CardHeader className='pb-0 pt-4 px-4 flex-col items-start'>
|
||||||
className={clsx(
|
<div className='flex items-center gap-2 mb-1'>
|
||||||
'p-4 rounded-lg',
|
{mode === 'dark' ? <MdDarkMode className="text-zinc-400" size={20} /> : <MdLightMode className="text-orange-400" size={20} />}
|
||||||
mode === 'dark' ? 'bg-zinc-900 text-white' : 'bg-zinc-100 text-black'
|
<h4 className={clsx('font-bold text-large', mode === 'dark' ? 'text-white' : 'text-black')}>
|
||||||
)}
|
{mode === 'dark' ? '深色模式' : '浅色模式'}
|
||||||
>
|
</h4>
|
||||||
<h3 className='flex items-center justify-center gap-2 p-2 rounded-md bg-opacity-20 mb-4 font-medium'>
|
</div>
|
||||||
{mode === 'dark' ? <MdDarkMode size={20} /> : <MdLightMode size={20} />}
|
<p className={clsx('text-tiny', mode === 'dark' ? 'text-zinc-400' : 'text-zinc-500')}>
|
||||||
{mode === 'dark' ? '深色模式' : '浅色模式'}
|
调整{mode === 'dark' ? '深色' : '浅色'}主题下的颜色变量
|
||||||
</h3>
|
</p>
|
||||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
|
</CardHeader>
|
||||||
{colorKeys.map((colorKey) => (
|
<CardBody className='p-4'>
|
||||||
<div
|
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
|
||||||
key={colorKey}
|
{colorKeys.map((colorKey) => (
|
||||||
className='flex items-center gap-2 p-2 rounded bg-black/5 dark:bg-white/5'
|
<div
|
||||||
>
|
key={colorKey}
|
||||||
<Controller
|
className={clsx(
|
||||||
control={control}
|
'flex items-center gap-3 p-2 rounded-lg border transition-colors',
|
||||||
name={`theme.${mode}.${colorKey}`}
|
mode === 'dark' ? 'bg-zinc-900/50 border-zinc-800 hover:bg-zinc-900' : 'bg-zinc-50 border-zinc-100 hover:bg-zinc-100'
|
||||||
render={({ field: { value, onChange } }) => {
|
)}
|
||||||
const hslArray = value?.split(' ') ?? [0, 0, 0];
|
>
|
||||||
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
|
<Controller
|
||||||
return (
|
control={control}
|
||||||
<ColorPicker
|
name={`theme.${mode}.${colorKey}`}
|
||||||
color={color}
|
render={({ field: { value, onChange } }) => {
|
||||||
onChange={(result) => {
|
const hslArray = value?.split(' ') ?? [0, 0, 0];
|
||||||
onChange(
|
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
|
||||||
`${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%`
|
return (
|
||||||
);
|
<ColorPicker
|
||||||
}}
|
color={color}
|
||||||
/>
|
onChange={(hslString) => {
|
||||||
);
|
const match = hslString.match(/hsl\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)%,\s*(\d+(?:\.\d+)?)%\)/);
|
||||||
}}
|
if (match) {
|
||||||
/>
|
onChange(`${match[1]} ${match[2]}% ${match[3]}%`);
|
||||||
<span className='text-xs font-mono truncate flex-1' title={colorKey}>
|
}
|
||||||
{colorKey.replace('--heroui-', '')}
|
}}
|
||||||
</span>
|
/>
|
||||||
</div>
|
);
|
||||||
))}
|
}}
|
||||||
</div>
|
/>
|
||||||
</div>
|
<div className='flex flex-col overflow-hidden'>
|
||||||
|
<span className={clsx('text-xs font-medium truncate', mode === 'dark' ? 'text-zinc-300' : 'text-zinc-700')}>
|
||||||
|
{colorKey.replace('--heroui-', '')}
|
||||||
|
</span>
|
||||||
|
<span className={clsx('text-[10px] truncate', mode === 'dark' ? 'text-zinc-500' : 'text-zinc-400')}>
|
||||||
|
Variable
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AccordionItem>
|
</Tab>
|
||||||
</Accordion>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,13 +63,12 @@ export default function ExtensionPage () {
|
|||||||
}, [extensionPages]);
|
}, [extensionPages]);
|
||||||
|
|
||||||
// 获取当前选中页面的 iframe URL
|
// 获取当前选中页面的 iframe URL
|
||||||
|
// 新路由格式不需要鉴权: /plugin/:pluginId/page/:pagePath
|
||||||
const currentPageUrl = useMemo(() => {
|
const currentPageUrl = useMemo(() => {
|
||||||
if (!selectedTab) return '';
|
if (!selectedTab) return '';
|
||||||
const [pluginId, ...pathParts] = selectedTab.split(':');
|
const [pluginId, ...pathParts] = selectedTab.split(':');
|
||||||
const path = pathParts.join(':').replace(/^\//, '');
|
const path = pathParts.join(':').replace(/^\//, '');
|
||||||
// 获取认证 token
|
return `/plugin/${pluginId}/page/${path}`;
|
||||||
const token = localStorage.getItem('token') || '';
|
|
||||||
return `/api/Plugin/page/${pluginId}/${path}?webui_token=${token}`;
|
|
||||||
}, [selectedTab]);
|
}, [selectedTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,78 +85,93 @@ export default function ExtensionPage () {
|
|||||||
setIframeLoading(false);
|
setIframeLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 在新窗口打开页面(新路由不需要鉴权)
|
||||||
|
const openInNewWindow = (pluginId: string, path: string) => {
|
||||||
|
const cleanPath = path.replace(/^\//, '');
|
||||||
|
const url = `/plugin/${pluginId}/page/${cleanPath}`;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>扩展页面 - NapCat WebUI</title>
|
<title>扩展页面 - NapCat WebUI</title>
|
||||||
<div className='p-2 md:p-4 relative h-full flex flex-col'>
|
<div className='p-2 md:p-4 relative h-[calc(100vh-6rem)] md:h-[calc(100vh-4rem)] flex flex-col'>
|
||||||
<PageLoading loading={loading} />
|
<PageLoading loading={loading} />
|
||||||
|
|
||||||
<div className='flex mb-4 items-center gap-4'>
|
<div className='flex mb-4 items-center justify-between gap-4 flex-wrap'>
|
||||||
<div className='flex items-center gap-2 text-default-600'>
|
<div className='flex items-center gap-4'>
|
||||||
<MdExtension size={24} />
|
<div className='flex items-center gap-2 text-default-600'>
|
||||||
<span className='text-lg font-medium'>插件扩展页面</span>
|
<MdExtension size={24} />
|
||||||
|
<span className='text-lg font-medium'>插件扩展页面</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
|
||||||
|
radius='full'
|
||||||
|
onPress={refresh}
|
||||||
|
>
|
||||||
|
<IoMdRefresh size={24} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{extensionPages.length > 0 && (
|
||||||
isIconOnly
|
<Tabs
|
||||||
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
|
aria-label='Extension Pages'
|
||||||
radius='full'
|
className='max-w-full'
|
||||||
onPress={refresh}
|
selectedKey={selectedTab}
|
||||||
>
|
onSelectionChange={(key) => setSelectedTab(key as string)}
|
||||||
<IoMdRefresh size={24} />
|
classNames={{
|
||||||
</Button>
|
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
|
||||||
|
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
||||||
|
panel: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Tab
|
||||||
|
key={tab.key}
|
||||||
|
title={
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
{tab.icon && <span>{tab.icon}</span>}
|
||||||
|
<span
|
||||||
|
className='cursor-pointer hover:underline truncate max-w-[6rem] md:max-w-none'
|
||||||
|
title={`插件:${tab.pluginName}\n点击在新窗口打开`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openInNewWindow(tab.pluginId, tab.path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.title}
|
||||||
|
</span>
|
||||||
|
<span className='text-xs text-default-400 hidden md:inline'>({tab.pluginName})</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{extensionPages.length === 0 && !loading
|
{extensionPages.length === 0 && !loading ? (
|
||||||
? (
|
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
|
||||||
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
|
<MdExtension size={64} className='mb-4 opacity-50' />
|
||||||
<MdExtension size={64} className='mb-4 opacity-50' />
|
<p className='text-lg'>暂无插件扩展页面</p>
|
||||||
<p className='text-lg'>暂无插件扩展页面</p>
|
<p className='text-sm mt-2'>插件可以通过注册页面来扩展 WebUI 功能</p>
|
||||||
<p className='text-sm mt-2'>插件可以通过注册页面来扩展 WebUI 功能</p>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)
|
<div className='flex-1 min-h-0 bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden relative'>
|
||||||
: (
|
{iframeLoading && (
|
||||||
<div className='flex-1 flex flex-col min-h-0'>
|
<div className='absolute inset-0 flex items-center justify-center bg-default-100/50 z-10'>
|
||||||
<Tabs
|
<Spinner size='lg' />
|
||||||
aria-label='Extension Pages'
|
</div>
|
||||||
className='max-w-full'
|
)}
|
||||||
selectedKey={selectedTab}
|
<iframe
|
||||||
onSelectionChange={(key) => setSelectedTab(key as string)}
|
src={currentPageUrl}
|
||||||
classNames={{
|
className='w-full h-full border-0'
|
||||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md flex-wrap',
|
onLoad={handleIframeLoad}
|
||||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
title='extension-page'
|
||||||
panel: 'flex-1 min-h-0 p-0',
|
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
|
||||||
}}
|
/>
|
||||||
>
|
</div>
|
||||||
{tabs.map((tab) => (
|
)}
|
||||||
<Tab
|
|
||||||
key={tab.key}
|
|
||||||
title={
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
{tab.icon && <span>{tab.icon}</span>}
|
|
||||||
<span>{tab.title}</span>
|
|
||||||
<span className='text-xs text-default-400'>({tab.pluginName})</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className='relative w-full h-[calc(100vh-220px)] bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden'>
|
|
||||||
{iframeLoading && (
|
|
||||||
<div className='absolute inset-0 flex items-center justify-center bg-default-100/50 z-10'>
|
|
||||||
<Spinner size='lg' />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<iframe
|
|
||||||
src={currentPageUrl}
|
|
||||||
className='w-full h-full border-0'
|
|
||||||
onLoad={handleIframeLoad}
|
|
||||||
title={tab.title}
|
|
||||||
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Tab>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import toast from 'react-hot-toast';
|
|||||||
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||||
|
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||||
|
|
||||||
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
|
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
|
||||||
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
|
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
|
||||||
@@ -51,10 +52,10 @@ export default function PluginStorePage () {
|
|||||||
const [pendingInstallPlugin, setPendingInstallPlugin] = useState<PluginStoreItem | null>(null);
|
const [pendingInstallPlugin, setPendingInstallPlugin] = useState<PluginStoreItem | null>(null);
|
||||||
const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(undefined);
|
const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const loadPlugins = async () => {
|
const loadPlugins = async (forceRefresh: boolean = false) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await PluginManager.getPluginStoreList();
|
const data = await PluginManager.getPluginStoreList(forceRefresh);
|
||||||
setPlugins(data.plugins);
|
setPlugins(data.plugins);
|
||||||
|
|
||||||
// 检查插件管理器是否已加载
|
// 检查插件管理器是否已加载
|
||||||
@@ -226,68 +227,70 @@ export default function PluginStorePage () {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||||
|
const hasBackground = !!backgroundImage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>插件商店 - NapCat WebUI</title>
|
<title>插件商店 - NapCat WebUI</title>
|
||||||
<div className="p-2 md:p-4 relative">
|
<div className="p-2 md:p-4 relative">
|
||||||
{/* 头部 */}
|
{/* 固定头部区域 */}
|
||||||
<div className="flex mb-6 items-center justify-between flex-wrap gap-4">
|
<div className={clsx(
|
||||||
<div className="flex items-center gap-4">
|
'sticky top-14 z-10 backdrop-blur-sm py-4 px-4 rounded-sm mb-4 -mx-2 md:-mx-4 -mt-2 md:-mt-4 transition-colors',
|
||||||
<h1 className="text-2xl font-bold">插件商店</h1>
|
hasBackground
|
||||||
<Button
|
? 'bg-white/20 dark:bg-black/10'
|
||||||
isIconOnly
|
: 'bg-transparent'
|
||||||
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
)}>
|
||||||
radius="full"
|
{/* 头部 */}
|
||||||
onPress={loadPlugins}
|
<div className="flex mb-4 items-center justify-between flex-wrap gap-4">
|
||||||
isLoading={loading}
|
<div className="flex items-center gap-4">
|
||||||
>
|
<h1 className="text-2xl font-bold">插件商店</h1>
|
||||||
<IoMdRefresh size={24} />
|
<Button
|
||||||
</Button>
|
isIconOnly
|
||||||
|
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
||||||
|
radius="full"
|
||||||
|
onPress={() => loadPlugins(true)}
|
||||||
|
isLoading={loading}
|
||||||
|
>
|
||||||
|
<IoMdRefresh size={24} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 商店列表源卡片 */}
|
||||||
|
<Card className="bg-default-100/50 backdrop-blur-md shadow-sm">
|
||||||
|
<CardBody className="py-2 px-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-default-500">列表源:</span>
|
||||||
|
<span className="text-sm font-medium">{getStoreSourceDisplayName()}</span>
|
||||||
|
</div>
|
||||||
|
<Tooltip content="切换列表源">
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
onPress={() => setStoreSourceModalOpen(true)}
|
||||||
|
>
|
||||||
|
<IoMdSettings size={16} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 商店列表源卡片 */}
|
{/* 搜索框 */}
|
||||||
<Card className="bg-default-100/50 backdrop-blur-md shadow-sm">
|
<div className="mb-4">
|
||||||
<CardBody className="py-2 px-3">
|
<Input
|
||||||
<div className="flex items-center gap-3">
|
placeholder="搜索插件名称、描述、作者或标签..."
|
||||||
<div className="flex items-center gap-2">
|
startContent={<IoMdSearch className="text-default-400" />}
|
||||||
<span className="text-xs text-default-500">列表源:</span>
|
value={searchQuery}
|
||||||
<span className="text-sm font-medium">{getStoreSourceDisplayName()}</span>
|
onValueChange={setSearchQuery}
|
||||||
</div>
|
className="max-w-md"
|
||||||
<Tooltip content="切换列表源">
|
/>
|
||||||
<Button
|
</div>
|
||||||
isIconOnly
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
onPress={() => setStoreSourceModalOpen(true)}
|
|
||||||
>
|
|
||||||
<IoMdSettings size={16} />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 搜索框 */}
|
{/* 标签页导航 */}
|
||||||
<div className="mb-6">
|
|
||||||
<Input
|
|
||||||
placeholder="搜索插件名称、描述、作者或标签..."
|
|
||||||
startContent={<IoMdSearch className="text-default-400" />}
|
|
||||||
value={searchQuery}
|
|
||||||
onValueChange={setSearchQuery}
|
|
||||||
className="max-w-md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标签页 */}
|
|
||||||
<div className="relative">
|
|
||||||
{/* 加载遮罩 - 只遮住插件列表区域 */}
|
|
||||||
{loading && (
|
|
||||||
<div className="absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg">
|
|
||||||
<Spinner size='lg' />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
aria-label="Plugin Store Categories"
|
aria-label="Plugin Store Categories"
|
||||||
className="max-w-full"
|
className="max-w-full"
|
||||||
@@ -296,32 +299,43 @@ export default function PluginStorePage () {
|
|||||||
classNames={{
|
classNames={{
|
||||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
|
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
|
||||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
||||||
|
panel: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
title={`${tab.title} (${tab.count})`}
|
title={`${tab.title} (${tab.count})`}
|
||||||
>
|
/>
|
||||||
<EmptySection isEmpty={!categorizedPlugins[tab.key]?.length} />
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
|
|
||||||
{categorizedPlugins[tab.key]?.map((plugin) => {
|
|
||||||
const installInfo = getPluginInstallInfo(plugin);
|
|
||||||
return (
|
|
||||||
<PluginStoreCard
|
|
||||||
key={plugin.id}
|
|
||||||
data={plugin}
|
|
||||||
installStatus={installInfo.status}
|
|
||||||
installedVersion={installInfo.installedVersion}
|
|
||||||
onInstall={() => handleInstall(plugin)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Tab>
|
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 插件列表区域 */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* 加载遮罩 - 只遮住插件列表区域 */}
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg">
|
||||||
|
<Spinner size='lg' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EmptySection isEmpty={!categorizedPlugins[activeTab]?.length} />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
|
||||||
|
{categorizedPlugins[activeTab]?.map((plugin) => {
|
||||||
|
const installInfo = getPluginInstallInfo(plugin);
|
||||||
|
return (
|
||||||
|
<PluginStoreCard
|
||||||
|
key={plugin.id}
|
||||||
|
data={plugin}
|
||||||
|
installStatus={installInfo.status}
|
||||||
|
installedVersion={installInfo.installedVersion}
|
||||||
|
onInstall={() => handleInstall(plugin)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 商店列表源选择弹窗 */}
|
{/* 商店列表源选择弹窗 */}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { Tab, Tabs } from '@heroui/tabs';
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import CryptoJS from 'crypto-js';
|
||||||
|
|
||||||
import logo from '@/assets/images/logo.png';
|
import logo from '@/assets/images/logo.png';
|
||||||
|
|
||||||
import HoverEffectCard from '@/components/effect_card';
|
import HoverEffectCard from '@/components/effect_card';
|
||||||
import { title } from '@/components/primitives';
|
import { title } from '@/components/primitives';
|
||||||
|
import PasswordLogin from '@/components/password_login';
|
||||||
import QrCodeLogin from '@/components/qr_code_login';
|
import QrCodeLogin from '@/components/qr_code_login';
|
||||||
import QuickLogin from '@/components/quick_login';
|
import QuickLogin from '@/components/quick_login';
|
||||||
import type { QQItem } from '@/components/quick_login';
|
import type { QQItem } from '@/components/quick_login';
|
||||||
@@ -51,6 +53,7 @@ export default function QQLoginPage () {
|
|||||||
const lastErrorRef = useRef<string>('');
|
const lastErrorRef = useRef<string>('');
|
||||||
const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([]);
|
const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([]);
|
||||||
const [refresh, setRefresh] = useState<boolean>(false);
|
const [refresh, setRefresh] = useState<boolean>(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<string>('shortcut');
|
||||||
const firstLoad = useRef<boolean>(true);
|
const firstLoad = useRef<boolean>(true);
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
if (!uinValue) {
|
if (!uinValue) {
|
||||||
@@ -72,6 +75,21 @@ export default function QQLoginPage () {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onPasswordSubmit = async (uin: string, password: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// 计算密码的MD5值
|
||||||
|
const passwordMd5 = CryptoJS.MD5(password).toString();
|
||||||
|
await QQManager.passwordLogin(uin, passwordMd5);
|
||||||
|
toast.success('密码登录请求已发送');
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message;
|
||||||
|
toast.error(`密码登录失败: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onUpdateQrCode = async () => {
|
const onUpdateQrCode = async () => {
|
||||||
if (firstLoad.current) setIsLoading(true);
|
if (firstLoad.current) setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -91,11 +109,17 @@ export default function QQLoginPage () {
|
|||||||
setLoginError(data.loginError);
|
setLoginError(data.loginError);
|
||||||
const friendlyMsg = parseLoginError(data.loginError);
|
const friendlyMsg = parseLoginError(data.loginError);
|
||||||
|
|
||||||
dialog.alert({
|
// 仅在扫码登录 Tab 下才弹窗,或者错误不是"二维码已过期"
|
||||||
title: '登录失败',
|
// 如果是 "二维码已过期",且不在 qrcode tab,则不弹窗
|
||||||
content: friendlyMsg,
|
const isQrCodeExpired = friendlyMsg.includes('二维码') && (friendlyMsg.includes('过期') || friendlyMsg.includes('失效'));
|
||||||
confirmText: '确定',
|
|
||||||
});
|
if (!isQrCodeExpired || activeTab === 'qrcode') {
|
||||||
|
dialog.alert({
|
||||||
|
title: '登录失败',
|
||||||
|
content: friendlyMsg,
|
||||||
|
confirmText: '确定',
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (!data.loginError) {
|
} else if (!data.loginError) {
|
||||||
lastErrorRef.current = '';
|
lastErrorRef.current = '';
|
||||||
setLoginError('');
|
setLoginError('');
|
||||||
@@ -197,6 +221,8 @@ export default function QQLoginPage () {
|
|||||||
}}
|
}}
|
||||||
isDisabled={isLoading}
|
isDisabled={isLoading}
|
||||||
size='lg'
|
size='lg'
|
||||||
|
selectedKey={activeTab}
|
||||||
|
onSelectionChange={(key) => key !== null && setActiveTab(key.toString())}
|
||||||
>
|
>
|
||||||
<Tab key='shortcut' title='快速登录'>
|
<Tab key='shortcut' title='快速登录'>
|
||||||
<QuickLogin
|
<QuickLogin
|
||||||
@@ -209,6 +235,13 @@ export default function QQLoginPage () {
|
|||||||
onUpdateQQList={onUpdateQQList}
|
onUpdateQQList={onUpdateQQList}
|
||||||
/>
|
/>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab key='password' title='密码登录'>
|
||||||
|
<PasswordLogin
|
||||||
|
isLoading={isLoading}
|
||||||
|
onSubmit={onPasswordSubmit}
|
||||||
|
qqList={qqList}
|
||||||
|
/>
|
||||||
|
</Tab>
|
||||||
<Tab key='qrcode' title='扫码登录'>
|
<Tab key='qrcode' title='扫码登录'>
|
||||||
<QrCodeLogin
|
<QrCodeLogin
|
||||||
loginError={parseLoginError(loginError)}
|
loginError={parseLoginError(loginError)}
|
||||||
|
|||||||
@@ -180,11 +180,14 @@ export const applyFont = (mode: string) => {
|
|||||||
|
|
||||||
if (mode === 'aacute') {
|
if (mode === 'aacute') {
|
||||||
root.style.setProperty('--font-family-base', "'Aa偷吃可爱长大的', var(--font-family-fallbacks)", 'important');
|
root.style.setProperty('--font-family-base', "'Aa偷吃可爱长大的', var(--font-family-fallbacks)", 'important');
|
||||||
|
root.style.setProperty('--font-family-mono', "'Aa偷吃可爱长大的', var(--font-family-fallbacks)", 'important');
|
||||||
} else if (mode === 'custom') {
|
} else if (mode === 'custom') {
|
||||||
root.style.setProperty('--font-family-base', "'CustomFont', var(--font-family-fallbacks)", 'important');
|
root.style.setProperty('--font-family-base', "'CustomFont', var(--font-family-fallbacks)", 'important');
|
||||||
|
root.style.setProperty('--font-family-mono', "'CustomFont', var(--font-family-fallbacks)", 'important');
|
||||||
} else {
|
} else {
|
||||||
// system or default - restore default
|
// system or default - restore default
|
||||||
root.style.setProperty('--font-family-base', 'var(--font-family-fallbacks)', 'important');
|
root.style.setProperty('--font-family-base', 'var(--font-family-fallbacks)', 'important');
|
||||||
|
root.style.setProperty('--font-family-mono', 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', 'important');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
'/api': backendDebugUrl,
|
'/api': backendDebugUrl,
|
||||||
'/files': backendDebugUrl,
|
'/files': backendDebugUrl,
|
||||||
|
'/plugin': backendDebugUrl,
|
||||||
'/webui/fonts/CustomFont.woff': backendDebugUrl,
|
'/webui/fonts/CustomFont.woff': backendDebugUrl,
|
||||||
'/webui/sw.js': backendDebugUrl,
|
'/webui/sw.js': backendDebugUrl,
|
||||||
},
|
},
|
||||||
|
|||||||
70
pnpm-lock.yaml
generated
70
pnpm-lock.yaml
generated
@@ -232,8 +232,8 @@ importers:
|
|||||||
packages/napcat-plugin-builtin:
|
packages/napcat-plugin-builtin:
|
||||||
dependencies:
|
dependencies:
|
||||||
napcat-types:
|
napcat-types:
|
||||||
specifier: 0.0.14
|
specifier: 0.0.16
|
||||||
version: 0.0.14
|
version: 0.0.16
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.1
|
specifier: ^22.0.1
|
||||||
@@ -302,6 +302,12 @@ importers:
|
|||||||
specifier: ^22.0.1
|
specifier: ^22.0.1
|
||||||
version: 22.19.1
|
version: 22.19.1
|
||||||
|
|
||||||
|
packages/napcat-rpc:
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.0.1
|
||||||
|
version: 22.19.1
|
||||||
|
|
||||||
packages/napcat-schema:
|
packages/napcat-schema:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
@@ -354,6 +360,12 @@ importers:
|
|||||||
napcat-core:
|
napcat-core:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../napcat-core
|
version: link:../napcat-core
|
||||||
|
napcat-image-size:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../napcat-image-size
|
||||||
|
napcat-rpc:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../napcat-rpc
|
||||||
devDependencies:
|
devDependencies:
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.0.9
|
specifier: ^4.0.9
|
||||||
@@ -634,9 +646,6 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.0
|
version: 19.2.0
|
||||||
react-color:
|
|
||||||
specifier: ^2.19.3
|
|
||||||
version: 2.19.3(react@19.2.0)
|
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.0(react@19.2.0)
|
version: 19.2.0(react@19.2.0)
|
||||||
@@ -1859,11 +1868,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
|
|
||||||
'@icons/material@0.2.4':
|
|
||||||
resolution: {integrity: sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==}
|
|
||||||
peerDependencies:
|
|
||||||
react: '*'
|
|
||||||
|
|
||||||
'@img/colour@1.0.0':
|
'@img/colour@1.0.0':
|
||||||
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
|
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -5154,9 +5158,6 @@ packages:
|
|||||||
markdown-table@3.0.4:
|
markdown-table@3.0.4:
|
||||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||||
|
|
||||||
material-colors@1.2.6:
|
|
||||||
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
|
||||||
|
|
||||||
math-intrinsics@1.1.0:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -5456,8 +5457,8 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
napcat-types@0.0.14:
|
napcat-types@0.0.16:
|
||||||
resolution: {integrity: sha512-q5ke+vzzXeZkYPsr9jmj94NxgH63/xv5yS/lPEU++A3x2mOM8SYJqdFEMbHG1QIFciyH1u3qnnNiJ0mBxOBFbA==}
|
resolution: {integrity: sha512-y3qhpdd16ATsMp4Jf88XwisFBVKqY+XSfvGX1YqMEasVFTNXeKr1MZrIzhHMkllW1QJZXAI8iNGVJO1gkHEtLQ==}
|
||||||
|
|
||||||
napcat.protobuf@1.1.4:
|
napcat.protobuf@1.1.4:
|
||||||
resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==}
|
resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==}
|
||||||
@@ -5859,11 +5860,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
react-color@2.19.3:
|
|
||||||
resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==}
|
|
||||||
peerDependencies:
|
|
||||||
react: '*'
|
|
||||||
|
|
||||||
react-dom@19.2.0:
|
react-dom@19.2.0:
|
||||||
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
|
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -5969,11 +5965,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
reactcss@1.2.3:
|
|
||||||
resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==}
|
|
||||||
peerDependencies:
|
|
||||||
react: '*'
|
|
||||||
|
|
||||||
read-cache@1.0.0:
|
read-cache@1.0.0:
|
||||||
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
||||||
|
|
||||||
@@ -6488,9 +6479,6 @@ packages:
|
|||||||
tinybench@2.9.0:
|
tinybench@2.9.0:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
tinycolor2@1.6.0:
|
|
||||||
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
|
|
||||||
|
|
||||||
tinyexec@0.3.2:
|
tinyexec@0.3.2:
|
||||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||||
|
|
||||||
@@ -8382,10 +8370,6 @@ snapshots:
|
|||||||
|
|
||||||
'@humanwhocodes/retry@0.4.3': {}
|
'@humanwhocodes/retry@0.4.3': {}
|
||||||
|
|
||||||
'@icons/material@0.2.4(react@19.2.0)':
|
|
||||||
dependencies:
|
|
||||||
react: 19.2.0
|
|
||||||
|
|
||||||
'@img/colour@1.0.0': {}
|
'@img/colour@1.0.0': {}
|
||||||
|
|
||||||
'@img/sharp-darwin-arm64@0.34.5':
|
'@img/sharp-darwin-arm64@0.34.5':
|
||||||
@@ -12298,8 +12282,6 @@ snapshots:
|
|||||||
|
|
||||||
markdown-table@3.0.4: {}
|
markdown-table@3.0.4: {}
|
||||||
|
|
||||||
material-colors@1.2.6: {}
|
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
mdast-util-find-and-replace@3.0.2:
|
mdast-util-find-and-replace@3.0.2:
|
||||||
@@ -12801,7 +12783,7 @@ snapshots:
|
|||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
napcat-types@0.0.14:
|
napcat-types@0.0.16:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sinclair/typebox': 0.34.41
|
'@sinclair/typebox': 0.34.41
|
||||||
'@types/node': 22.19.1
|
'@types/node': 22.19.1
|
||||||
@@ -13214,17 +13196,6 @@ snapshots:
|
|||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
strip-json-comments: 2.0.1
|
strip-json-comments: 2.0.1
|
||||||
|
|
||||||
react-color@2.19.3(react@19.2.0):
|
|
||||||
dependencies:
|
|
||||||
'@icons/material': 0.2.4(react@19.2.0)
|
|
||||||
lodash: 4.17.21
|
|
||||||
lodash-es: 4.17.21
|
|
||||||
material-colors: 1.2.6
|
|
||||||
prop-types: 15.8.1
|
|
||||||
react: 19.2.0
|
|
||||||
reactcss: 1.2.3(react@19.2.0)
|
|
||||||
tinycolor2: 1.6.0
|
|
||||||
|
|
||||||
react-dom@19.2.0(react@19.2.0):
|
react-dom@19.2.0(react@19.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
@@ -13329,11 +13300,6 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.0: {}
|
react@19.2.0: {}
|
||||||
|
|
||||||
reactcss@1.2.3(react@19.2.0):
|
|
||||||
dependencies:
|
|
||||||
lodash: 4.17.21
|
|
||||||
react: 19.2.0
|
|
||||||
|
|
||||||
read-cache@1.0.0:
|
read-cache@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
pify: 2.3.0
|
pify: 2.3.0
|
||||||
@@ -14023,8 +13989,6 @@ snapshots:
|
|||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
tinycolor2@1.6.0: {}
|
|
||||||
|
|
||||||
tinyexec@0.3.2: {}
|
tinyexec@0.3.2: {}
|
||||||
|
|
||||||
tinyglobby@0.2.15:
|
tinyglobby@0.2.15:
|
||||||
|
|||||||
@@ -6,9 +6,6 @@
|
|||||||
"lib": [
|
"lib": [
|
||||||
"ES2021"
|
"ES2021"
|
||||||
],
|
],
|
||||||
"typeRoots": [
|
|
||||||
"./node_modules/@types"
|
|
||||||
],
|
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user