mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-11 23:40:24 +00:00
Compare commits
31 Commits
feat/secur
...
v4.14.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,4 +16,5 @@ checkVersion.sh
|
||||
bun.lockb
|
||||
tests/run/
|
||||
guild1.db-wal
|
||||
guild1.db-shm
|
||||
guild1.db-shm
|
||||
packages/napcat-develop/config/.env
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"build:shell": "pnpm --filter napcat-shell run build || 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:webui": "pnpm --filter napcat-webui-frontend run build || exit 1",
|
||||
"build:plugin-builtin": "pnpm --filter napcat-plugin-builtin run build || exit 1",
|
||||
|
||||
8
packages/napcat-core/external/appid.json
vendored
8
packages/napcat-core/external/appid.json
vendored
@@ -518,5 +518,13 @@
|
||||
"9.9.26-44725": {
|
||||
"appid": 537337569,
|
||||
"qua": "V1_WIN_NQ_9.9.26_44725_GW_B"
|
||||
},
|
||||
"9.9.27-45627": {
|
||||
"appid": 537340060,
|
||||
"qua": "V1_WIN_NQ_9.9.26_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": {
|
||||
"send": "0A18D0C",
|
||||
"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": {
|
||||
"send": "2CEBB20",
|
||||
"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';
|
||||
|
||||
interface ForwardMsgJson {
|
||||
app: string
|
||||
app: string;
|
||||
config: ForwardMsgJsonConfig,
|
||||
desc: string,
|
||||
extra: ForwardMsgJsonExtra,
|
||||
meta: ForwardMsgJsonMeta,
|
||||
prompt: string,
|
||||
ver: string,
|
||||
view: string
|
||||
view: string;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonConfig {
|
||||
@@ -17,7 +17,7 @@ interface ForwardMsgJsonConfig {
|
||||
forward: number,
|
||||
round: number,
|
||||
type: string,
|
||||
width: number
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonExtra {
|
||||
@@ -26,17 +26,17 @@ interface ForwardMsgJsonExtra {
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonMeta {
|
||||
detail: ForwardMsgJsonMetaDetail
|
||||
detail: ForwardMsgJsonMetaDetail;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonMetaDetail {
|
||||
news: {
|
||||
text: string
|
||||
text: string;
|
||||
}[],
|
||||
resid: string,
|
||||
source: string,
|
||||
summary: string,
|
||||
uniseq: string
|
||||
uniseq: string;
|
||||
}
|
||||
|
||||
interface ForwardAdaptMsg {
|
||||
@@ -50,8 +50,8 @@ interface ForwardAdaptMsgElement {
|
||||
}
|
||||
|
||||
export class ForwardMsgBuilder {
|
||||
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
||||
const id = crypto.randomUUID();
|
||||
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||
const id = uuid ?? crypto.randomUUID();
|
||||
const isGroupMsg = msg.some(m => m.isGroupMsg);
|
||||
if (!source) {
|
||||
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, []);
|
||||
}
|
||||
|
||||
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 => ({
|
||||
senderName: msg.senderName,
|
||||
isGroupMsg: msg.groupId !== undefined,
|
||||
msg: msg.msg.map(m => ({
|
||||
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 { gunzipSync } from 'zlib';
|
||||
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
|
||||
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
|
||||
|
||||
export class PacketOperationContext {
|
||||
private readonly context: PacketContext;
|
||||
@@ -26,7 +27,7 @@ export class PacketOperationContext {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -94,12 +95,15 @@ export class PacketOperationContext {
|
||||
.filter(Boolean)
|
||||
);
|
||||
const res = await Promise.allSettled(reqList);
|
||||
this.context.logger.info(`上传资源${res.length}个,失败${res.filter((r) => r.status === 'rejected').length}个`);
|
||||
res.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
|
||||
}
|
||||
});
|
||||
const failedCount = res.filter((r) => r.status === 'rejected').length;
|
||||
if (failedCount > 0) {
|
||||
this.context.logger.warn(`上传资源${res.length}个,失败${failedCount}个`);
|
||||
res.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async UploadImage (img: PacketMsgPicElement) {
|
||||
@@ -224,7 +228,15 @@ export class PacketOperationContext {
|
||||
const res = trans.UploadForwardMsg.parse(resp);
|
||||
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 (
|
||||
groupUin: number,
|
||||
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 FetchC2CMessage } from './FetchC2CMessage';
|
||||
export { default as DownloadForwardMsg } from './DownloadForwardMsg';
|
||||
export { default as UploadForwardMsgV2 } from './UploadForwardMsgV2';
|
||||
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": ""
|
||||
}
|
||||
@@ -78,7 +78,7 @@ async function copyAll () {
|
||||
process.env.NAPCAT_WORKDIR = TARGET_DIR;
|
||||
// 开发环境使用固定密钥
|
||||
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...');
|
||||
await import(pathToFileURL(NAPCAT_MJS_PATH).href);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
{
|
||||
"name": "napcat-develop",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "powershell ./nodeTest.ps1"
|
||||
"name": "napcat-develop",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "powershell ./nodeTest.ps1",
|
||||
"copy-env": "xcopy config ..\\napcat-shell\\dist\\config /E /I /Y"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./index.js"
|
||||
},
|
||||
"./*": {
|
||||
"require": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"fs-extra": "^11.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"./*": {
|
||||
"require": "./*"
|
||||
}
|
||||
},
|
||||
"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-pty': resolve(__dirname, '../napcat-pty'),
|
||||
'@/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-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 { ReadStream } from 'fs';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export interface ImageSize {
|
||||
width: number;
|
||||
@@ -12,17 +19,18 @@ export enum ImageType {
|
||||
BMP = 'bmp',
|
||||
GIF = 'gif',
|
||||
WEBP = 'webp',
|
||||
TIFF = 'tiff',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
interface ImageParser {
|
||||
export interface ImageParser {
|
||||
readonly type: ImageType;
|
||||
canParse(buffer: Buffer): boolean;
|
||||
parseSize(stream: ReadStream): Promise<ImageSize | undefined>;
|
||||
canParse (buffer: Buffer): boolean;
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
@@ -35,316 +43,39 @@ function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// PNG解析器
|
||||
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];
|
||||
// 所有解析器实例
|
||||
const parserInstances = {
|
||||
png: new PngParser(),
|
||||
jpeg: new JpegParser(),
|
||||
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) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
// 类型到解析器的映射
|
||||
const typeToParser = new Map<ImageType, ImageParser>([
|
||||
[ImageType.PNG, parserInstances.png],
|
||||
[ImageType.JPEG, parserInstances.jpeg],
|
||||
[ImageType.BMP, parserInstances.bmp],
|
||||
[ImageType.GIF, parserInstances.gif],
|
||||
[ImageType.WEBP, parserInstances.webp],
|
||||
[ImageType.TIFF, parserInstances.tiff],
|
||||
]);
|
||||
|
||||
// JPEG解析器
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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(),
|
||||
];
|
||||
// 所有解析器列表(用于回退)
|
||||
const parsers: ReadonlyArray<ImageParser> = Object.values(parserInstances);
|
||||
|
||||
export async function detectImageType (filePath: string): Promise<ImageType> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -354,18 +85,22 @@ export async function detectImageType (filePath: string): Promise<ImageType> {
|
||||
end: 63,
|
||||
});
|
||||
|
||||
let buffer: Buffer | null = null;
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
stream.once('error', (err) => {
|
||||
stream.on('error', (err) => {
|
||||
stream.destroy();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
stream.once('readable', () => {
|
||||
buffer = stream.read(64) as Buffer;
|
||||
stream.destroy();
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
chunks.push(chunkBuffer);
|
||||
});
|
||||
|
||||
if (!buffer) {
|
||||
stream.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
if (buffer.length === 0) {
|
||||
return resolve(ImageType.UNKNOWN);
|
||||
}
|
||||
|
||||
@@ -377,12 +112,6 @@ export async function detectImageType (filePath: string): Promise<ImageType> {
|
||||
|
||||
resolve(ImageType.UNKNOWN);
|
||||
});
|
||||
|
||||
stream.once('end', () => {
|
||||
if (!buffer) {
|
||||
resolve(ImageType.UNKNOWN);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -390,7 +119,7 @@ export async function imageSizeFromFile (filePath: string): Promise<ImageSize |
|
||||
try {
|
||||
// 先检测类型
|
||||
const type = await detectImageType(filePath);
|
||||
const parser = parsers.find(p => p.type === type);
|
||||
const parser = typeToParser.get(type);
|
||||
if (!parser) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -422,3 +151,71 @@ export async function imageSizeFallBack (
|
||||
): Promise<ImageSize> {
|
||||
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> {
|
||||
override actionName = ActionName.Exit;
|
||||
override payloadSchema = Type.Void();
|
||||
override returnSchema = Type.Void();
|
||||
override payloadSchema = Type.Object({});
|
||||
override returnSchema = Type.Object({});
|
||||
override actionSummary = '退出登录';
|
||||
override actionTags = ['系统扩展'];
|
||||
override payloadExample = {};
|
||||
|
||||
@@ -12,7 +12,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
||||
|
||||
export class GetClientkey extends OneBotAction<void, ReturnType> {
|
||||
override actionName = ActionName.GetClientkey;
|
||||
override payloadSchema = Type.Void();
|
||||
override payloadSchema = Type.Object({});
|
||||
override returnSchema = ReturnSchema;
|
||||
override actionSummary = '获取ClientKey';
|
||||
override actionDescription = '获取当前登录帐号的ClientKey';
|
||||
|
||||
@@ -18,7 +18,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
||||
|
||||
export class GetFriendWithCategory extends OneBotAction<void, ReturnType> {
|
||||
override actionName = ActionName.GetFriendsWithCategory;
|
||||
override payloadSchema = Type.Void();
|
||||
override payloadSchema = Type.Object({});
|
||||
override returnSchema = ReturnSchema;
|
||||
override actionSummary = '获取带分组的好友列表';
|
||||
override actionTags = ['用户扩展'];
|
||||
|
||||
@@ -22,7 +22,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
||||
|
||||
export default class GetGroupAddRequest extends OneBotAction<void, ReturnType> {
|
||||
override actionName = ActionName.GetGroupIgnoreAddRequest;
|
||||
override payloadSchema = Type.Void();
|
||||
override payloadSchema = Type.Object({});
|
||||
override returnSchema = ReturnSchema;
|
||||
override actionSummary = '获取群被忽略的加群请求';
|
||||
override actionTags = ['群组接口'];
|
||||
|
||||
@@ -8,7 +8,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
||||
|
||||
export class GetRkey extends GetPacketStatusDepends<void, ReturnType> {
|
||||
override actionName = ActionName.GetRkey;
|
||||
override payloadSchema = Type.Void();
|
||||
override payloadSchema = Type.Object({});
|
||||
override returnSchema = ReturnSchema;
|
||||
override actionSummary = '获取 RKey';
|
||||
override actionTags = ['系统扩展'];
|
||||
|
||||
@@ -14,7 +14,7 @@ export class GetRobotUinRange extends OneBotAction<void, ReturnType> {
|
||||
override returnExample = [
|
||||
{ minUin: '12345678', maxUin: '87654321' }
|
||||
];
|
||||
override payloadSchema = Type.Void();
|
||||
override payloadSchema = Type.Object({});
|
||||
override returnSchema = ReturnSchema;
|
||||
|
||||
async _handle () {
|
||||
|
||||
@@ -19,7 +19,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
||||
|
||||
export class GetUnidirectionalFriendList extends OneBotAction<void, ReturnType> {
|
||||
override actionName = ActionName.GetUnidirectionalFriendList;
|
||||
override payloadSchema = Type.Void();
|
||||
override payloadSchema = Type.Object({});
|
||||
override returnSchema = ReturnSchema;
|
||||
override actionSummary = '获取单向好友列表';
|
||||
override actionTags = ['用户扩展'];
|
||||
|
||||
@@ -17,6 +17,7 @@ import { rawMsgWithSendMsg } from 'napcat-core/packet/message/converter';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { MsgActionsExamples } from '@/napcat-onebot/action/msg/examples';
|
||||
import { OB11MessageMixTypeSchema } from '@/napcat-onebot/types/message';
|
||||
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
|
||||
|
||||
export const SendMsgPayloadSchema = Type.Object({
|
||||
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<{
|
||||
finallySendElements: SendArkElement,
|
||||
res_id?: string,
|
||||
uuid?: string,
|
||||
packetMsg: PacketMsg[],
|
||||
deleteAfterSentFiles: string[],
|
||||
innerPacketMsg?: Array<{ uuid: string, packetMsg: PacketMsg[]; }>;
|
||||
} | null> {
|
||||
const packetMsg: PacketMsg[] = [];
|
||||
const delFiles: string[] = [];
|
||||
const innerMsg: Array<{ uuid: string, packetMsg: PacketMsg[]; }> = new Array();
|
||||
for (const node of messageNodes) {
|
||||
if (dp >= 3) {
|
||||
this.core.context.logger.logWarn('转发消息深度超过3层,将停止解析!');
|
||||
@@ -232,6 +237,13 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
||||
}, dp + 1);
|
||||
sendElements = uploadReturnData?.finallySendElements ? [uploadReturnData.finallySendElements] : [];
|
||||
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 {
|
||||
const sendElementsCreateReturn = await this.obContext.apis.MsgApi.createSendElements(OB11Data, msgPeer);
|
||||
sendElements = sendElementsCreateReturn.sendElements;
|
||||
@@ -273,8 +285,19 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
||||
this.core.context.logger.logWarn('handleForwardedNodesPacket 元素为空!');
|
||||
return null;
|
||||
}
|
||||
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsg(packetMsg, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
|
||||
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt);
|
||||
const uploadMsgData: UploadForwardMsgParams[] = [{
|
||||
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 {
|
||||
deleteAfterSentFiles: delFiles,
|
||||
finallySendElements: {
|
||||
@@ -285,6 +308,9 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
||||
},
|
||||
} as SendArkElement,
|
||||
res_id: resid,
|
||||
uuid: uuid,
|
||||
packetMsg: packetMsg,
|
||||
innerPacketMsg: innerMsg,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export { PluginModule } from './plugin/types';
|
||||
export { PluginStatusConfig } from './plugin/types';
|
||||
export { PluginRouterRegistry, PluginRequestHandler, PluginApiRouteDefinition, PluginPageDefinition, HttpMethod } from './plugin/types';
|
||||
export { PluginHttpRequest, PluginHttpResponse, PluginNextFunction } from './plugin/types';
|
||||
export { MemoryStaticFile, MemoryFileGenerator } from './plugin/types';
|
||||
export { PluginRouterRegistryImpl } from './plugin/router-registry';
|
||||
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> implements IPluginManager {
|
||||
private readonly pluginPath: string;
|
||||
@@ -179,6 +180,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
||||
this.pluginRouters.delete(entry.id);
|
||||
}
|
||||
|
||||
// 清理模块缓存
|
||||
this.loader.clearCache(entry.pluginPath);
|
||||
|
||||
// 重置状态
|
||||
entry.loaded = false;
|
||||
entry.runtime = {
|
||||
@@ -211,6 +215,15 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
||||
// 保存到路由注册表
|
||||
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 {
|
||||
core: this.core,
|
||||
oneBot: this.obContext,
|
||||
@@ -224,6 +237,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
||||
pluginManager: this,
|
||||
logger: pluginLogger,
|
||||
router: routerRegistry,
|
||||
getPluginExports,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -316,6 +330,11 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
||||
entry = newEntry;
|
||||
}
|
||||
|
||||
if (!entry.enable) {
|
||||
this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return await this.loadPlugin(entry);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
import { LogWrapper } from 'napcat-core/helper/log';
|
||||
import {
|
||||
PluginPackageJson,
|
||||
@@ -123,8 +125,8 @@ export class PluginLoader {
|
||||
const entryFile = this.findEntryFile(pluginDir, packageJson);
|
||||
const entryPath = entryFile ? path.join(pluginDir, entryFile) : undefined;
|
||||
|
||||
// 获取启用状态(默认启用)
|
||||
const enable = statusConfig[pluginId] !== false;
|
||||
// 获取启用状态(默认禁用,内置插件除外)
|
||||
const enable = statusConfig[pluginId] ?? (pluginId === 'napcat-plugin-builtin');
|
||||
|
||||
// 创建插件条目
|
||||
const entry: PluginEntry = {
|
||||
@@ -159,7 +161,7 @@ export class PluginLoader {
|
||||
id: dirname, // 使用目录名作为 ID
|
||||
fileId: dirname,
|
||||
pluginPath: path.join(this.pluginPath, dirname),
|
||||
enable: statusConfig[dirname] !== false,
|
||||
enable: statusConfig[dirname] ?? (dirname === 'napcat-plugin-builtin'),
|
||||
loaded: false,
|
||||
runtime: {
|
||||
status: 'error',
|
||||
@@ -295,4 +297,24 @@ export class PluginLoader {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 创建获取其他插件导出的方法
|
||||
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 {
|
||||
core: this.core,
|
||||
oneBot: this.obContext,
|
||||
@@ -207,6 +216,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
||||
pluginManager: this,
|
||||
logger: pluginLogger,
|
||||
router,
|
||||
getPluginExports,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -285,6 +295,11 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
||||
entry = newEntry;
|
||||
}
|
||||
|
||||
if (!entry.enable) {
|
||||
this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
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 {
|
||||
PluginRouterRegistry,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
PluginHttpRequest,
|
||||
PluginHttpResponse,
|
||||
HttpMethod,
|
||||
MemoryStaticFile,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
@@ -59,10 +60,17 @@ function wrapResponse (res: Response): PluginHttpResponse {
|
||||
* 插件路由注册器实现
|
||||
* 为每个插件创建独立的路由注册器,收集路由定义
|
||||
*/
|
||||
/** 内存静态路由定义 */
|
||||
interface MemoryStaticRoute {
|
||||
urlPath: string;
|
||||
files: MemoryStaticFile[];
|
||||
}
|
||||
|
||||
export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
||||
private apiRoutes: PluginApiRouteDefinition[] = [];
|
||||
private pageDefinitions: PluginPageDefinition[] = [];
|
||||
private staticRoutes: Array<{ urlPath: string; localPath: string; }> = [];
|
||||
private memoryStaticRoutes: MemoryStaticRoute[] = [];
|
||||
|
||||
constructor (
|
||||
private readonly pluginId: string,
|
||||
@@ -111,19 +119,19 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
||||
this.staticRoutes.push({ urlPath, localPath: absolutePath });
|
||||
}
|
||||
|
||||
staticOnMem (urlPath: string, files: MemoryStaticFile[]): void {
|
||||
this.memoryStaticRoutes.push({ urlPath, files });
|
||||
}
|
||||
|
||||
// ==================== 构建路由 ====================
|
||||
|
||||
/**
|
||||
* 构建 Express Router(用于 API 路由)
|
||||
* 注意:静态资源路由不在此处挂载,由 webui-backend 直接在不需要鉴权的路径下处理
|
||||
*/
|
||||
buildApiRouter (): Router {
|
||||
const router = Router();
|
||||
|
||||
// 注册静态文件路由
|
||||
for (const { urlPath, localPath } of this.staticRoutes) {
|
||||
router.use(urlPath, expressStatic(localPath));
|
||||
}
|
||||
|
||||
// 注册 API 路由
|
||||
for (const route of this.apiRoutes) {
|
||||
const handler = this.wrapHandler(route.handler);
|
||||
@@ -179,7 +187,14 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
||||
* 检查是否有注册的 API 路由
|
||||
*/
|
||||
hasApiRoutes (): boolean {
|
||||
return this.apiRoutes.length > 0 || this.staticRoutes.length > 0;
|
||||
return this.apiRoutes.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有注册的静态资源路由
|
||||
*/
|
||||
hasStaticRoutes (): boolean {
|
||||
return this.staticRoutes.length > 0 || this.memoryStaticRoutes.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,6 +225,20 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
||||
return this.pluginPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有注册的静态路由
|
||||
*/
|
||||
getStaticRoutes (): Array<{ urlPath: string; localPath: string; }> {
|
||||
return [...this.staticRoutes];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有注册的内存静态路由
|
||||
*/
|
||||
getMemoryStaticRoutes (): MemoryStaticRoute[] {
|
||||
return [...this.memoryStaticRoutes];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空路由(用于插件卸载)
|
||||
*/
|
||||
@@ -217,5 +246,6 @@ export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
||||
this.apiRoutes = [];
|
||||
this.pageDefinitions = [];
|
||||
this.staticRoutes = [];
|
||||
this.memoryStaticRoutes = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,19 @@ export interface PluginPageDefinition {
|
||||
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 {
|
||||
// ==================== API 路由注册 ====================
|
||||
@@ -167,6 +180,13 @@ export interface PluginRouterRegistry {
|
||||
* @param localPath 本地文件夹路径(相对于插件目录或绝对路径)
|
||||
*/
|
||||
static (urlPath: string, localPath: string): void;
|
||||
|
||||
/**
|
||||
* 提供内存生成的静态文件服务
|
||||
* @param urlPath URL 路径
|
||||
* @param files 内存文件列表
|
||||
*/
|
||||
staticOnMem (urlPath: string, files: MemoryStaticFile[]): void;
|
||||
}
|
||||
|
||||
// ==================== 插件管理器接口 ====================
|
||||
@@ -247,8 +267,15 @@ export interface NapCatPluginContext {
|
||||
/**
|
||||
* WebUI 路由注册器
|
||||
* 用于注册插件的 HTTP API 路由,路由将挂载到 /api/Plugin/ext/{pluginId}/
|
||||
* 静态资源将挂载到 /plugin/{pluginId}/files/{urlPath}/
|
||||
*/
|
||||
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 目录下的文件可通过 /api/Plugin/ext/{pluginId}/static/ 访问)
|
||||
// 注册静态资源目录
|
||||
// 静态资源可通过 /plugin/{pluginId}/files/static/ 访问(无需鉴权)
|
||||
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) => {
|
||||
const uptime = Date.now() - startTime;
|
||||
res.json({
|
||||
@@ -107,6 +129,37 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 插件互调用示例 ====================
|
||||
// 演示如何调用其他插件的导出方法
|
||||
ctx.router.get('/call-plugin/:pluginId', (req, res) => {
|
||||
const { pluginId } = req.params;
|
||||
|
||||
// 使用 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),
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 注册扩展页面
|
||||
ctx.router.page({
|
||||
path: 'dashboard',
|
||||
@@ -116,7 +169,10 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
|
||||
description: '查看内置插件的运行状态和配置'
|
||||
});
|
||||
|
||||
logger.info('WebUI 路由已注册: /api/Plugin/ext/' + ctx.pluginName);
|
||||
logger.info('WebUI 路由已注册:');
|
||||
logger.info(' - API 路由: /api/Plugin/ext/' + ctx.pluginName + '/');
|
||||
logger.info(' - 静态资源: /plugin/' + ctx.pluginName + '/files/static/');
|
||||
logger.info(' - 内存资源: /plugin/' + ctx.pluginName + '/mem/dynamic/');
|
||||
};
|
||||
|
||||
export const plugin_get_config: PluginModule['plugin_get_config'] = async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"description": "NapCat 内置插件",
|
||||
"author": "NapNeko",
|
||||
"dependencies": {
|
||||
"napcat-types": "0.0.14"
|
||||
"napcat-types": "0.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
|
||||
@@ -259,11 +259,14 @@
|
||||
</div>
|
||||
<div id="static-content">
|
||||
<p style="color: var(--text-secondary); font-size: 13px; margin-bottom: 12px;">
|
||||
测试插件静态资源服务是否正常工作
|
||||
测试插件静态资源服务是否正常工作(不需要鉴权)
|
||||
</p>
|
||||
<div class="actions" style="margin-top: 0;">
|
||||
<button class="btn btn-primary" onclick="testStaticResource()">
|
||||
获取 test.txt
|
||||
获取 test.txt(文件系统)
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="testMemoryResource()">
|
||||
获取 info.json(内存生成)
|
||||
</button>
|
||||
</div>
|
||||
<div id="static-result" style="margin-top: 12px;"></div>
|
||||
@@ -280,8 +283,12 @@
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const webuiToken = urlParams.get('webui_token') || '';
|
||||
|
||||
// 插件自行管理 API 调用
|
||||
// 插件 API 基础路径(需要鉴权)
|
||||
const apiBase = '/api/Plugin/ext/napcat-plugin-builtin';
|
||||
// 插件静态资源基础路径(不需要鉴权)
|
||||
const staticBase = '/plugin/napcat-plugin-builtin/files';
|
||||
// 插件内存资源基础路径(不需要鉴权)
|
||||
const memBase = '/plugin/napcat-plugin-builtin/mem';
|
||||
|
||||
// 封装 fetch,自动携带认证
|
||||
async function authFetch (url, options = {}) {
|
||||
@@ -392,12 +399,13 @@
|
||||
resultDiv.innerHTML = '<div class="loading">加载中</div>';
|
||||
|
||||
try {
|
||||
const response = await authFetch(`${apiBase}/static/test.txt`);
|
||||
// 静态资源不需要鉴权,直接请求
|
||||
const response = await fetch(`${staticBase}/static/test.txt`);
|
||||
if (response.ok) {
|
||||
const text = await response.text();
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
@@ -408,6 +416,30 @@
|
||||
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>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export default defineConfig({
|
||||
'@/napcat-schema': resolve(__dirname, './src'),
|
||||
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -7,11 +7,33 @@ import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
|
||||
import { webUiRuntimePort } from '@/napcat-webui-backend/index';
|
||||
import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// ES 模块中获取 __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
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 = {
|
||||
@@ -20,6 +42,7 @@ const ENV = {
|
||||
isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1',
|
||||
} as const;
|
||||
|
||||
|
||||
// Worker 消息类型
|
||||
interface WorkerMessage {
|
||||
type: 'restart' | 'restart-prepare' | 'shutdown';
|
||||
@@ -27,8 +50,7 @@ interface WorkerMessage {
|
||||
port?: number;
|
||||
}
|
||||
|
||||
// 初始化日志
|
||||
const pathWrapper = new NapCatPathWrapper();
|
||||
|
||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||
|
||||
// 进程管理器和当前 Worker 进程引用
|
||||
@@ -223,21 +245,21 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
|
||||
// 如果不是由于主动重启或关闭引起的退出,尝试自动重新拉起
|
||||
if (!isRestarting && !isShuttingDown) {
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
// 清理超出时间窗口的崩溃记录
|
||||
while (recentCrashTimestamps.length > 0 && now - recentCrashTimestamps[0]! > CRASH_TIME_WINDOW) {
|
||||
recentCrashTimestamps.shift();
|
||||
}
|
||||
|
||||
|
||||
// 记录本次崩溃
|
||||
recentCrashTimestamps.push(now);
|
||||
|
||||
|
||||
// 检查是否超过崩溃阈值
|
||||
if (recentCrashTimestamps.length >= MAX_CRASHES_IN_WINDOW) {
|
||||
logger.logError(`[NapCat] [${processType}] Worker进程在 ${CRASH_TIME_WINDOW / 1000} 秒内异常退出 ${MAX_CRASHES_IN_WINDOW} 次,主进程退出`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出 (${recentCrashTimestamps.length}/${MAX_CRASHES_IN_WINDOW}),正在尝试重新拉起...`);
|
||||
startWorker(true).catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
|
||||
|
||||
@@ -46,7 +46,7 @@ const ShellBaseConfig = (source_map: boolean = false) =>
|
||||
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
||||
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
||||
'@/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'),
|
||||
},
|
||||
},
|
||||
|
||||
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,7 @@
|
||||
"vitest": "^4.0.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*"
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-image-size": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, '../../'),
|
||||
'@/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",
|
||||
"version": "0.0.14",
|
||||
"version": "0.0.15",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"types": "./napcat-types/index.d.ts",
|
||||
|
||||
@@ -27,6 +27,8 @@ import compression from 'compression';
|
||||
import { napCatVersion } from 'napcat-common/src/version';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
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 __dirname = dirname(__filename);
|
||||
@@ -123,9 +125,14 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查并更新默认密码(仅在启用WebUI时)
|
||||
if (config.token === 'napcat' || !config.token) {
|
||||
const randomToken = process.env['NAPCAT_WEBUI_SECRET_KEY'] || getRandomToken(8);
|
||||
// 优先使用环境变量覆盖 Token
|
||||
if (process.env['NAPCAT_WEBUI_SECRET_KEY'] && config.token !== process.env['NAPCAT_WEBUI_SECRET_KEY']) {
|
||||
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 });
|
||||
logger.log('[NapCat] [WebUi] 检测到默认密码,已自动更新为安全密码');
|
||||
|
||||
@@ -226,10 +233,13 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
// 添加字体变量
|
||||
if (fontMode === 'aacute') {
|
||||
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||||
css += "--font-family-mono: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||||
} else if (fontMode === 'custom') {
|
||||
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||||
css += "--font-family-mono: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||||
} else {
|
||||
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 += '}';
|
||||
|
||||
@@ -240,10 +250,13 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
// 添加字体变量
|
||||
if (fontMode === 'aacute') {
|
||||
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||||
css += "--font-family-mono: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||||
} else if (fontMode === 'custom') {
|
||||
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||||
css += "--font-family-mono: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||||
} else {
|
||||
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 += '}';
|
||||
|
||||
@@ -283,6 +296,72 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
app.use('/webui', express.static(pathWrapper.staticPath, {
|
||||
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' });
|
||||
});
|
||||
|
||||
// 插件文件系统静态资源路由(不需要鉴权)
|
||||
// 路径格式: /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服务器
|
||||
const sslCerts = await checkCertificates(logger);
|
||||
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, '删除字体文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 检查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();
|
||||
if (pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
|
||||
if (!forceRefresh && pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
|
||||
//console.log('Using cached plugin list');
|
||||
return pluginListCache;
|
||||
}
|
||||
@@ -192,9 +192,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 {
|
||||
const data = await fetchPluginList();
|
||||
// 支持 forceRefresh 查询参数强制刷新缓存
|
||||
const forceRefresh = req.query['forceRefresh'] === 'true';
|
||||
const data = await fetchPluginList(forceRefresh);
|
||||
return sendSuccess(res, data);
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Failed to fetch plugin store list: ' + e.message);
|
||||
@@ -252,11 +254,13 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
|
||||
// 删除临时文件
|
||||
fs.unlinkSync(tempZipPath);
|
||||
|
||||
// 如果 pluginManager 存在,立即注册插件
|
||||
// 如果 pluginManager 存在,立即注册或重载插件
|
||||
const pluginManager = getPluginManager();
|
||||
if (pluginManager) {
|
||||
// 检查是否已注册,避免重复注册
|
||||
if (!pluginManager.getPluginInfo(id)) {
|
||||
// 如果插件已存在,则重载以刷新版本信息;否则注册新插件
|
||||
if (pluginManager.getPluginInfo(id)) {
|
||||
await pluginManager.reloadPlugin(id);
|
||||
} else {
|
||||
await pluginManager.loadPluginById(id);
|
||||
}
|
||||
}
|
||||
@@ -334,11 +338,14 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
||||
sendProgress('解压完成,正在清理...', 90);
|
||||
fs.unlinkSync(tempZipPath);
|
||||
|
||||
// 如果 pluginManager 存在,立即注册插件
|
||||
// 如果 pluginManager 存在,立即注册或重载插件
|
||||
const pluginManager = getPluginManager();
|
||||
if (pluginManager) {
|
||||
// 检查是否已注册,避免重复注册
|
||||
if (!pluginManager.getPluginInfo(id)) {
|
||||
// 如果插件已存在,则重载以刷新版本信息;否则注册新插件
|
||||
if (pluginManager.getPluginInfo(id)) {
|
||||
sendProgress('正在刷新插件信息...', 95);
|
||||
await pluginManager.reloadPlugin(id);
|
||||
} else {
|
||||
sendProgress('正在注册插件...', 95);
|
||||
await pluginManager.loadPluginById(id);
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper,
|
||||
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
logger.log('[NapCat Update] No pending updates found');
|
||||
//logger.log('[NapCat Update] No pending updates found');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
UploadHandler,
|
||||
UploadWebUIFontHandler,
|
||||
DeleteWebUIFontHandler, // 添加上传处理器
|
||||
CheckWebUIFontExistHandler, // Add this
|
||||
} from '../api/File';
|
||||
|
||||
const router: Router = Router();
|
||||
@@ -46,4 +47,5 @@ router.post('/upload', UploadHandler);
|
||||
|
||||
router.post('/font/upload/webui', UploadWebUIFontHandler);
|
||||
router.post('/font/delete/webui', DeleteWebUIFontHandler);
|
||||
router.get('/font/exists/webui', CheckWebUIFontExistHandler); // Add this
|
||||
export { router as FileRouter };
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
|
||||
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 upload = multer({
|
||||
storage: multer.memoryStorage()
|
||||
});
|
||||
|
||||
// router:读取配置
|
||||
router.post('/GetConfig', OB11GetConfigHandler);
|
||||
// router:写入配置
|
||||
router.post('/SetConfig', OB11SetConfigHandler);
|
||||
// router:导出配置
|
||||
router.get('/ExportConfig', BackupExportConfigHandler);
|
||||
// router:导入配置
|
||||
router.post('/ImportConfig', upload.single('configFile'), BackupImportConfigHandler);
|
||||
|
||||
export { router as OB11ConfigRouter };
|
||||
|
||||
|
||||
@@ -9,15 +9,30 @@ const SUPPORTED_FONT_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf'];
|
||||
|
||||
// 清理旧的字体文件
|
||||
const cleanOldFontFiles = (fontsPath: string) => {
|
||||
for (const ext of SUPPORTED_FONT_EXTENSIONS) {
|
||||
const fontPath = path.join(fontsPath, `webui${ext}`);
|
||||
try {
|
||||
if (fs.existsSync(fontPath)) {
|
||||
fs.unlinkSync(fontPath);
|
||||
}
|
||||
} catch {
|
||||
// 忽略删除失败
|
||||
try {
|
||||
// 确保字体目录存在
|
||||
if (!fs.existsSync(fontsPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 遍历目录下所有文件
|
||||
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) => {
|
||||
// 保留原始扩展名,统一文件名为 webui
|
||||
// 强制文件名为 CustomFont,保留原始扩展名
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, `webui${ext}`);
|
||||
cb(null, `CustomFont${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"qrcode.react": "^4.2.0",
|
||||
"quill": "^2.0.3",
|
||||
"react": "^19.0.0",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
|
||||
@@ -1,36 +1,409 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import React from 'react';
|
||||
import { ColorResult, SketchPicker } from 'react-color';
|
||||
|
||||
// 假定 heroui 提供的 Popover组件
|
||||
import { Input } from "@heroui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@heroui/popover";
|
||||
import React, { useCallback, useEffect, useRef, useState, memo } from "react";
|
||||
|
||||
interface ColorPickerProps {
|
||||
color: string
|
||||
onChange: (color: ColorResult) => void
|
||||
color: string;
|
||||
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 handleChange = (colorResult: ColorResult) => {
|
||||
onChange(colorResult);
|
||||
const [hsl, setHsl] = useState(parseHsl(color));
|
||||
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 (
|
||||
<Popover triggerScaleOnOpen={false}>
|
||||
<Popover triggerScaleOnOpen={false} placement="bottom">
|
||||
<PopoverTrigger>
|
||||
<div
|
||||
className='w-36 h-8 rounded-md cursor-pointer border border-content4'
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<div className="flex items-center gap-2 cursor-pointer group">
|
||||
<div
|
||||
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>
|
||||
<PopoverContent>
|
||||
<SketchPicker
|
||||
color={color}
|
||||
onChange={handleChange}
|
||||
className='!bg-transparent !shadow-none'
|
||||
/>
|
||||
<PopoverContent className="w-72 p-4 bg-background/80 backdrop-blur-xl border border-default-200 shadow-2xl rounded-2xl"
|
||||
onMouseDownCapture={() => { isDraggingRef.current = true; }}
|
||||
onMouseUpCapture={() => { isDraggingRef.current = false; }}
|
||||
onTouchStartCapture={() => { isDraggingRef.current = true; }}
|
||||
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>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPicker;
|
||||
export default ColorPicker;
|
||||
@@ -4,7 +4,7 @@ import clsx from 'clsx';
|
||||
import key from '@/const/key';
|
||||
|
||||
export interface ContainerProps {
|
||||
title: string;
|
||||
title: React.ReactNode;
|
||||
tag?: React.ReactNode;
|
||||
action: React.ReactNode;
|
||||
enableSwitch: React.ReactNode;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useState } from 'react';
|
||||
import { IoMdDownload, IoMdRefresh, IoMdCheckmarkCircle } from 'react-icons/io';
|
||||
|
||||
@@ -20,7 +21,7 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
onInstall,
|
||||
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 handleInstall = () => {
|
||||
@@ -41,7 +42,7 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
return {
|
||||
text: '更新',
|
||||
icon: <IoMdDownload size={16} />,
|
||||
color: 'success' as const,
|
||||
color: 'default' as const,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
@@ -53,11 +54,31 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
};
|
||||
|
||||
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 (
|
||||
<DisplayCardContainer
|
||||
className='w-full max-w-[420px]'
|
||||
title={name}
|
||||
title={titleContent}
|
||||
tag={
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{installStatus === 'installed' && (
|
||||
|
||||
@@ -260,14 +260,14 @@ const NewVersionTip = (props: NewVersionTipProps) => {
|
||||
<div className="cursor-pointer flex items-center justify-center" onClick={updateStatus === 'updating' ? undefined : showUpdateDialog}>
|
||||
<Chip
|
||||
size="sm"
|
||||
color="danger"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
classNames={{
|
||||
content: "font-bold text-[10px] px-1 flex items-center justify-center",
|
||||
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>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@@ -218,4 +218,11 @@ export default class FileManager {
|
||||
);
|
||||
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> {
|
||||
const { data } = await serverRequest.get<ServerResponse<PluginStoreList>>('/Plugin/Store/List');
|
||||
public static async getPluginStoreList (forceRefresh: boolean = false): Promise<PluginStoreList> {
|
||||
const params = forceRefresh ? { forceRefresh: 'true' } : {};
|
||||
const { data } = await serverRequest.get<ServerResponse<PluginStoreList>>('/Plugin/Store/List', { params });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ThemeConfigCard from './theme';
|
||||
import WebUIConfigCard from './webui';
|
||||
import BackupConfigCard from './backup';
|
||||
|
||||
export interface ConfigPageProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -108,6 +109,11 @@ export default function ConfigPage () {
|
||||
<ThemeConfigCard />
|
||||
</ConfigPageItem>
|
||||
</Tab>
|
||||
<Tab title='备份与恢复' key='backup'>
|
||||
<ConfigPageItem>
|
||||
<BackupConfigCard />
|
||||
</ConfigPageItem>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Accordion, AccordionItem } from '@heroui/accordion';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { useRequest } from 'ahooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||
@@ -162,6 +162,7 @@ const ThemeConfigCard = () => {
|
||||
|
||||
const [dataLoaded, setDataLoaded] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [customFontExists, setCustomFontExists] = useState(false);
|
||||
|
||||
// 使用 useRef 存储 style 标签引用和状态
|
||||
const styleTagRef = useRef<HTMLStyleElement | null>(null);
|
||||
@@ -213,6 +214,10 @@ const ThemeConfigCard = () => {
|
||||
}
|
||||
setDataLoaded(true);
|
||||
setHasUnsavedChanges(false);
|
||||
// 检查自定义字体是否存在
|
||||
FileManager.checkWebUIFontExists().then(exists => {
|
||||
setCustomFontExists(exists);
|
||||
}).catch(err => console.error('Failed to check custom font:', err));
|
||||
}, [data, setOnebotValue]);
|
||||
|
||||
// 实时应用字体预设(预览)
|
||||
@@ -293,138 +298,180 @@ const ThemeConfigCard = () => {
|
||||
<title>主题配置 - NapCat WebUI</title>
|
||||
|
||||
{/* 顶部操作栏 */}
|
||||
<div className='sticky top-0 z-20 bg-background/80 backdrop-blur-md border-b border-divider'>
|
||||
<div className='flex items-center justify-between p-4'>
|
||||
<div className='flex items-center gap-3 flex-wrap'>
|
||||
<div className='flex items-center gap-2 text-sm'>
|
||||
<span className='text-default-400'>当前主题:</span>
|
||||
<Chip size='sm' color='primary' variant='flat'>
|
||||
{savedThemeName || '加载中...'}
|
||||
</Chip>
|
||||
<div className='w-full px-4 pt-4 pb-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<h1 className='text-xl font-bold text-default-900 tracking-tight'>外观设置</h1>
|
||||
<div className='flex items-center gap-3 text-tiny text-default-500'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<IoIosColorPalette className='text-primary' size={16} />
|
||||
<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 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 className='flex items-center gap-2'>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
<Button
|
||||
size='sm'
|
||||
radius='full'
|
||||
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={() => {
|
||||
reset();
|
||||
toast.success('已重置');
|
||||
}}
|
||||
isDisabled={!hasUnsavedChanges}
|
||||
>
|
||||
取消更改
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
color='primary'
|
||||
radius='full'
|
||||
className='font-medium shadow-md shadow-primary/20'
|
||||
className='font-medium shadow-lg shadow-primary/20 px-6 h-9'
|
||||
isLoading={isSubmitting}
|
||||
onPress={() => onSubmit()}
|
||||
isDisabled={!hasUnsavedChanges}
|
||||
>
|
||||
保存
|
||||
保存应用
|
||||
</Button>
|
||||
<div className='w-px h-6 bg-divider mx-1 hidden sm:block'></div>
|
||||
<Button
|
||||
size='sm'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
variant='flat'
|
||||
className='text-default-500 bg-default-100 dark:bg-default-50/50'
|
||||
variant='light'
|
||||
className='text-default-500 hover:text-default-900 hidden sm:flex'
|
||||
onPress={onRefresh}
|
||||
>
|
||||
<IoMdRefresh size={18} />
|
||||
<IoMdRefresh size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='p-4'>
|
||||
<Accordion variant='splitted' defaultExpandedKeys={['font', 'select']}>
|
||||
<AccordionItem
|
||||
key='font'
|
||||
aria-label='Font Settings'
|
||||
title='字体设置'
|
||||
subtitle='自定义WebUI显示的字体'
|
||||
className='shadow-small'
|
||||
startContent={<FaFont />}
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<Controller
|
||||
control={control}
|
||||
name='theme.fontMode'
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
label='字体预设'
|
||||
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 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 className='px-4 pt-0 pb-4 w-full h-full'>
|
||||
<Tabs
|
||||
aria-label="Theme Config Options"
|
||||
color="primary"
|
||||
variant="underlined"
|
||||
disableAnimation
|
||||
classNames={{
|
||||
tabList: "gap-8 w-full relative rounded-none p-0 border-b border-divider overflow-x-auto no-scrollbar",
|
||||
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",
|
||||
tabContent: "font-semibold py-2",
|
||||
panel: "py-4"
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
key="font"
|
||||
title={
|
||||
<div className="flex items-center space-x-2">
|
||||
<FaFont />
|
||||
<span>字体设置</span>
|
||||
</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) => (
|
||||
<PreviewThemeCard
|
||||
key={t.name}
|
||||
@@ -436,64 +483,77 @@ const ThemeConfigCard = () => {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Tab>
|
||||
|
||||
<AccordionItem
|
||||
key='pick'
|
||||
aria-label='Pick Color'
|
||||
title='自定义配色'
|
||||
subtitle='精细调整每个颜色变量'
|
||||
className='shadow-small'
|
||||
startContent={<FaPaintbrush />}
|
||||
<Tab
|
||||
key="custom-color"
|
||||
title={
|
||||
<div className="flex items-center space-x-2">
|
||||
<FaPaintbrush />
|
||||
<span>自定义配色</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 lg:grid-cols-2 gap-6'>
|
||||
{(['light', 'dark'] as const).map((mode) => (
|
||||
<div
|
||||
key={mode}
|
||||
className={clsx(
|
||||
'p-4 rounded-lg',
|
||||
mode === 'dark' ? 'bg-zinc-900 text-white' : 'bg-zinc-100 text-black'
|
||||
)}
|
||||
>
|
||||
<h3 className='flex items-center justify-center gap-2 p-2 rounded-md bg-opacity-20 mb-4 font-medium'>
|
||||
{mode === 'dark' ? <MdDarkMode size={20} /> : <MdLightMode size={20} />}
|
||||
{mode === 'dark' ? '深色模式' : '浅色模式'}
|
||||
</h3>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
|
||||
{colorKeys.map((colorKey) => (
|
||||
<div
|
||||
key={colorKey}
|
||||
className='flex items-center gap-2 p-2 rounded bg-black/5 dark:bg-white/5'
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`theme.${mode}.${colorKey}`}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const hslArray = value?.split(' ') ?? [0, 0, 0];
|
||||
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
|
||||
return (
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(result) => {
|
||||
onChange(
|
||||
`${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className='text-xs font-mono truncate flex-1' title={colorKey}>
|
||||
{colorKey.replace('--heroui-', '')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Card key={mode} className={clsx('border shadow-sm', mode === 'dark' ? 'bg-[#18181b] border-zinc-800' : 'bg-white border-zinc-200')}>
|
||||
<CardHeader className='pb-0 pt-4 px-4 flex-col items-start'>
|
||||
<div className='flex items-center gap-2 mb-1'>
|
||||
{mode === 'dark' ? <MdDarkMode className="text-zinc-400" size={20} /> : <MdLightMode className="text-orange-400" size={20} />}
|
||||
<h4 className={clsx('font-bold text-large', mode === 'dark' ? 'text-white' : 'text-black')}>
|
||||
{mode === 'dark' ? '深色模式' : '浅色模式'}
|
||||
</h4>
|
||||
</div>
|
||||
<p className={clsx('text-tiny', mode === 'dark' ? 'text-zinc-400' : 'text-zinc-500')}>
|
||||
调整{mode === 'dark' ? '深色' : '浅色'}主题下的颜色变量
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardBody className='p-4'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
|
||||
{colorKeys.map((colorKey) => (
|
||||
<div
|
||||
key={colorKey}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 p-2 rounded-lg border transition-colors',
|
||||
mode === 'dark' ? 'bg-zinc-900/50 border-zinc-800 hover:bg-zinc-900' : 'bg-zinc-50 border-zinc-100 hover:bg-zinc-100'
|
||||
)}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`theme.${mode}.${colorKey}`}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const hslArray = value?.split(' ') ?? [0, 0, 0];
|
||||
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
|
||||
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]}%`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -86,78 +86,94 @@ export default function ExtensionPage () {
|
||||
setIframeLoading(false);
|
||||
};
|
||||
|
||||
// 在新窗口打开页面
|
||||
const openInNewWindow = (pluginId: string, path: string) => {
|
||||
const cleanPath = path.replace(/^\//, '');
|
||||
const token = localStorage.getItem('token') || '';
|
||||
const url = `/api/Plugin/page/${pluginId}/${cleanPath}?webui_token=${token}`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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} />
|
||||
|
||||
<div className='flex mb-4 items-center gap-4'>
|
||||
<div className='flex items-center gap-2 text-default-600'>
|
||||
<MdExtension size={24} />
|
||||
<span className='text-lg font-medium'>插件扩展页面</span>
|
||||
<div className='flex mb-4 items-center justify-between gap-4 flex-wrap'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='flex items-center gap-2 text-default-600'>
|
||||
<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>
|
||||
<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>
|
||||
{extensionPages.length > 0 && (
|
||||
<Tabs
|
||||
aria-label='Extension Pages'
|
||||
className='max-w-full'
|
||||
selectedKey={selectedTab}
|
||||
onSelectionChange={(key) => setSelectedTab(key as string)}
|
||||
classNames={{
|
||||
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'
|
||||
title='点击在新窗口打开'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openInNewWindow(tab.pluginId, tab.path);
|
||||
}}
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
<span className='text-xs text-default-400'>({tab.pluginName})</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{extensionPages.length === 0 && !loading
|
||||
? (
|
||||
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
|
||||
<MdExtension size={64} className='mb-4 opacity-50' />
|
||||
<p className='text-lg'>暂无插件扩展页面</p>
|
||||
<p className='text-sm mt-2'>插件可以通过注册页面来扩展 WebUI 功能</p>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className='flex-1 flex flex-col min-h-0'>
|
||||
<Tabs
|
||||
aria-label='Extension Pages'
|
||||
className='max-w-full'
|
||||
selectedKey={selectedTab}
|
||||
onSelectionChange={(key) => setSelectedTab(key as string)}
|
||||
classNames={{
|
||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md flex-wrap',
|
||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
||||
panel: 'flex-1 min-h-0 p-0',
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
{extensionPages.length === 0 && !loading ? (
|
||||
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
|
||||
<MdExtension size={64} className='mb-4 opacity-50' />
|
||||
<p className='text-lg'>暂无插件扩展页面</p>
|
||||
<p className='text-sm mt-2'>插件可以通过注册页面来扩展 WebUI 功能</p>
|
||||
</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='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='extension-page'
|
||||
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -51,10 +51,10 @@ export default function PluginStorePage () {
|
||||
const [pendingInstallPlugin, setPendingInstallPlugin] = useState<PluginStoreItem | null>(null);
|
||||
const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(undefined);
|
||||
|
||||
const loadPlugins = async () => {
|
||||
const loadPlugins = async (forceRefresh: boolean = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await PluginManager.getPluginStoreList();
|
||||
const data = await PluginManager.getPluginStoreList(forceRefresh);
|
||||
setPlugins(data.plugins);
|
||||
|
||||
// 检查插件管理器是否已加载
|
||||
@@ -238,7 +238,7 @@ export default function PluginStorePage () {
|
||||
isIconOnly
|
||||
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
||||
radius="full"
|
||||
onPress={loadPlugins}
|
||||
onPress={() => loadPlugins(true)}
|
||||
isLoading={loading}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
@@ -287,7 +287,7 @@ export default function PluginStorePage () {
|
||||
<Spinner size='lg' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<Tabs
|
||||
aria-label="Plugin Store Categories"
|
||||
className="max-w-full"
|
||||
|
||||
@@ -180,11 +180,14 @@ export const applyFont = (mode: string) => {
|
||||
|
||||
if (mode === 'aacute') {
|
||||
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') {
|
||||
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 {
|
||||
// system or default - restore default
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
61
pnpm-lock.yaml
generated
61
pnpm-lock.yaml
generated
@@ -232,8 +232,8 @@ importers:
|
||||
packages/napcat-plugin-builtin:
|
||||
dependencies:
|
||||
napcat-types:
|
||||
specifier: 0.0.14
|
||||
version: 0.0.14
|
||||
specifier: 0.0.15
|
||||
version: 0.0.15
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.1
|
||||
@@ -354,6 +354,9 @@ importers:
|
||||
napcat-core:
|
||||
specifier: workspace:*
|
||||
version: link:../napcat-core
|
||||
napcat-image-size:
|
||||
specifier: workspace:*
|
||||
version: link:../napcat-image-size
|
||||
devDependencies:
|
||||
vitest:
|
||||
specifier: ^4.0.9
|
||||
@@ -634,9 +637,6 @@ importers:
|
||||
react:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.0
|
||||
react-color:
|
||||
specifier: ^2.19.3
|
||||
version: 2.19.3(react@19.2.0)
|
||||
react-dom:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.0(react@19.2.0)
|
||||
@@ -1859,11 +1859,6 @@ packages:
|
||||
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
||||
engines: {node: '>=18.18'}
|
||||
|
||||
'@icons/material@0.2.4':
|
||||
resolution: {integrity: sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
|
||||
'@img/colour@1.0.0':
|
||||
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5154,9 +5149,6 @@ packages:
|
||||
markdown-table@3.0.4:
|
||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||
|
||||
material-colors@1.2.6:
|
||||
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5456,8 +5448,8 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
napcat-types@0.0.14:
|
||||
resolution: {integrity: sha512-q5ke+vzzXeZkYPsr9jmj94NxgH63/xv5yS/lPEU++A3x2mOM8SYJqdFEMbHG1QIFciyH1u3qnnNiJ0mBxOBFbA==}
|
||||
napcat-types@0.0.15:
|
||||
resolution: {integrity: sha512-uOkaQPO3SVgkO/Rt0cQ+02wCI9C9jzdYVViHByHrr9sA+2ZjT1HV5nVSgNNQXUaZ9q405LUu45xQ4lysNyLpBA==}
|
||||
|
||||
napcat.protobuf@1.1.4:
|
||||
resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==}
|
||||
@@ -5859,11 +5851,6 @@ packages:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
||||
react-color@2.19.3:
|
||||
resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
|
||||
react-dom@19.2.0:
|
||||
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
|
||||
peerDependencies:
|
||||
@@ -5969,11 +5956,6 @@ packages:
|
||||
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
reactcss@1.2.3:
|
||||
resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
|
||||
read-cache@1.0.0:
|
||||
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
||||
|
||||
@@ -6488,9 +6470,6 @@ packages:
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinycolor2@1.6.0:
|
||||
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
|
||||
|
||||
tinyexec@0.3.2:
|
||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||
|
||||
@@ -8382,10 +8361,6 @@ snapshots:
|
||||
|
||||
'@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/sharp-darwin-arm64@0.34.5':
|
||||
@@ -12298,8 +12273,6 @@ snapshots:
|
||||
|
||||
markdown-table@3.0.4: {}
|
||||
|
||||
material-colors@1.2.6: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mdast-util-find-and-replace@3.0.2:
|
||||
@@ -12801,7 +12774,7 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
napcat-types@0.0.14:
|
||||
napcat-types@0.0.15:
|
||||
dependencies:
|
||||
'@sinclair/typebox': 0.34.41
|
||||
'@types/node': 22.19.1
|
||||
@@ -13214,17 +13187,6 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
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):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
@@ -13329,11 +13291,6 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
pify: 2.3.0
|
||||
@@ -14023,8 +13980,6 @@ snapshots:
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinycolor2@1.6.0: {}
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
"lib": [
|
||||
"ES2021"
|
||||
],
|
||||
"typeRoots": [
|
||||
"./node_modules/@types"
|
||||
],
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"noEmit": false,
|
||||
|
||||
Reference in New Issue
Block a user