mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-12 16:00:27 +00:00
Compare commits
25 Commits
feat/secur
...
v4.14.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build:shell": "pnpm --filter napcat-shell run build || exit 1",
|
"build:shell": "pnpm --filter napcat-shell run build || exit 1",
|
||||||
"build:shell:dev": "pnpm --filter napcat-shell run build:dev || exit 1",
|
"build:shell:dev": "pnpm --filter napcat-shell run build:dev || exit 1",
|
||||||
|
"build:shell:config": "pnpm --filter napcat-shell run build && pnpm --filter napcat-develop run copy-env",
|
||||||
"build:framework": "pnpm --filter napcat-framework run build || exit 1",
|
"build:framework": "pnpm --filter napcat-framework run build || exit 1",
|
||||||
"build:webui": "pnpm --filter napcat-webui-frontend run build || exit 1",
|
"build:webui": "pnpm --filter napcat-webui-frontend run build || exit 1",
|
||||||
"build:plugin-builtin": "pnpm --filter napcat-plugin-builtin run build || exit 1",
|
"build:plugin-builtin": "pnpm --filter napcat-plugin-builtin run build || exit 1",
|
||||||
|
|||||||
8
packages/napcat-core/external/appid.json
vendored
8
packages/napcat-core/external/appid.json
vendored
@@ -518,5 +518,13 @@
|
|||||||
"9.9.26-44725": {
|
"9.9.26-44725": {
|
||||||
"appid": 537337569,
|
"appid": 537337569,
|
||||||
"qua": "V1_WIN_NQ_9.9.26_44725_GW_B"
|
"qua": "V1_WIN_NQ_9.9.26_44725_GW_B"
|
||||||
|
},
|
||||||
|
"9.9.27-45627": {
|
||||||
|
"appid": 537340060,
|
||||||
|
"qua": "V1_WIN_NQ_9.9.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": {
|
"9.9.26-44725-x64": {
|
||||||
"send": "0A18D0C",
|
"send": "0A18D0C",
|
||||||
"recv": "1D4BF0D"
|
"recv": "1D4BF0D"
|
||||||
|
},
|
||||||
|
"9.9.27-45627-x64": {
|
||||||
|
"send": "0A697CC",
|
||||||
|
"recv": "1E86AC1"
|
||||||
|
},
|
||||||
|
"6.9.88-44725-x64": {
|
||||||
|
"send": "2756EF6",
|
||||||
|
"recv": "0A36152"
|
||||||
|
},
|
||||||
|
"6.9.88-44725-arm64": {
|
||||||
|
"send": "2313C68",
|
||||||
|
"recv": "09693E4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
12
packages/napcat-core/external/packet.json
vendored
12
packages/napcat-core/external/packet.json
vendored
@@ -662,5 +662,17 @@
|
|||||||
"9.9.26-44725-x64": {
|
"9.9.26-44725-x64": {
|
||||||
"send": "2CEBB20",
|
"send": "2CEBB20",
|
||||||
"recv": "2CEF0A0"
|
"recv": "2CEF0A0"
|
||||||
|
},
|
||||||
|
"9.9.27-45627-x64": {
|
||||||
|
"send": "2E59CC0",
|
||||||
|
"recv": "2E5D240"
|
||||||
|
},
|
||||||
|
"6.9.88-44725-x64": {
|
||||||
|
"send": "451FE90",
|
||||||
|
"recv": "4522A40"
|
||||||
|
},
|
||||||
|
"6.9.88-44725-arm64": {
|
||||||
|
"send": "3D79168",
|
||||||
|
"recv": "3D7BA78"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,14 @@ import * as crypto from 'node:crypto';
|
|||||||
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
||||||
|
|
||||||
interface ForwardMsgJson {
|
interface ForwardMsgJson {
|
||||||
app: string
|
app: string;
|
||||||
config: ForwardMsgJsonConfig,
|
config: ForwardMsgJsonConfig,
|
||||||
desc: string,
|
desc: string,
|
||||||
extra: ForwardMsgJsonExtra,
|
extra: ForwardMsgJsonExtra,
|
||||||
meta: ForwardMsgJsonMeta,
|
meta: ForwardMsgJsonMeta,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
ver: string,
|
ver: string,
|
||||||
view: string
|
view: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ForwardMsgJsonConfig {
|
interface ForwardMsgJsonConfig {
|
||||||
@@ -17,7 +17,7 @@ interface ForwardMsgJsonConfig {
|
|||||||
forward: number,
|
forward: number,
|
||||||
round: number,
|
round: number,
|
||||||
type: string,
|
type: string,
|
||||||
width: number
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ForwardMsgJsonExtra {
|
interface ForwardMsgJsonExtra {
|
||||||
@@ -26,17 +26,17 @@ interface ForwardMsgJsonExtra {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ForwardMsgJsonMeta {
|
interface ForwardMsgJsonMeta {
|
||||||
detail: ForwardMsgJsonMetaDetail
|
detail: ForwardMsgJsonMetaDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ForwardMsgJsonMetaDetail {
|
interface ForwardMsgJsonMetaDetail {
|
||||||
news: {
|
news: {
|
||||||
text: string
|
text: string;
|
||||||
}[],
|
}[],
|
||||||
resid: string,
|
resid: string,
|
||||||
source: string,
|
source: string,
|
||||||
summary: string,
|
summary: string,
|
||||||
uniseq: string
|
uniseq: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ForwardAdaptMsg {
|
interface ForwardAdaptMsg {
|
||||||
@@ -50,8 +50,8 @@ interface ForwardAdaptMsgElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ForwardMsgBuilder {
|
export class ForwardMsgBuilder {
|
||||||
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||||
const id = crypto.randomUUID();
|
const id = uuid ?? crypto.randomUUID();
|
||||||
const isGroupMsg = msg.some(m => m.isGroupMsg);
|
const isGroupMsg = msg.some(m => m.isGroupMsg);
|
||||||
if (!source) {
|
if (!source) {
|
||||||
source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录');
|
source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录');
|
||||||
@@ -104,13 +104,19 @@ export class ForwardMsgBuilder {
|
|||||||
return this.build(resId, []);
|
return this.build(resId, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||||
return this.build(resId, packetMsg.map(msg => ({
|
return this.build(resId, packetMsg.map(msg => ({
|
||||||
senderName: msg.senderName,
|
senderName: msg.senderName,
|
||||||
isGroupMsg: msg.groupId !== undefined,
|
isGroupMsg: msg.groupId !== undefined,
|
||||||
msg: msg.msg.map(m => ({
|
msg: msg.msg.map(m => ({
|
||||||
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
|
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
|
||||||
})),
|
})),
|
||||||
})), source, news, summary, prompt);
|
})),
|
||||||
|
source,
|
||||||
|
news,
|
||||||
|
summary,
|
||||||
|
prompt,
|
||||||
|
uuid,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { OidbPacket } from '@/napcat-core/packet/transformer/base';
|
|||||||
import { ImageOcrResult } from '@/napcat-core/packet/entities/ocrResult';
|
import { ImageOcrResult } from '@/napcat-core/packet/entities/ocrResult';
|
||||||
import { gunzipSync } from 'zlib';
|
import { gunzipSync } from 'zlib';
|
||||||
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
|
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
|
||||||
|
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
|
||||||
|
|
||||||
export class PacketOperationContext {
|
export class PacketOperationContext {
|
||||||
private readonly context: PacketContext;
|
private readonly context: PacketContext;
|
||||||
@@ -26,7 +27,7 @@ export class PacketOperationContext {
|
|||||||
this.context = context;
|
this.context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendPacket<T extends boolean = false>(pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
|
async sendPacket<T extends boolean = false> (pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
|
||||||
return await this.context.client.sendOidbPacket(pkt, rsp);
|
return await this.context.client.sendOidbPacket(pkt, rsp);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,12 +95,15 @@ export class PacketOperationContext {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
);
|
);
|
||||||
const res = await Promise.allSettled(reqList);
|
const res = await Promise.allSettled(reqList);
|
||||||
this.context.logger.info(`上传资源${res.length}个,失败${res.filter((r) => r.status === 'rejected').length}个`);
|
const failedCount = res.filter((r) => r.status === 'rejected').length;
|
||||||
res.forEach((result, index) => {
|
if (failedCount > 0) {
|
||||||
if (result.status === 'rejected') {
|
this.context.logger.warn(`上传资源${res.length}个,失败${failedCount}个`);
|
||||||
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
|
res.forEach((result, index) => {
|
||||||
}
|
if (result.status === 'rejected') {
|
||||||
});
|
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async UploadImage (img: PacketMsgPicElement) {
|
async UploadImage (img: PacketMsgPicElement) {
|
||||||
@@ -224,7 +228,15 @@ export class PacketOperationContext {
|
|||||||
const res = trans.UploadForwardMsg.parse(resp);
|
const res = trans.UploadForwardMsg.parse(resp);
|
||||||
return res.result.resId;
|
return res.result.resId;
|
||||||
}
|
}
|
||||||
|
async UploadForwardMsgV2 (msg: UploadForwardMsgParams[], groupUin: number = 0) {
|
||||||
|
//await this.SendPreprocess(msg, groupUin);
|
||||||
|
// 遍历上传资源
|
||||||
|
await Promise.allSettled(msg.map(async (item) => { return await this.SendPreprocess(item.actionMsg, groupUin); }));
|
||||||
|
const req = trans.UploadForwardMsgV2.build(this.context.napcore.basicInfo.uid, msg, groupUin);
|
||||||
|
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||||
|
const res = trans.UploadForwardMsg.parse(resp);
|
||||||
|
return res.result.resId;
|
||||||
|
}
|
||||||
async MoveGroupFile (
|
async MoveGroupFile (
|
||||||
groupUin: number,
|
groupUin: number,
|
||||||
fileUUID: string,
|
fileUUID: string,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import zlib from 'node:zlib';
|
||||||
|
import * as proto from '@/napcat-core/packet/transformer/proto';
|
||||||
|
import { NapProtoMsg } from 'napcat-protobuf';
|
||||||
|
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/napcat-core/packet/transformer/base';
|
||||||
|
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
||||||
|
|
||||||
|
export interface UploadForwardMsgParams {
|
||||||
|
actionCommand: string;
|
||||||
|
actionMsg: PacketMsg[];
|
||||||
|
}
|
||||||
|
class UploadForwardMsgV2 extends PacketTransformer<typeof proto.SendLongMsgResp> {
|
||||||
|
build (selfUid: string, msg: UploadForwardMsgParams[], groupUin: number = 0): OidbPacket {
|
||||||
|
const reqdata = msg.map((item) => ({
|
||||||
|
actionCommand: item.actionCommand,
|
||||||
|
actionData: {
|
||||||
|
msgBody: this.msgBuilder.buildFakeMsg(selfUid, item.actionMsg),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const longMsgResultData = new NapProtoMsg(proto.LongMsgResult).encode(
|
||||||
|
{
|
||||||
|
action: reqdata,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const payload = zlib.gzipSync(Buffer.from(longMsgResultData));
|
||||||
|
const req = new NapProtoMsg(proto.SendLongMsgReq).encode(
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
type: groupUin === 0 ? 1 : 3,
|
||||||
|
uid: {
|
||||||
|
uid: groupUin === 0 ? selfUid : groupUin.toString(),
|
||||||
|
},
|
||||||
|
groupUin,
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
field1: 4, field2: 1, field3: 7, field4: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
cmd: 'trpc.group.long_msg_interface.MsgService.SsoSendLongMsg',
|
||||||
|
data: PacketBufBuilder(req),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
parse (data: Buffer) {
|
||||||
|
return new NapProtoMsg(proto.SendLongMsgResp).decode(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new UploadForwardMsgV2();
|
||||||
@@ -2,3 +2,4 @@ export { default as UploadForwardMsg } from './UploadForwardMsg';
|
|||||||
export { default as FetchGroupMessage } from './FetchGroupMessage';
|
export { default as FetchGroupMessage } from './FetchGroupMessage';
|
||||||
export { default as FetchC2CMessage } from './FetchC2CMessage';
|
export { default as FetchC2CMessage } from './FetchC2CMessage';
|
||||||
export { default as DownloadForwardMsg } from './DownloadForwardMsg';
|
export { default as DownloadForwardMsg } from './DownloadForwardMsg';
|
||||||
|
export { default as UploadForwardMsgV2 } from './UploadForwardMsgV2';
|
||||||
4
packages/napcat-develop/config/.env
Normal file
4
packages/napcat-develop/config/.env
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_WORKDIR = TARGET_DIR;
|
||||||
// 开发环境使用固定密钥
|
// 开发环境使用固定密钥
|
||||||
process.env.NAPCAT_WEBUI_JWT_SECRET_KEY = 'napcat_dev_secret_key';
|
process.env.NAPCAT_WEBUI_JWT_SECRET_KEY = 'napcat_dev_secret_key';
|
||||||
process.env.NAPCAT_WEBUI_SECRET_KEY = 'napcat';
|
process.env.NAPCAT_WEBUI_SECRET_KEY = 'napcatqq';
|
||||||
console.log('Loading NapCat module...');
|
console.log('Loading NapCat module...');
|
||||||
await import(pathToFileURL(NAPCAT_MJS_PATH).href);
|
await import(pathToFileURL(NAPCAT_MJS_PATH).href);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "napcat-develop",
|
"name": "napcat-develop",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "powershell ./nodeTest.ps1"
|
"dev": "powershell ./nodeTest.ps1",
|
||||||
|
"copy-env": "xcopy config ..\\napcat-shell\\dist\\config /E /I /Y"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"require": "./index.js"
|
||||||
},
|
},
|
||||||
"exports": {
|
"./*": {
|
||||||
".": {
|
"require": "./*"
|
||||||
"require": "./index.js"
|
|
||||||
},
|
|
||||||
"./*": {
|
|
||||||
"require": "./*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"fs-extra": "^11.3.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"fs-extra": "^11.3.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ const FrameworkBaseConfig = () =>
|
|||||||
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
||||||
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
||||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||||
'@/image-size': resolve(__dirname, '../image-size'),
|
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||||
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
||||||
'@/napcat-adapter': resolve(__dirname, '../napcat-adapter'),
|
'@/napcat-adapter': resolve(__dirname, '../napcat-adapter'),
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
packages/napcat-image-size/resource/test-20x20.jpg
Normal file
BIN
packages/napcat-image-size/resource/test-20x20.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
packages/napcat-image-size/resource/test-20x20.png
Normal file
BIN
packages/napcat-image-size/resource/test-20x20.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
packages/napcat-image-size/resource/test-20x20.tiff
Normal file
BIN
packages/napcat-image-size/resource/test-20x20.tiff
Normal file
Binary file not shown.
BIN
packages/napcat-image-size/resource/test-20x20.webp
Normal file
BIN
packages/napcat-image-size/resource/test-20x20.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 962 B |
BIN
packages/napcat-image-size/resource/test-490x498.gif
Normal file
BIN
packages/napcat-image-size/resource/test-490x498.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -1,5 +1,12 @@
|
|||||||
|
import { BmpParser } from '@/napcat-image-size/src/parser/BmpParser';
|
||||||
|
import { GifParser } from '@/napcat-image-size/src/parser/GifParser';
|
||||||
|
import { JpegParser } from '@/napcat-image-size/src/parser/JpegParser';
|
||||||
|
import { PngParser } from '@/napcat-image-size/src/parser/PngParser';
|
||||||
|
import { TiffParser } from '@/napcat-image-size/src/parser/TiffParser';
|
||||||
|
import { WebpParser } from '@/napcat-image-size/src/parser/WebpParser';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { ReadStream } from 'fs';
|
import { ReadStream } from 'fs';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
export interface ImageSize {
|
export interface ImageSize {
|
||||||
width: number;
|
width: number;
|
||||||
@@ -12,17 +19,18 @@ export enum ImageType {
|
|||||||
BMP = 'bmp',
|
BMP = 'bmp',
|
||||||
GIF = 'gif',
|
GIF = 'gif',
|
||||||
WEBP = 'webp',
|
WEBP = 'webp',
|
||||||
|
TIFF = 'tiff',
|
||||||
UNKNOWN = 'unknown',
|
UNKNOWN = 'unknown',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImageParser {
|
export interface ImageParser {
|
||||||
readonly type: ImageType;
|
readonly type: ImageType;
|
||||||
canParse(buffer: Buffer): boolean;
|
canParse (buffer: Buffer): boolean;
|
||||||
parseSize(stream: ReadStream): Promise<ImageSize | undefined>;
|
parseSize (stream: ReadStream): Promise<ImageSize | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 魔术匹配
|
// 魔术匹配
|
||||||
function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
|
export function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
|
||||||
if (buffer.length < offset + magic.length) {
|
if (buffer.length < offset + magic.length) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -35,316 +43,39 @@ function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PNG解析器
|
// 所有解析器实例
|
||||||
class PngParser implements ImageParser {
|
const parserInstances = {
|
||||||
readonly type = ImageType.PNG;
|
png: new PngParser(),
|
||||||
// PNG 魔术头:89 50 4E 47 0D 0A 1A 0A
|
jpeg: new JpegParser(),
|
||||||
private readonly PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
bmp: new BmpParser(),
|
||||||
|
gif: new GifParser(),
|
||||||
|
webp: new WebpParser(),
|
||||||
|
tiff: new TiffParser(),
|
||||||
|
};
|
||||||
|
|
||||||
canParse (buffer: Buffer): boolean {
|
// 首字节到可能的图片类型映射,用于快速筛选
|
||||||
return matchMagic(buffer, this.PNG_SIGNATURE);
|
const firstByteMap = new Map<number, ImageType[]>([
|
||||||
}
|
[0x42, [ImageType.BMP]], // 'B' - BMP
|
||||||
|
[0x47, [ImageType.GIF]], // 'G' - GIF
|
||||||
|
[0x49, [ImageType.TIFF]], // 'I' - TIFF (II - little endian)
|
||||||
|
[0x4D, [ImageType.TIFF]], // 'M' - TIFF (MM - big endian)
|
||||||
|
[0x52, [ImageType.WEBP]], // 'R' - RIFF (WebP)
|
||||||
|
[0x89, [ImageType.PNG]], // PNG signature
|
||||||
|
[0xFF, [ImageType.JPEG]], // JPEG SOI
|
||||||
|
]);
|
||||||
|
|
||||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
// 类型到解析器的映射
|
||||||
return new Promise((resolve, reject) => {
|
const typeToParser = new Map<ImageType, ImageParser>([
|
||||||
stream.once('error', reject);
|
[ImageType.PNG, parserInstances.png],
|
||||||
stream.once('readable', () => {
|
[ImageType.JPEG, parserInstances.jpeg],
|
||||||
const buf = stream.read(24) as Buffer;
|
[ImageType.BMP, parserInstances.bmp],
|
||||||
if (!buf || buf.length < 24) {
|
[ImageType.GIF, parserInstances.gif],
|
||||||
return resolve(undefined);
|
[ImageType.WEBP, parserInstances.webp],
|
||||||
}
|
[ImageType.TIFF, parserInstances.tiff],
|
||||||
if (this.canParse(buf)) {
|
]);
|
||||||
const width = buf.readUInt32BE(16);
|
|
||||||
const height = buf.readUInt32BE(20);
|
|
||||||
resolve({ width, height });
|
|
||||||
} else {
|
|
||||||
resolve(undefined);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// JPEG解析器
|
// 所有解析器列表(用于回退)
|
||||||
class JpegParser implements ImageParser {
|
const parsers: ReadonlyArray<ImageParser> = Object.values(parserInstances);
|
||||||
readonly type = ImageType.JPEG;
|
|
||||||
// JPEG 魔术头:FF D8
|
|
||||||
private readonly JPEG_SIGNATURE = [0xFF, 0xD8];
|
|
||||||
|
|
||||||
// JPEG标记常量
|
|
||||||
private readonly SOF_MARKERS = {
|
|
||||||
SOF0: 0xC0, // 基线DCT
|
|
||||||
SOF1: 0xC1, // 扩展顺序DCT
|
|
||||||
SOF2: 0xC2, // 渐进式DCT
|
|
||||||
SOF3: 0xC3, // 无损
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// 非SOF标记
|
|
||||||
private readonly NON_SOF_MARKERS: number[] = [
|
|
||||||
0xC4, // DHT
|
|
||||||
0xC8, // JPEG扩展
|
|
||||||
0xCC, // DAC
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
canParse (buffer: Buffer): boolean {
|
|
||||||
return matchMagic(buffer, this.JPEG_SIGNATURE);
|
|
||||||
}
|
|
||||||
|
|
||||||
isSOFMarker (marker: number): boolean {
|
|
||||||
return (
|
|
||||||
marker === this.SOF_MARKERS.SOF0 ||
|
|
||||||
marker === this.SOF_MARKERS.SOF1 ||
|
|
||||||
marker === this.SOF_MARKERS.SOF2 ||
|
|
||||||
marker === this.SOF_MARKERS.SOF3
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
isNonSOFMarker (marker: number): boolean {
|
|
||||||
return this.NON_SOF_MARKERS.includes(marker);
|
|
||||||
}
|
|
||||||
|
|
||||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
|
||||||
return new Promise<ImageSize | undefined>((resolve, reject) => {
|
|
||||||
const BUFFER_SIZE = 1024; // 读取块大小,可以根据需要调整
|
|
||||||
let buffer = Buffer.alloc(0);
|
|
||||||
let offset = 0;
|
|
||||||
let found = false;
|
|
||||||
|
|
||||||
// 处理错误
|
|
||||||
stream.on('error', (err) => {
|
|
||||||
stream.destroy();
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 处理数据块
|
|
||||||
stream.on('data', (chunk: Buffer | string) => {
|
|
||||||
// 追加新数据到缓冲区
|
|
||||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
||||||
buffer = Buffer.concat([buffer.subarray(offset), chunkBuffer]);
|
|
||||||
offset = 0;
|
|
||||||
|
|
||||||
// 保持缓冲区在合理大小内,只保留最后的部分用于跨块匹配
|
|
||||||
const bufferSize = buffer.length;
|
|
||||||
const MIN_REQUIRED_BYTES = 10; // SOF段最低字节数
|
|
||||||
|
|
||||||
// 从JPEG头部后开始扫描
|
|
||||||
while (offset < bufferSize - MIN_REQUIRED_BYTES) {
|
|
||||||
// 寻找FF标记
|
|
||||||
if (buffer[offset] === 0xFF && buffer[offset + 1]! >= 0xC0 && buffer[offset + 1]! <= 0xCF) {
|
|
||||||
const marker = buffer[offset + 1];
|
|
||||||
if (!marker) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// 跳过非SOF标记
|
|
||||||
if (this.isNonSOFMarker(marker)) {
|
|
||||||
offset += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理SOF标记 (包含尺寸信息)
|
|
||||||
if (this.isSOFMarker(marker)) {
|
|
||||||
// 确保缓冲区中有足够数据读取尺寸
|
|
||||||
if (offset + 9 < bufferSize) {
|
|
||||||
// 解析尺寸: FF XX YY YY PP HH HH WW WW ...
|
|
||||||
// XX = 标记, YY YY = 段长度, PP = 精度, HH HH = 高, WW WW = 宽
|
|
||||||
const height = buffer.readUInt16BE(offset + 5);
|
|
||||||
const width = buffer.readUInt16BE(offset + 7);
|
|
||||||
|
|
||||||
found = true;
|
|
||||||
stream.destroy();
|
|
||||||
resolve({ width, height });
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
// 如果缓冲区内数据不够,保留当前位置等待更多数据
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
offset++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 缓冲区管理: 如果处理了许多数据但没找到标记,
|
|
||||||
// 保留最后N字节用于跨块匹配,丢弃之前的数据
|
|
||||||
if (offset > BUFFER_SIZE) {
|
|
||||||
const KEEP_BYTES = 20; // 保留足够数据以处理跨块边界的情况
|
|
||||||
if (offset > KEEP_BYTES) {
|
|
||||||
buffer = buffer.subarray(offset - KEEP_BYTES);
|
|
||||||
offset = KEEP_BYTES;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 处理流结束
|
|
||||||
stream.on('end', () => {
|
|
||||||
if (!found) {
|
|
||||||
resolve(undefined);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BMP解析器
|
|
||||||
class BmpParser implements ImageParser {
|
|
||||||
readonly type = ImageType.BMP;
|
|
||||||
// BMP 魔术头:42 4D (BM)
|
|
||||||
private readonly BMP_SIGNATURE = [0x42, 0x4D];
|
|
||||||
|
|
||||||
canParse (buffer: Buffer): boolean {
|
|
||||||
return matchMagic(buffer, this.BMP_SIGNATURE);
|
|
||||||
}
|
|
||||||
|
|
||||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
stream.once('error', reject);
|
|
||||||
stream.once('readable', () => {
|
|
||||||
const buf = stream.read(26) as Buffer;
|
|
||||||
if (!buf || buf.length < 26) {
|
|
||||||
return resolve(undefined);
|
|
||||||
}
|
|
||||||
if (this.canParse(buf)) {
|
|
||||||
const width = buf.readUInt32LE(18);
|
|
||||||
const height = buf.readUInt32LE(22);
|
|
||||||
resolve({ width, height });
|
|
||||||
} else {
|
|
||||||
resolve(undefined);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GIF解析器
|
|
||||||
class GifParser implements ImageParser {
|
|
||||||
readonly type = ImageType.GIF;
|
|
||||||
// GIF87a 魔术头:47 49 46 38 37 61
|
|
||||||
private readonly GIF87A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
|
|
||||||
// GIF89a 魔术头:47 49 46 38 39 61
|
|
||||||
private readonly GIF89A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
|
|
||||||
|
|
||||||
canParse (buffer: Buffer): boolean {
|
|
||||||
return (
|
|
||||||
matchMagic(buffer, this.GIF87A_SIGNATURE) ||
|
|
||||||
matchMagic(buffer, this.GIF89A_SIGNATURE)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
stream.once('error', reject);
|
|
||||||
stream.once('readable', () => {
|
|
||||||
const buf = stream.read(10) as Buffer;
|
|
||||||
if (!buf || buf.length < 10) {
|
|
||||||
return resolve(undefined);
|
|
||||||
}
|
|
||||||
if (this.canParse(buf)) {
|
|
||||||
const width = buf.readUInt16LE(6);
|
|
||||||
const height = buf.readUInt16LE(8);
|
|
||||||
resolve({ width, height });
|
|
||||||
} else {
|
|
||||||
resolve(undefined);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WEBP解析器 - 完整支持VP8, VP8L, VP8X格式
|
|
||||||
class WebpParser implements ImageParser {
|
|
||||||
readonly type = ImageType.WEBP;
|
|
||||||
// WEBP RIFF 头:52 49 46 46 (RIFF)
|
|
||||||
private readonly RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46];
|
|
||||||
// WEBP 魔术头:57 45 42 50 (WEBP)
|
|
||||||
private readonly WEBP_SIGNATURE = [0x57, 0x45, 0x42, 0x50];
|
|
||||||
|
|
||||||
// WEBP 块头
|
|
||||||
private readonly CHUNK_VP8 = [0x56, 0x50, 0x38, 0x20]; // "VP8 "
|
|
||||||
private readonly CHUNK_VP8L = [0x56, 0x50, 0x38, 0x4C]; // "VP8L"
|
|
||||||
private readonly CHUNK_VP8X = [0x56, 0x50, 0x38, 0x58]; // "VP8X"
|
|
||||||
|
|
||||||
canParse (buffer: Buffer): boolean {
|
|
||||||
return (
|
|
||||||
buffer.length >= 12 &&
|
|
||||||
matchMagic(buffer, this.RIFF_SIGNATURE, 0) &&
|
|
||||||
matchMagic(buffer, this.WEBP_SIGNATURE, 8)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
isChunkType (buffer: Buffer, offset: number, chunkType: number[]): boolean {
|
|
||||||
return buffer.length >= offset + 4 && matchMagic(buffer, chunkType, offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// 需要读取足够的字节来检测所有三种格式
|
|
||||||
const MAX_HEADER_SIZE = 32;
|
|
||||||
let totalBytes = 0;
|
|
||||||
let buffer = Buffer.alloc(0);
|
|
||||||
|
|
||||||
stream.on('error', reject);
|
|
||||||
|
|
||||||
stream.on('data', (chunk: Buffer | string) => {
|
|
||||||
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
||||||
buffer = Buffer.concat([buffer, chunkBuffer]);
|
|
||||||
totalBytes += chunk.length;
|
|
||||||
|
|
||||||
// 检查是否有足够的字节进行格式检测
|
|
||||||
if (totalBytes >= MAX_HEADER_SIZE) {
|
|
||||||
stream.destroy();
|
|
||||||
|
|
||||||
// 检查基本的WEBP签名
|
|
||||||
if (!this.canParse(buffer)) {
|
|
||||||
return resolve(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查chunk头部,位于字节12-15
|
|
||||||
if (this.isChunkType(buffer, 12, this.CHUNK_VP8)) {
|
|
||||||
// VP8格式 - 标准WebP
|
|
||||||
// 宽度和高度在帧头中
|
|
||||||
const width = buffer.readUInt16LE(26) & 0x3FFF;
|
|
||||||
const height = buffer.readUInt16LE(28) & 0x3FFF;
|
|
||||||
return resolve({ width, height });
|
|
||||||
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8L)) {
|
|
||||||
// VP8L格式 - 无损WebP
|
|
||||||
// 1字节标记后是14位宽度和14位高度
|
|
||||||
const bits = buffer.readUInt32LE(21);
|
|
||||||
const width = 1 + (bits & 0x3FFF);
|
|
||||||
const height = 1 + ((bits >> 14) & 0x3FFF);
|
|
||||||
return resolve({ width, height });
|
|
||||||
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8X)) {
|
|
||||||
// VP8X格式 - 扩展WebP
|
|
||||||
// 24位宽度和高度(减去1)
|
|
||||||
if (!buffer[24] || !buffer[25] || !buffer[26] || !buffer[27] || !buffer[28] || !buffer[29]) {
|
|
||||||
return resolve(undefined);
|
|
||||||
}
|
|
||||||
const width = 1 + ((buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) & 0xFFFFFF);
|
|
||||||
const height = 1 + ((buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) & 0xFFFFFF);
|
|
||||||
return resolve({ width, height });
|
|
||||||
} else {
|
|
||||||
// 未知的WebP子格式
|
|
||||||
return resolve(undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('end', () => {
|
|
||||||
// 如果没有读到足够的字节
|
|
||||||
if (totalBytes < MAX_HEADER_SIZE) {
|
|
||||||
resolve(undefined);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsers: ReadonlyArray<ImageParser> = [
|
|
||||||
new PngParser(),
|
|
||||||
new JpegParser(),
|
|
||||||
new BmpParser(),
|
|
||||||
new GifParser(),
|
|
||||||
new WebpParser(),
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function detectImageType (filePath: string): Promise<ImageType> {
|
export async function detectImageType (filePath: string): Promise<ImageType> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -354,18 +85,22 @@ export async function detectImageType (filePath: string): Promise<ImageType> {
|
|||||||
end: 63,
|
end: 63,
|
||||||
});
|
});
|
||||||
|
|
||||||
let buffer: Buffer | null = null;
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
stream.once('error', (err) => {
|
stream.on('error', (err) => {
|
||||||
stream.destroy();
|
stream.destroy();
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.once('readable', () => {
|
stream.on('data', (chunk: Buffer | string) => {
|
||||||
buffer = stream.read(64) as Buffer;
|
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
stream.destroy();
|
chunks.push(chunkBuffer);
|
||||||
|
});
|
||||||
|
|
||||||
if (!buffer) {
|
stream.on('end', () => {
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
if (buffer.length === 0) {
|
||||||
return resolve(ImageType.UNKNOWN);
|
return resolve(ImageType.UNKNOWN);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,12 +112,6 @@ export async function detectImageType (filePath: string): Promise<ImageType> {
|
|||||||
|
|
||||||
resolve(ImageType.UNKNOWN);
|
resolve(ImageType.UNKNOWN);
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.once('end', () => {
|
|
||||||
if (!buffer) {
|
|
||||||
resolve(ImageType.UNKNOWN);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +119,7 @@ export async function imageSizeFromFile (filePath: string): Promise<ImageSize |
|
|||||||
try {
|
try {
|
||||||
// 先检测类型
|
// 先检测类型
|
||||||
const type = await detectImageType(filePath);
|
const type = await detectImageType(filePath);
|
||||||
const parser = parsers.find(p => p.type === type);
|
const parser = typeToParser.get(type);
|
||||||
if (!parser) {
|
if (!parser) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -422,3 +151,71 @@ export async function imageSizeFallBack (
|
|||||||
): Promise<ImageSize> {
|
): Promise<ImageSize> {
|
||||||
return await imageSizeFromFile(filePath) ?? fallback;
|
return await imageSizeFromFile(filePath) ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 Buffer 创建可读流
|
||||||
|
function bufferToReadStream (buffer: Buffer): ReadStream {
|
||||||
|
const readable = new Readable({
|
||||||
|
read () {
|
||||||
|
this.push(buffer);
|
||||||
|
this.push(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return readable as unknown as ReadStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Buffer 检测图片类型(使用首字节快速筛选)
|
||||||
|
export function detectImageTypeFromBuffer (buffer: Buffer): ImageType {
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
return ImageType.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstByte = buffer[0]!;
|
||||||
|
const possibleTypes = firstByteMap.get(firstByte);
|
||||||
|
|
||||||
|
if (possibleTypes) {
|
||||||
|
// 根据首字节快速筛选可能的类型
|
||||||
|
for (const type of possibleTypes) {
|
||||||
|
const parser = typeToParser.get(type);
|
||||||
|
if (parser && parser.canParse(buffer)) {
|
||||||
|
return parser.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:遍历所有解析器
|
||||||
|
for (const parser of parsers) {
|
||||||
|
if (parser.canParse(buffer)) {
|
||||||
|
return parser.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImageType.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Buffer 解析图片尺寸
|
||||||
|
export async function imageSizeFromBuffer (buffer: Buffer): Promise<ImageSize | undefined> {
|
||||||
|
const type = detectImageTypeFromBuffer(buffer);
|
||||||
|
const parser = typeToParser.get(type);
|
||||||
|
if (!parser) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = bufferToReadStream(buffer);
|
||||||
|
return await parser.parseSize(stream);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`解析图片尺寸出错: ${err}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Buffer 解析图片尺寸,带回退值
|
||||||
|
export async function imageSizeFromBufferFallBack (
|
||||||
|
buffer: Buffer,
|
||||||
|
fallback: ImageSize = {
|
||||||
|
width: 1024,
|
||||||
|
height: 1024,
|
||||||
|
}
|
||||||
|
): Promise<ImageSize> {
|
||||||
|
return await imageSizeFromBuffer(buffer) ?? fallback;
|
||||||
|
}
|
||||||
|
|||||||
32
packages/napcat-image-size/src/parser/BmpParser.ts
Normal file
32
packages/napcat-image-size/src/parser/BmpParser.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||||
|
import { ReadStream } from 'fs';
|
||||||
|
|
||||||
|
// BMP解析器
|
||||||
|
export class BmpParser implements ImageParser {
|
||||||
|
readonly type = ImageType.BMP;
|
||||||
|
// BMP 魔术头:42 4D (BM)
|
||||||
|
private readonly BMP_SIGNATURE = [0x42, 0x4D];
|
||||||
|
|
||||||
|
canParse (buffer: Buffer): boolean {
|
||||||
|
return matchMagic(buffer, this.BMP_SIGNATURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
stream.once('error', reject);
|
||||||
|
stream.once('readable', () => {
|
||||||
|
const buf = stream.read(26) as Buffer;
|
||||||
|
if (!buf || buf.length < 26) {
|
||||||
|
return resolve(undefined);
|
||||||
|
}
|
||||||
|
if (this.canParse(buf)) {
|
||||||
|
const width = buf.readUInt32LE(18);
|
||||||
|
const height = buf.readUInt32LE(22);
|
||||||
|
resolve({ width, height });
|
||||||
|
} else {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
37
packages/napcat-image-size/src/parser/GifParser.ts
Normal file
37
packages/napcat-image-size/src/parser/GifParser.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||||
|
import { ReadStream } from 'fs';
|
||||||
|
|
||||||
|
// GIF解析器
|
||||||
|
export class GifParser implements ImageParser {
|
||||||
|
readonly type = ImageType.GIF;
|
||||||
|
// GIF87a 魔术头:47 49 46 38 37 61
|
||||||
|
private readonly GIF87A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
|
||||||
|
// GIF89a 魔术头:47 49 46 38 39 61
|
||||||
|
private readonly GIF89A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
|
||||||
|
|
||||||
|
canParse (buffer: Buffer): boolean {
|
||||||
|
return (
|
||||||
|
matchMagic(buffer, this.GIF87A_SIGNATURE) ||
|
||||||
|
matchMagic(buffer, this.GIF89A_SIGNATURE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
stream.once('error', reject);
|
||||||
|
stream.once('readable', () => {
|
||||||
|
const buf = stream.read(10) as Buffer;
|
||||||
|
if (!buf || buf.length < 10) {
|
||||||
|
return resolve(undefined);
|
||||||
|
}
|
||||||
|
if (this.canParse(buf)) {
|
||||||
|
const width = buf.readUInt16LE(6);
|
||||||
|
const height = buf.readUInt16LE(8);
|
||||||
|
resolve({ width, height });
|
||||||
|
} else {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
123
packages/napcat-image-size/src/parser/JpegParser.ts
Normal file
123
packages/napcat-image-size/src/parser/JpegParser.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||||
|
import { ReadStream } from 'fs';
|
||||||
|
|
||||||
|
// JPEG解析器
|
||||||
|
export class JpegParser implements ImageParser {
|
||||||
|
readonly type = ImageType.JPEG;
|
||||||
|
// JPEG 魔术头:FF D8
|
||||||
|
private readonly JPEG_SIGNATURE = [0xFF, 0xD8];
|
||||||
|
|
||||||
|
// JPEG标记常量
|
||||||
|
private readonly SOF_MARKERS = {
|
||||||
|
SOF0: 0xC0, // 基线DCT
|
||||||
|
SOF1: 0xC1, // 扩展顺序DCT
|
||||||
|
SOF2: 0xC2, // 渐进式DCT
|
||||||
|
SOF3: 0xC3, // 无损
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 非SOF标记
|
||||||
|
private readonly NON_SOF_MARKERS: number[] = [
|
||||||
|
0xC4, // DHT
|
||||||
|
0xC8, // JPEG扩展
|
||||||
|
0xCC, // DAC
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
canParse (buffer: Buffer): boolean {
|
||||||
|
return matchMagic(buffer, this.JPEG_SIGNATURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
isSOFMarker (marker: number): boolean {
|
||||||
|
return (
|
||||||
|
marker === this.SOF_MARKERS.SOF0 ||
|
||||||
|
marker === this.SOF_MARKERS.SOF1 ||
|
||||||
|
marker === this.SOF_MARKERS.SOF2 ||
|
||||||
|
marker === this.SOF_MARKERS.SOF3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isNonSOFMarker (marker: number): boolean {
|
||||||
|
return this.NON_SOF_MARKERS.includes(marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||||
|
return new Promise<ImageSize | undefined>((resolve, reject) => {
|
||||||
|
const BUFFER_SIZE = 1024; // 读取块大小,可以根据需要调整
|
||||||
|
let buffer = Buffer.alloc(0);
|
||||||
|
let offset = 0;
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
// 处理错误
|
||||||
|
stream.on('error', (err) => {
|
||||||
|
stream.destroy();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理数据块
|
||||||
|
stream.on('data', (chunk: Buffer | string) => {
|
||||||
|
// 追加新数据到缓冲区
|
||||||
|
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
buffer = Buffer.concat([buffer.subarray(offset), chunkBuffer]);
|
||||||
|
offset = 0;
|
||||||
|
|
||||||
|
// 保持缓冲区在合理大小内,只保留最后的部分用于跨块匹配
|
||||||
|
const bufferSize = buffer.length;
|
||||||
|
const MIN_REQUIRED_BYTES = 10; // SOF段最低字节数
|
||||||
|
|
||||||
|
|
||||||
|
// 从JPEG头部后开始扫描
|
||||||
|
while (offset < bufferSize - MIN_REQUIRED_BYTES) {
|
||||||
|
// 寻找FF标记
|
||||||
|
if (buffer[offset] === 0xFF && buffer[offset + 1]! >= 0xC0 && buffer[offset + 1]! <= 0xCF) {
|
||||||
|
const marker = buffer[offset + 1];
|
||||||
|
if (!marker) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 跳过非SOF标记
|
||||||
|
if (this.isNonSOFMarker(marker)) {
|
||||||
|
offset += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理SOF标记 (包含尺寸信息)
|
||||||
|
if (this.isSOFMarker(marker)) {
|
||||||
|
// 确保缓冲区中有足够数据读取尺寸
|
||||||
|
if (offset + 9 < bufferSize) {
|
||||||
|
// 解析尺寸: FF XX YY YY PP HH HH WW WW ...
|
||||||
|
// XX = 标记, YY YY = 段长度, PP = 精度, HH HH = 高, WW WW = 宽
|
||||||
|
const height = buffer.readUInt16BE(offset + 5);
|
||||||
|
const width = buffer.readUInt16BE(offset + 7);
|
||||||
|
|
||||||
|
found = true;
|
||||||
|
stream.destroy();
|
||||||
|
resolve({ width, height });
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// 如果缓冲区内数据不够,保留当前位置等待更多数据
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓冲区管理: 如果处理了许多数据但没找到标记,
|
||||||
|
// 保留最后N字节用于跨块匹配,丢弃之前的数据
|
||||||
|
if (offset > BUFFER_SIZE) {
|
||||||
|
const KEEP_BYTES = 20; // 保留足够数据以处理跨块边界的情况
|
||||||
|
if (offset > KEEP_BYTES) {
|
||||||
|
buffer = buffer.subarray(offset - KEEP_BYTES);
|
||||||
|
offset = KEEP_BYTES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理流结束
|
||||||
|
stream.on('end', () => {
|
||||||
|
if (!found) {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
32
packages/napcat-image-size/src/parser/PngParser.ts
Normal file
32
packages/napcat-image-size/src/parser/PngParser.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||||
|
import { ReadStream } from 'fs';
|
||||||
|
|
||||||
|
// PNG解析器
|
||||||
|
export class PngParser implements ImageParser {
|
||||||
|
readonly type = ImageType.PNG;
|
||||||
|
// PNG 魔术头:89 50 4E 47 0D 0A 1A 0A
|
||||||
|
private readonly PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||||
|
|
||||||
|
canParse (buffer: Buffer): boolean {
|
||||||
|
return matchMagic(buffer, this.PNG_SIGNATURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
stream.once('error', reject);
|
||||||
|
stream.once('readable', () => {
|
||||||
|
const buf = stream.read(24) as Buffer;
|
||||||
|
if (!buf || buf.length < 24) {
|
||||||
|
return resolve(undefined);
|
||||||
|
}
|
||||||
|
if (this.canParse(buf)) {
|
||||||
|
const width = buf.readUInt32BE(16);
|
||||||
|
const height = buf.readUInt32BE(20);
|
||||||
|
resolve({ width, height });
|
||||||
|
} else {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
124
packages/napcat-image-size/src/parser/TiffParser.ts
Normal file
124
packages/napcat-image-size/src/parser/TiffParser.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||||
|
import { ReadStream } from 'fs';
|
||||||
|
|
||||||
|
// TIFF解析器
|
||||||
|
export class TiffParser implements ImageParser {
|
||||||
|
readonly type = ImageType.TIFF;
|
||||||
|
// TIFF Little Endian 魔术头:49 49 2A 00 (II)
|
||||||
|
private readonly TIFF_LE_SIGNATURE = [0x49, 0x49, 0x2A, 0x00];
|
||||||
|
// TIFF Big Endian 魔术头:4D 4D 00 2A (MM)
|
||||||
|
private readonly TIFF_BE_SIGNATURE = [0x4D, 0x4D, 0x00, 0x2A];
|
||||||
|
|
||||||
|
canParse (buffer: Buffer): boolean {
|
||||||
|
return (
|
||||||
|
matchMagic(buffer, this.TIFF_LE_SIGNATURE) ||
|
||||||
|
matchMagic(buffer, this.TIFF_BE_SIGNATURE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
const MAX_BYTES = 64 * 1024; // 最多读取 64KB
|
||||||
|
|
||||||
|
stream.on('error', reject);
|
||||||
|
|
||||||
|
stream.on('data', (chunk: Buffer | string) => {
|
||||||
|
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
chunks.push(chunkBuffer);
|
||||||
|
totalBytes += chunkBuffer.length;
|
||||||
|
|
||||||
|
if (totalBytes >= MAX_BYTES) {
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
const size = this.parseTiffSize(buffer);
|
||||||
|
resolve(size);
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('close', () => {
|
||||||
|
if (chunks.length > 0) {
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
const size = this.parseTiffSize(buffer);
|
||||||
|
resolve(size);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseTiffSize (buffer: Buffer): ImageSize | undefined {
|
||||||
|
if (buffer.length < 8) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断字节序
|
||||||
|
const isLittleEndian = buffer[0] === 0x49; // 'I'
|
||||||
|
|
||||||
|
const readUInt16 = isLittleEndian
|
||||||
|
? (offset: number) => buffer.readUInt16LE(offset)
|
||||||
|
: (offset: number) => buffer.readUInt16BE(offset);
|
||||||
|
|
||||||
|
const readUInt32 = isLittleEndian
|
||||||
|
? (offset: number) => buffer.readUInt32LE(offset)
|
||||||
|
: (offset: number) => buffer.readUInt32BE(offset);
|
||||||
|
|
||||||
|
// 获取第一个 IFD 的偏移量
|
||||||
|
const ifdOffset = readUInt32(4);
|
||||||
|
if (ifdOffset + 2 > buffer.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 IFD 条目数量
|
||||||
|
const numEntries = readUInt16(ifdOffset);
|
||||||
|
let width: number | undefined;
|
||||||
|
let height: number | undefined;
|
||||||
|
|
||||||
|
// TIFF 标签
|
||||||
|
const TAG_IMAGE_WIDTH = 0x0100;
|
||||||
|
const TAG_IMAGE_HEIGHT = 0x0101;
|
||||||
|
|
||||||
|
// 遍历 IFD 条目
|
||||||
|
for (let i = 0; i < numEntries; i++) {
|
||||||
|
const entryOffset = ifdOffset + 2 + i * 12;
|
||||||
|
if (entryOffset + 12 > buffer.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = readUInt16(entryOffset);
|
||||||
|
const type = readUInt16(entryOffset + 2);
|
||||||
|
// const count = readUInt32(entryOffset + 4);
|
||||||
|
|
||||||
|
// 根据类型读取值
|
||||||
|
let value: number;
|
||||||
|
if (type === 3) {
|
||||||
|
// SHORT (2 bytes)
|
||||||
|
value = readUInt16(entryOffset + 8);
|
||||||
|
} else if (type === 4) {
|
||||||
|
// LONG (4 bytes)
|
||||||
|
value = readUInt32(entryOffset + 8);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === TAG_IMAGE_WIDTH) {
|
||||||
|
width = value;
|
||||||
|
} else if (tag === TAG_IMAGE_HEIGHT) {
|
||||||
|
height = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width !== undefined && height !== undefined) {
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width !== undefined && height !== undefined) {
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
packages/napcat-image-size/src/parser/WebpParser.ts
Normal file
90
packages/napcat-image-size/src/parser/WebpParser.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
|
||||||
|
import { ReadStream } from 'fs';
|
||||||
|
|
||||||
|
// WEBP解析器 - 完整支持VP8, VP8L, VP8X格式
|
||||||
|
export class WebpParser implements ImageParser {
|
||||||
|
readonly type = ImageType.WEBP;
|
||||||
|
// WEBP RIFF 头:52 49 46 46 (RIFF)
|
||||||
|
private readonly RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46];
|
||||||
|
// WEBP 魔术头:57 45 42 50 (WEBP)
|
||||||
|
private readonly WEBP_SIGNATURE = [0x57, 0x45, 0x42, 0x50];
|
||||||
|
|
||||||
|
// WEBP 块头
|
||||||
|
private readonly CHUNK_VP8 = [0x56, 0x50, 0x38, 0x20]; // "VP8 "
|
||||||
|
private readonly CHUNK_VP8L = [0x56, 0x50, 0x38, 0x4C]; // "VP8L"
|
||||||
|
private readonly CHUNK_VP8X = [0x56, 0x50, 0x38, 0x58]; // "VP8X"
|
||||||
|
|
||||||
|
canParse (buffer: Buffer): boolean {
|
||||||
|
return (
|
||||||
|
buffer.length >= 12 &&
|
||||||
|
matchMagic(buffer, this.RIFF_SIGNATURE, 0) &&
|
||||||
|
matchMagic(buffer, this.WEBP_SIGNATURE, 8)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isChunkType (buffer: Buffer, offset: number, chunkType: number[]): boolean {
|
||||||
|
return buffer.length >= offset + 4 && matchMagic(buffer, chunkType, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 需要读取足够的字节来检测所有三种格式
|
||||||
|
const MAX_HEADER_SIZE = 32;
|
||||||
|
let totalBytes = 0;
|
||||||
|
let buffer = Buffer.alloc(0);
|
||||||
|
|
||||||
|
stream.on('error', reject);
|
||||||
|
|
||||||
|
stream.on('data', (chunk: Buffer | string) => {
|
||||||
|
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
buffer = Buffer.concat([buffer, chunkBuffer]);
|
||||||
|
totalBytes += chunk.length;
|
||||||
|
|
||||||
|
// 检查是否有足够的字节进行格式检测
|
||||||
|
if (totalBytes >= MAX_HEADER_SIZE) {
|
||||||
|
stream.destroy();
|
||||||
|
|
||||||
|
// 检查基本的WEBP签名
|
||||||
|
if (!this.canParse(buffer)) {
|
||||||
|
return resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查chunk头部,位于字节12-15
|
||||||
|
if (this.isChunkType(buffer, 12, this.CHUNK_VP8)) {
|
||||||
|
// VP8格式 - 标准WebP
|
||||||
|
// 宽度和高度在帧头中
|
||||||
|
const width = buffer.readUInt16LE(26) & 0x3FFF;
|
||||||
|
const height = buffer.readUInt16LE(28) & 0x3FFF;
|
||||||
|
return resolve({ width, height });
|
||||||
|
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8L)) {
|
||||||
|
// VP8L格式 - 无损WebP
|
||||||
|
// 1字节标记后是14位宽度和14位高度
|
||||||
|
const bits = buffer.readUInt32LE(21);
|
||||||
|
const width = 1 + (bits & 0x3FFF);
|
||||||
|
const height = 1 + ((bits >> 14) & 0x3FFF);
|
||||||
|
return resolve({ width, height });
|
||||||
|
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8X)) {
|
||||||
|
// VP8X格式 - 扩展WebP
|
||||||
|
// 24位宽度和高度(减去1)
|
||||||
|
if (buffer.length < 30) {
|
||||||
|
return resolve(undefined);
|
||||||
|
}
|
||||||
|
const width = 1 + ((buffer[24]! | (buffer[25]! << 8) | (buffer[26]! << 16)) & 0xFFFFFF);
|
||||||
|
const height = 1 + ((buffer[27]! | (buffer[28]! << 8) | (buffer[29]! << 16)) & 0xFFFFFF);
|
||||||
|
return resolve({ width, height });
|
||||||
|
} else {
|
||||||
|
// 未知的WebP子格式
|
||||||
|
return resolve(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
// 如果没有读到足够的字节
|
||||||
|
if (totalBytes < MAX_HEADER_SIZE) {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import { Type } from '@sinclair/typebox';
|
|||||||
|
|
||||||
export class BotExit extends OneBotAction<void, void> {
|
export class BotExit extends OneBotAction<void, void> {
|
||||||
override actionName = ActionName.Exit;
|
override actionName = ActionName.Exit;
|
||||||
override payloadSchema = Type.Void();
|
override payloadSchema = Type.Object({});
|
||||||
override returnSchema = Type.Void();
|
override returnSchema = Type.Object({});
|
||||||
override actionSummary = '退出登录';
|
override actionSummary = '退出登录';
|
||||||
override actionTags = ['系统扩展'];
|
override actionTags = ['系统扩展'];
|
||||||
override payloadExample = {};
|
override payloadExample = {};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
|||||||
|
|
||||||
export class GetClientkey extends OneBotAction<void, ReturnType> {
|
export class GetClientkey extends OneBotAction<void, ReturnType> {
|
||||||
override actionName = ActionName.GetClientkey;
|
override actionName = ActionName.GetClientkey;
|
||||||
override payloadSchema = Type.Void();
|
override payloadSchema = Type.Object({});
|
||||||
override returnSchema = ReturnSchema;
|
override returnSchema = ReturnSchema;
|
||||||
override actionSummary = '获取ClientKey';
|
override actionSummary = '获取ClientKey';
|
||||||
override actionDescription = '获取当前登录帐号的ClientKey';
|
override actionDescription = '获取当前登录帐号的ClientKey';
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
|||||||
|
|
||||||
export class GetFriendWithCategory extends OneBotAction<void, ReturnType> {
|
export class GetFriendWithCategory extends OneBotAction<void, ReturnType> {
|
||||||
override actionName = ActionName.GetFriendsWithCategory;
|
override actionName = ActionName.GetFriendsWithCategory;
|
||||||
override payloadSchema = Type.Void();
|
override payloadSchema = Type.Object({});
|
||||||
override returnSchema = ReturnSchema;
|
override returnSchema = ReturnSchema;
|
||||||
override actionSummary = '获取带分组的好友列表';
|
override actionSummary = '获取带分组的好友列表';
|
||||||
override actionTags = ['用户扩展'];
|
override actionTags = ['用户扩展'];
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
|||||||
|
|
||||||
export default class GetGroupAddRequest extends OneBotAction<void, ReturnType> {
|
export default class GetGroupAddRequest extends OneBotAction<void, ReturnType> {
|
||||||
override actionName = ActionName.GetGroupIgnoreAddRequest;
|
override actionName = ActionName.GetGroupIgnoreAddRequest;
|
||||||
override payloadSchema = Type.Void();
|
override payloadSchema = Type.Object({});
|
||||||
override returnSchema = ReturnSchema;
|
override returnSchema = ReturnSchema;
|
||||||
override actionSummary = '获取群被忽略的加群请求';
|
override actionSummary = '获取群被忽略的加群请求';
|
||||||
override actionTags = ['群组接口'];
|
override actionTags = ['群组接口'];
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
|||||||
|
|
||||||
export class GetRkey extends GetPacketStatusDepends<void, ReturnType> {
|
export class GetRkey extends GetPacketStatusDepends<void, ReturnType> {
|
||||||
override actionName = ActionName.GetRkey;
|
override actionName = ActionName.GetRkey;
|
||||||
override payloadSchema = Type.Void();
|
override payloadSchema = Type.Object({});
|
||||||
override returnSchema = ReturnSchema;
|
override returnSchema = ReturnSchema;
|
||||||
override actionSummary = '获取 RKey';
|
override actionSummary = '获取 RKey';
|
||||||
override actionTags = ['系统扩展'];
|
override actionTags = ['系统扩展'];
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export class GetRobotUinRange extends OneBotAction<void, ReturnType> {
|
|||||||
override returnExample = [
|
override returnExample = [
|
||||||
{ minUin: '12345678', maxUin: '87654321' }
|
{ minUin: '12345678', maxUin: '87654321' }
|
||||||
];
|
];
|
||||||
override payloadSchema = Type.Void();
|
override payloadSchema = Type.Object({});
|
||||||
override returnSchema = ReturnSchema;
|
override returnSchema = ReturnSchema;
|
||||||
|
|
||||||
async _handle () {
|
async _handle () {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type ReturnType = Static<typeof ReturnSchema>;
|
|||||||
|
|
||||||
export class GetUnidirectionalFriendList extends OneBotAction<void, ReturnType> {
|
export class GetUnidirectionalFriendList extends OneBotAction<void, ReturnType> {
|
||||||
override actionName = ActionName.GetUnidirectionalFriendList;
|
override actionName = ActionName.GetUnidirectionalFriendList;
|
||||||
override payloadSchema = Type.Void();
|
override payloadSchema = Type.Object({});
|
||||||
override returnSchema = ReturnSchema;
|
override returnSchema = ReturnSchema;
|
||||||
override actionSummary = '获取单向好友列表';
|
override actionSummary = '获取单向好友列表';
|
||||||
override actionTags = ['用户扩展'];
|
override actionTags = ['用户扩展'];
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { rawMsgWithSendMsg } from 'napcat-core/packet/message/converter';
|
|||||||
import { Static, Type } from '@sinclair/typebox';
|
import { Static, Type } from '@sinclair/typebox';
|
||||||
import { MsgActionsExamples } from '@/napcat-onebot/action/msg/examples';
|
import { MsgActionsExamples } from '@/napcat-onebot/action/msg/examples';
|
||||||
import { OB11MessageMixTypeSchema } from '@/napcat-onebot/types/message';
|
import { OB11MessageMixTypeSchema } from '@/napcat-onebot/types/message';
|
||||||
|
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
|
||||||
|
|
||||||
export const SendMsgPayloadSchema = Type.Object({
|
export const SendMsgPayloadSchema = Type.Object({
|
||||||
message_type: Type.Optional(Type.Union([Type.Literal('private'), Type.Literal('group')], { description: '消息类型 (private/group)' })),
|
message_type: Type.Optional(Type.Union([Type.Literal('private'), Type.Literal('group')], { description: '消息类型 (private/group)' })),
|
||||||
@@ -211,10 +212,14 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
|||||||
}, dp: number = 0): Promise<{
|
}, dp: number = 0): Promise<{
|
||||||
finallySendElements: SendArkElement,
|
finallySendElements: SendArkElement,
|
||||||
res_id?: string,
|
res_id?: string,
|
||||||
|
uuid?: string,
|
||||||
|
packetMsg: PacketMsg[],
|
||||||
deleteAfterSentFiles: string[],
|
deleteAfterSentFiles: string[],
|
||||||
|
innerPacketMsg?: Array<{ uuid: string, packetMsg: PacketMsg[]; }>;
|
||||||
} | null> {
|
} | null> {
|
||||||
const packetMsg: PacketMsg[] = [];
|
const packetMsg: PacketMsg[] = [];
|
||||||
const delFiles: string[] = [];
|
const delFiles: string[] = [];
|
||||||
|
const innerMsg: Array<{ uuid: string, packetMsg: PacketMsg[]; }> = new Array();
|
||||||
for (const node of messageNodes) {
|
for (const node of messageNodes) {
|
||||||
if (dp >= 3) {
|
if (dp >= 3) {
|
||||||
this.core.context.logger.logWarn('转发消息深度超过3层,将停止解析!');
|
this.core.context.logger.logWarn('转发消息深度超过3层,将停止解析!');
|
||||||
@@ -232,6 +237,13 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
|||||||
}, dp + 1);
|
}, dp + 1);
|
||||||
sendElements = uploadReturnData?.finallySendElements ? [uploadReturnData.finallySendElements] : [];
|
sendElements = uploadReturnData?.finallySendElements ? [uploadReturnData.finallySendElements] : [];
|
||||||
delFiles.push(...(uploadReturnData?.deleteAfterSentFiles || []));
|
delFiles.push(...(uploadReturnData?.deleteAfterSentFiles || []));
|
||||||
|
if (uploadReturnData?.uuid) {
|
||||||
|
innerMsg.push({ uuid: uploadReturnData.uuid, packetMsg: uploadReturnData.packetMsg });
|
||||||
|
uploadReturnData.innerPacketMsg?.forEach(m => {
|
||||||
|
innerMsg.push({ uuid: m.uuid, packetMsg: m.packetMsg });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
const sendElementsCreateReturn = await this.obContext.apis.MsgApi.createSendElements(OB11Data, msgPeer);
|
const sendElementsCreateReturn = await this.obContext.apis.MsgApi.createSendElements(OB11Data, msgPeer);
|
||||||
sendElements = sendElementsCreateReturn.sendElements;
|
sendElements = sendElementsCreateReturn.sendElements;
|
||||||
@@ -273,8 +285,19 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
|||||||
this.core.context.logger.logWarn('handleForwardedNodesPacket 元素为空!');
|
this.core.context.logger.logWarn('handleForwardedNodesPacket 元素为空!');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsg(packetMsg, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
|
const uploadMsgData: UploadForwardMsgParams[] = [{
|
||||||
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt);
|
actionCommand: 'MultiMsg',
|
||||||
|
actionMsg: packetMsg,
|
||||||
|
}];
|
||||||
|
innerMsg.forEach(({ uuid, packetMsg: msg }) => {
|
||||||
|
uploadMsgData.push({
|
||||||
|
actionCommand: uuid,
|
||||||
|
actionMsg: msg,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsgV2(uploadMsgData, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
|
||||||
|
const uuid = crypto.randomUUID();
|
||||||
|
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt, uuid);
|
||||||
return {
|
return {
|
||||||
deleteAfterSentFiles: delFiles,
|
deleteAfterSentFiles: delFiles,
|
||||||
finallySendElements: {
|
finallySendElements: {
|
||||||
@@ -285,6 +308,9 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
|||||||
},
|
},
|
||||||
} as SendArkElement,
|
} as SendArkElement,
|
||||||
res_id: resid,
|
res_id: resid,
|
||||||
|
uuid: uuid,
|
||||||
|
packetMsg: packetMsg,
|
||||||
|
innerPacketMsg: innerMsg,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -316,6 +316,11 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
|||||||
entry = newEntry;
|
entry = newEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!entry.enable) {
|
||||||
|
this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return await this.loadPlugin(entry);
|
return await this.loadPlugin(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,8 +123,8 @@ export class PluginLoader {
|
|||||||
const entryFile = this.findEntryFile(pluginDir, packageJson);
|
const entryFile = this.findEntryFile(pluginDir, packageJson);
|
||||||
const entryPath = entryFile ? path.join(pluginDir, entryFile) : undefined;
|
const entryPath = entryFile ? path.join(pluginDir, entryFile) : undefined;
|
||||||
|
|
||||||
// 获取启用状态(默认启用)
|
// 获取启用状态(默认禁用,内置插件除外)
|
||||||
const enable = statusConfig[pluginId] !== false;
|
const enable = statusConfig[pluginId] ?? (pluginId === 'napcat-plugin-builtin');
|
||||||
|
|
||||||
// 创建插件条目
|
// 创建插件条目
|
||||||
const entry: PluginEntry = {
|
const entry: PluginEntry = {
|
||||||
@@ -159,7 +159,7 @@ export class PluginLoader {
|
|||||||
id: dirname, // 使用目录名作为 ID
|
id: dirname, // 使用目录名作为 ID
|
||||||
fileId: dirname,
|
fileId: dirname,
|
||||||
pluginPath: path.join(this.pluginPath, dirname),
|
pluginPath: path.join(this.pluginPath, dirname),
|
||||||
enable: statusConfig[dirname] !== false,
|
enable: statusConfig[dirname] ?? (dirname === 'napcat-plugin-builtin'),
|
||||||
loaded: false,
|
loaded: false,
|
||||||
runtime: {
|
runtime: {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
|
|||||||
@@ -285,6 +285,11 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
|||||||
entry = newEntry;
|
entry = newEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!entry.enable) {
|
||||||
|
this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return await this.loadPlugin(entry);
|
return await this.loadPlugin(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,33 @@ import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
|
|||||||
import { webUiRuntimePort } from '@/napcat-webui-backend/index';
|
import { webUiRuntimePort } from '@/napcat-webui-backend/index';
|
||||||
import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api';
|
import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
// ES 模块中获取 __dirname
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
const pathWrapper = new NapCatPathWrapper();
|
||||||
|
const envPath = path.join(__dirname, 'config', '.env');
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(envPath, 'utf8');
|
||||||
|
let loadedCount = 0;
|
||||||
|
data.split(/\r?\n/).forEach(line => {
|
||||||
|
line = line.trim();
|
||||||
|
if (line && !line.startsWith('#')) {
|
||||||
|
const parts = line.split('=');
|
||||||
|
const key = parts[0]?.trim();
|
||||||
|
const value = parts.slice(1).join('=').trim();
|
||||||
|
if (key && value) {
|
||||||
|
process.env[key] = value;
|
||||||
|
loadedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[NapCat] Failed to load .env file:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 环境变量配置
|
// 环境变量配置
|
||||||
const ENV = {
|
const ENV = {
|
||||||
@@ -20,6 +42,7 @@ const ENV = {
|
|||||||
isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1',
|
isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
||||||
// Worker 消息类型
|
// Worker 消息类型
|
||||||
interface WorkerMessage {
|
interface WorkerMessage {
|
||||||
type: 'restart' | 'restart-prepare' | 'shutdown';
|
type: 'restart' | 'restart-prepare' | 'shutdown';
|
||||||
@@ -27,8 +50,7 @@ interface WorkerMessage {
|
|||||||
port?: number;
|
port?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化日志
|
|
||||||
const pathWrapper = new NapCatPathWrapper();
|
|
||||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||||
|
|
||||||
// 进程管理器和当前 Worker 进程引用
|
// 进程管理器和当前 Worker 进程引用
|
||||||
@@ -223,21 +245,21 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
|
|||||||
// 如果不是由于主动重启或关闭引起的退出,尝试自动重新拉起
|
// 如果不是由于主动重启或关闭引起的退出,尝试自动重新拉起
|
||||||
if (!isRestarting && !isShuttingDown) {
|
if (!isRestarting && !isShuttingDown) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// 清理超出时间窗口的崩溃记录
|
// 清理超出时间窗口的崩溃记录
|
||||||
while (recentCrashTimestamps.length > 0 && now - recentCrashTimestamps[0]! > CRASH_TIME_WINDOW) {
|
while (recentCrashTimestamps.length > 0 && now - recentCrashTimestamps[0]! > CRASH_TIME_WINDOW) {
|
||||||
recentCrashTimestamps.shift();
|
recentCrashTimestamps.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录本次崩溃
|
// 记录本次崩溃
|
||||||
recentCrashTimestamps.push(now);
|
recentCrashTimestamps.push(now);
|
||||||
|
|
||||||
// 检查是否超过崩溃阈值
|
// 检查是否超过崩溃阈值
|
||||||
if (recentCrashTimestamps.length >= MAX_CRASHES_IN_WINDOW) {
|
if (recentCrashTimestamps.length >= MAX_CRASHES_IN_WINDOW) {
|
||||||
logger.logError(`[NapCat] [${processType}] Worker进程在 ${CRASH_TIME_WINDOW / 1000} 秒内异常退出 ${MAX_CRASHES_IN_WINDOW} 次,主进程退出`);
|
logger.logError(`[NapCat] [${processType}] Worker进程在 ${CRASH_TIME_WINDOW / 1000} 秒内异常退出 ${MAX_CRASHES_IN_WINDOW} 次,主进程退出`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出 (${recentCrashTimestamps.length}/${MAX_CRASHES_IN_WINDOW}),正在尝试重新拉起...`);
|
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出 (${recentCrashTimestamps.length}/${MAX_CRASHES_IN_WINDOW}),正在尝试重新拉起...`);
|
||||||
startWorker(true).catch(e => {
|
startWorker(true).catch(e => {
|
||||||
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
|
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const ShellBaseConfig = (source_map: boolean = false) =>
|
|||||||
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
||||||
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
||||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||||
'@/image-size': resolve(__dirname, '../image-size'),
|
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||||
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
346
packages/napcat-test/imageSize.test.ts
Normal file
346
packages/napcat-test/imageSize.test.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
detectImageTypeFromBuffer,
|
||||||
|
imageSizeFromBuffer,
|
||||||
|
imageSizeFromBufferFallBack,
|
||||||
|
imageSizeFromFile,
|
||||||
|
matchMagic,
|
||||||
|
ImageType,
|
||||||
|
} from '@/napcat-image-size/src';
|
||||||
|
|
||||||
|
// resource 目录路径
|
||||||
|
const resourceDir = path.resolve(__dirname, '../napcat-image-size/resource');
|
||||||
|
|
||||||
|
// 测试用的 Buffer 数据
|
||||||
|
const testBuffers = {
|
||||||
|
// PNG 测试图片 (100x200)
|
||||||
|
png: Buffer.from([
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||||
|
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||||
|
0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0xC8,
|
||||||
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
]),
|
||||||
|
|
||||||
|
// JPEG 测试图片 (320x240)
|
||||||
|
jpeg: Buffer.from([
|
||||||
|
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10,
|
||||||
|
0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||||
|
0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
|
||||||
|
0xFF, 0xC0, 0x00, 0x0B, 0x08,
|
||||||
|
0x00, 0xF0, 0x01, 0x40, 0x03, 0x01, 0x22, 0x00,
|
||||||
|
]),
|
||||||
|
|
||||||
|
// BMP 测试图片 (640x480)
|
||||||
|
bmp: (() => {
|
||||||
|
const buf = Buffer.alloc(54);
|
||||||
|
buf.write('BM', 0);
|
||||||
|
buf.writeUInt32LE(54, 2);
|
||||||
|
buf.writeUInt32LE(0, 6);
|
||||||
|
buf.writeUInt32LE(54, 10);
|
||||||
|
buf.writeUInt32LE(40, 14);
|
||||||
|
buf.writeUInt32LE(640, 18);
|
||||||
|
buf.writeUInt32LE(480, 22);
|
||||||
|
buf.writeUInt16LE(1, 26);
|
||||||
|
buf.writeUInt16LE(24, 28);
|
||||||
|
return buf;
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// GIF87a 测试图片 (800x600)
|
||||||
|
gif87a: Buffer.from([
|
||||||
|
0x47, 0x49, 0x46, 0x38, 0x37, 0x61,
|
||||||
|
0x20, 0x03, 0x58, 0x02, 0x00, 0x00, 0x00,
|
||||||
|
]),
|
||||||
|
|
||||||
|
// GIF89a 测试图片 (1024x768)
|
||||||
|
gif89a: Buffer.from([
|
||||||
|
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
|
||||||
|
0x00, 0x04, 0x00, 0x03, 0x00, 0x00, 0x00,
|
||||||
|
]),
|
||||||
|
|
||||||
|
// WebP VP8 测试图片 (1920x1080)
|
||||||
|
webpVP8: (() => {
|
||||||
|
const buf = Buffer.alloc(32);
|
||||||
|
buf.write('RIFF', 0);
|
||||||
|
buf.writeUInt32LE(24, 4);
|
||||||
|
buf.write('WEBP', 8);
|
||||||
|
buf.write('VP8 ', 12);
|
||||||
|
buf.writeUInt32LE(14, 16);
|
||||||
|
buf.writeUInt8(0x9D, 20);
|
||||||
|
buf.writeUInt8(0x01, 21);
|
||||||
|
buf.writeUInt8(0x2A, 22);
|
||||||
|
buf.writeUInt16LE(1920 & 0x3FFF, 26);
|
||||||
|
buf.writeUInt16LE(1080 & 0x3FFF, 28);
|
||||||
|
return buf;
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// WebP VP8L 测试图片 (256x128)
|
||||||
|
webpVP8L: (() => {
|
||||||
|
const buf = Buffer.alloc(32);
|
||||||
|
buf.write('RIFF', 0);
|
||||||
|
buf.writeUInt32LE(24, 4);
|
||||||
|
buf.write('WEBP', 8);
|
||||||
|
buf.write('VP8L', 12);
|
||||||
|
buf.writeUInt32LE(10, 16);
|
||||||
|
buf.writeUInt8(0x2F, 20);
|
||||||
|
const vp8lBits = (256 - 1) | ((128 - 1) << 14);
|
||||||
|
buf.writeUInt32LE(vp8lBits, 21);
|
||||||
|
return buf;
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// WebP VP8X 测试图片 (512x384)
|
||||||
|
webpVP8X: (() => {
|
||||||
|
const buf = Buffer.alloc(32);
|
||||||
|
buf.write('RIFF', 0);
|
||||||
|
buf.writeUInt32LE(24, 4);
|
||||||
|
buf.write('WEBP', 8);
|
||||||
|
buf.write('VP8X', 12);
|
||||||
|
buf.writeUInt32LE(10, 16);
|
||||||
|
buf.writeUInt8((512 - 1) & 0xFF, 24);
|
||||||
|
buf.writeUInt8(((512 - 1) >> 8) & 0xFF, 25);
|
||||||
|
buf.writeUInt8(((512 - 1) >> 16) & 0xFF, 26);
|
||||||
|
buf.writeUInt8((384 - 1) & 0xFF, 27);
|
||||||
|
buf.writeUInt8(((384 - 1) >> 8) & 0xFF, 28);
|
||||||
|
buf.writeUInt8(((384 - 1) >> 16) & 0xFF, 29);
|
||||||
|
return buf;
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// TIFF Little Endian 测试图片
|
||||||
|
tiffLE: Buffer.from([
|
||||||
|
0x49, 0x49, 0x2A, 0x00, // II + magic
|
||||||
|
0x08, 0x00, 0x00, 0x00, // IFD offset = 8
|
||||||
|
0x02, 0x00, // 2 entries
|
||||||
|
// Entry 1: ImageWidth = 100
|
||||||
|
0x00, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00,
|
||||||
|
// Entry 2: ImageHeight = 200
|
||||||
|
0x01, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0xC8, 0x00, 0x00, 0x00,
|
||||||
|
]),
|
||||||
|
|
||||||
|
// TIFF Big Endian 测试图片
|
||||||
|
tiffBE: Buffer.from([
|
||||||
|
0x4D, 0x4D, 0x00, 0x2A, // MM + magic
|
||||||
|
0x00, 0x00, 0x00, 0x08, // IFD offset = 8
|
||||||
|
0x00, 0x02, // 2 entries
|
||||||
|
// Entry 1: ImageWidth = 100
|
||||||
|
0x01, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x64, 0x00, 0x00,
|
||||||
|
// Entry 2: ImageHeight = 200
|
||||||
|
0x01, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0xC8, 0x00, 0x00,
|
||||||
|
]),
|
||||||
|
|
||||||
|
invalid: Buffer.from('This is not an image file'),
|
||||||
|
empty: Buffer.alloc(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('napcat-image-size', () => {
|
||||||
|
describe('matchMagic', () => {
|
||||||
|
it('should match magic bytes at the beginning', () => {
|
||||||
|
const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||||
|
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match magic bytes at offset', () => {
|
||||||
|
const buffer = Buffer.from([0x00, 0x00, 0x89, 0x50, 0x4E, 0x47]);
|
||||||
|
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47], 2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-matching magic', () => {
|
||||||
|
const buffer = Buffer.from([0x00, 0x00, 0x00, 0x00]);
|
||||||
|
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for buffer too short', () => {
|
||||||
|
const buffer = Buffer.from([0x89, 0x50]);
|
||||||
|
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for offset beyond buffer', () => {
|
||||||
|
const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]);
|
||||||
|
expect(matchMagic(buffer, [0x89, 0x50], 10)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectImageTypeFromBuffer', () => {
|
||||||
|
it('should detect PNG image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.png)).toBe(ImageType.PNG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect JPEG image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.jpeg)).toBe(ImageType.JPEG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect BMP image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.bmp)).toBe(ImageType.BMP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect GIF87a image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.gif87a)).toBe(ImageType.GIF);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect GIF89a image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.gif89a)).toBe(ImageType.GIF);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect WebP VP8 image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.webpVP8)).toBe(ImageType.WEBP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect WebP VP8L image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.webpVP8L)).toBe(ImageType.WEBP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect WebP VP8X image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.webpVP8X)).toBe(ImageType.WEBP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect TIFF Little Endian image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.tiffLE)).toBe(ImageType.TIFF);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect TIFF Big Endian image type', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.tiffBE)).toBe(ImageType.TIFF);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return UNKNOWN for invalid data', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.invalid)).toBe(ImageType.UNKNOWN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return UNKNOWN for empty buffer', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(testBuffers.empty)).toBe(ImageType.UNKNOWN);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('imageSizeFromBuffer', () => {
|
||||||
|
it('should parse PNG image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.png)).toEqual({ width: 100, height: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse JPEG image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.jpeg)).toEqual({ width: 320, height: 240 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse BMP image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.bmp)).toEqual({ width: 640, height: 480 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse GIF87a image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.gif87a)).toEqual({ width: 800, height: 600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse GIF89a image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.gif89a)).toEqual({ width: 1024, height: 768 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse WebP VP8 image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.webpVP8)).toEqual({ width: 1920, height: 1080 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse WebP VP8L image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.webpVP8L)).toEqual({ width: 256, height: 128 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse WebP VP8X image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.webpVP8X)).toEqual({ width: 512, height: 384 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse TIFF Little Endian image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.tiffLE)).toEqual({ width: 100, height: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse TIFF Big Endian image size correctly', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.tiffBE)).toEqual({ width: 100, height: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for invalid data', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.invalid)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for empty buffer', async () => {
|
||||||
|
expect(await imageSizeFromBuffer(testBuffers.empty)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('imageSizeFromBufferFallBack', () => {
|
||||||
|
it('should return actual size for valid image', async () => {
|
||||||
|
expect(await imageSizeFromBufferFallBack(testBuffers.png)).toEqual({ width: 100, height: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default fallback for invalid data', async () => {
|
||||||
|
expect(await imageSizeFromBufferFallBack(testBuffers.invalid)).toEqual({ width: 1024, height: 1024 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return custom fallback for invalid data', async () => {
|
||||||
|
expect(await imageSizeFromBufferFallBack(testBuffers.invalid, { width: 500, height: 300 })).toEqual({ width: 500, height: 300 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default fallback for empty buffer', async () => {
|
||||||
|
expect(await imageSizeFromBufferFallBack(testBuffers.empty)).toEqual({ width: 1024, height: 1024 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return custom fallback for empty buffer', async () => {
|
||||||
|
expect(await imageSizeFromBufferFallBack(testBuffers.empty, { width: 800, height: 600 })).toEqual({ width: 800, height: 600 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ImageType enum', () => {
|
||||||
|
it('should have correct enum values', () => {
|
||||||
|
expect(ImageType.JPEG).toBe('jpeg');
|
||||||
|
expect(ImageType.PNG).toBe('png');
|
||||||
|
expect(ImageType.BMP).toBe('bmp');
|
||||||
|
expect(ImageType.GIF).toBe('gif');
|
||||||
|
expect(ImageType.WEBP).toBe('webp');
|
||||||
|
expect(ImageType.TIFF).toBe('tiff');
|
||||||
|
expect(ImageType.UNKNOWN).toBe('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Real image files from resource directory', () => {
|
||||||
|
it('should detect and parse test-20x20.jpg', async () => {
|
||||||
|
const filePath = path.join(resourceDir, 'test-20x20.jpg');
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.JPEG);
|
||||||
|
const size = await imageSizeFromBuffer(buffer);
|
||||||
|
expect(size).toEqual({ width: 20, height: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect and parse test-20x20.png', async () => {
|
||||||
|
const filePath = path.join(resourceDir, 'test-20x20.png');
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.PNG);
|
||||||
|
const size = await imageSizeFromBuffer(buffer);
|
||||||
|
expect(size).toEqual({ width: 20, height: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect and parse test-20x20.tiff', async () => {
|
||||||
|
const filePath = path.join(resourceDir, 'test-20x20.tiff');
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.TIFF);
|
||||||
|
const size = await imageSizeFromBuffer(buffer);
|
||||||
|
expect(size).toEqual({ width: 20, height: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect and parse test-20x20.webp', async () => {
|
||||||
|
const filePath = path.join(resourceDir, 'test-20x20.webp');
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.WEBP);
|
||||||
|
const size = await imageSizeFromBuffer(buffer);
|
||||||
|
expect(size).toEqual({ width: 20, height: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect and parse test-490x498.gif', async () => {
|
||||||
|
const filePath = path.join(resourceDir, 'test-490x498.gif');
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.GIF);
|
||||||
|
const size = await imageSizeFromBuffer(buffer);
|
||||||
|
expect(size).toEqual({ width: 490, height: 498 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse real images using imageSizeFromFile', async () => {
|
||||||
|
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.jpg'))).toEqual({ width: 20, height: 20 });
|
||||||
|
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.png'))).toEqual({ width: 20, height: 20 });
|
||||||
|
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.tiff'))).toEqual({ width: 20, height: 20 });
|
||||||
|
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.webp'))).toEqual({ width: 20, height: 20 });
|
||||||
|
expect(await imageSizeFromFile(path.join(resourceDir, 'test-490x498.gif'))).toEqual({ width: 490, height: 498 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"vitest": "^4.0.9"
|
"vitest": "^4.0.9"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"napcat-core": "workspace:*"
|
"napcat-core": "workspace:*",
|
||||||
|
"napcat-image-size": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,10 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
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'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -123,9 +123,14 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查并更新默认密码(仅在启用WebUI时)
|
// 优先使用环境变量覆盖 Token
|
||||||
if (config.token === 'napcat' || !config.token) {
|
if (process.env['NAPCAT_WEBUI_SECRET_KEY'] && config.token !== process.env['NAPCAT_WEBUI_SECRET_KEY']) {
|
||||||
const randomToken = process.env['NAPCAT_WEBUI_SECRET_KEY'] || getRandomToken(8);
|
await WebUiConfig.UpdateWebUIConfig({ token: process.env['NAPCAT_WEBUI_SECRET_KEY'] });
|
||||||
|
logger.log(`[NapCat] [WebUi] 检测到环境变量配置,已更新 WebUI Token 为 ${process.env['NAPCAT_WEBUI_SECRET_KEY']}`);
|
||||||
|
config = await WebUiConfig.GetWebUIConfig();
|
||||||
|
} else if (config.token === 'napcat' || !config.token) {
|
||||||
|
// 只有没设置环境变量,且是默认密码时,才生成随机密码
|
||||||
|
const randomToken = getRandomToken(8);
|
||||||
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
|
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
|
||||||
logger.log('[NapCat] [WebUi] 检测到默认密码,已自动更新为安全密码');
|
logger.log('[NapCat] [WebUi] 检测到默认密码,已自动更新为安全密码');
|
||||||
|
|
||||||
@@ -226,10 +231,13 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
|||||||
// 添加字体变量
|
// 添加字体变量
|
||||||
if (fontMode === 'aacute') {
|
if (fontMode === 'aacute') {
|
||||||
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||||||
|
css += "--font-family-mono: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||||||
} else if (fontMode === 'custom') {
|
} else if (fontMode === 'custom') {
|
||||||
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
|
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||||||
|
css += "--font-family-mono: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||||||
} else {
|
} else {
|
||||||
css += '--font-family-base: var(--font-family-fallbacks) !important;';
|
css += '--font-family-base: var(--font-family-fallbacks) !important;';
|
||||||
|
css += '--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;';
|
||||||
}
|
}
|
||||||
css += '}';
|
css += '}';
|
||||||
|
|
||||||
@@ -240,10 +248,13 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
|||||||
// 添加字体变量
|
// 添加字体变量
|
||||||
if (fontMode === 'aacute') {
|
if (fontMode === 'aacute') {
|
||||||
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||||||
|
css += "--font-family-mono: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
|
||||||
} else if (fontMode === 'custom') {
|
} else if (fontMode === 'custom') {
|
||||||
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
|
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||||||
|
css += "--font-family-mono: 'CustomFont', var(--font-family-fallbacks) !important;";
|
||||||
} else {
|
} else {
|
||||||
css += '--font-family-base: var(--font-family-fallbacks) !important;';
|
css += '--font-family-base: var(--font-family-fallbacks) !important;';
|
||||||
|
css += '--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;';
|
||||||
}
|
}
|
||||||
css += '}';
|
css += '}';
|
||||||
|
|
||||||
|
|||||||
151
packages/napcat-webui-backend/src/api/BackupConfig.ts
Normal file
151
packages/napcat-webui-backend/src/api/BackupConfig.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { RequestHandler } from 'express';
|
||||||
|
import { existsSync, readdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||||
|
import { join, normalize } from 'node:path';
|
||||||
|
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||||
|
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||||
|
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||||
|
import compressing from 'compressing';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
|
||||||
|
// 使用compressing库进行流式压缩导出
|
||||||
|
export const BackupExportConfigHandler: RequestHandler = async (_req, res) => {
|
||||||
|
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||||
|
if (!isLogin) {
|
||||||
|
return sendError(res, 'Not Login');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configPath = webUiPathWrapper.configPath;
|
||||||
|
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
return sendError(res, '配置目录不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return date.toISOString().replace(/[:.]/g, '-');
|
||||||
|
};
|
||||||
|
const zipFileName = `config_backup_${formatDate(new Date())}.zip`;
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
|
||||||
|
|
||||||
|
// 使用compressing的Stream API进行流式压缩
|
||||||
|
const stream = new compressing.zip.Stream();
|
||||||
|
|
||||||
|
// 添加目录下的所有文件到压缩流(单层平坦结构)
|
||||||
|
const entries = readdirSync(configPath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isFile()) {
|
||||||
|
const entryPath = join(configPath, entry.name);
|
||||||
|
stream.addEntry(entryPath, { relativePath: entry.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管道传输到响应
|
||||||
|
stream.pipe(res);
|
||||||
|
|
||||||
|
// 处理流错误
|
||||||
|
stream.on('error', (err) => {
|
||||||
|
console.error('压缩流错误:', err);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
sendError(res, '流式压缩失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message;
|
||||||
|
console.error('导出配置失败:', error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return sendError(res, `导出配置失败: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从内存Buffer流式解压,返回文件名和内容的映射
|
||||||
|
async function extractZipToMemory (buffer: Buffer): Promise<Map<string, Buffer>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const files = new Map<string, Buffer>();
|
||||||
|
const bufferStream = Readable.from(buffer);
|
||||||
|
const uncompressStream = new compressing.zip.UncompressStream();
|
||||||
|
|
||||||
|
uncompressStream.on('entry', (header, stream, next) => {
|
||||||
|
// 只处理文件,忽略目录
|
||||||
|
if (header.type === 'file') {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||||
|
stream.on('end', () => {
|
||||||
|
// 取文件名(忽略路径中的目录部分)
|
||||||
|
const fileName = header.name.split('/').pop() || header.name;
|
||||||
|
files.set(fileName, Buffer.concat(chunks));
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
stream.on('error', (err) => {
|
||||||
|
console.error(`读取文件失败: ${header.name}`, err);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
stream.resume();
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
uncompressStream.on('finish', () => resolve(files));
|
||||||
|
uncompressStream.on('error', reject);
|
||||||
|
|
||||||
|
bufferStream.pipe(uncompressStream);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入配置 - 流式处理,完全在内存中解压
|
||||||
|
export const BackupImportConfigHandler: RequestHandler = async (req, res) => {
|
||||||
|
// 检查是否有文件上传(multer memoryStorage 模式下文件在 req.file.buffer)
|
||||||
|
if (!req.file || !req.file.buffer) {
|
||||||
|
return sendError(res, '请选择要导入的配置文件');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configPath = webUiPathWrapper.configPath;
|
||||||
|
|
||||||
|
// 从内存中解压zip
|
||||||
|
const extractedFiles = await extractZipToMemory(req.file.buffer);
|
||||||
|
|
||||||
|
if (extractedFiles.size === 0) {
|
||||||
|
return sendError(res, '配置文件为空或格式不正确');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备份当前配置到内存
|
||||||
|
const backupFiles = new Map<string, Buffer>();
|
||||||
|
if (existsSync(configPath)) {
|
||||||
|
const currentFiles = readdirSync(configPath, { withFileTypes: true });
|
||||||
|
for (const entry of currentFiles) {
|
||||||
|
if (entry.isFile()) {
|
||||||
|
const filePath = join(configPath, entry.name);
|
||||||
|
backupFiles.set(entry.name, readFileSync(filePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入新的配置文件
|
||||||
|
for (const [fileName, content] of extractedFiles) {
|
||||||
|
// 防止路径穿越攻击
|
||||||
|
const destPath = join(configPath, fileName);
|
||||||
|
const normalizedPath = normalize(destPath);
|
||||||
|
if (!normalizedPath.startsWith(normalize(configPath))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
writeFileSync(destPath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendSuccess(res, {
|
||||||
|
message: '配置导入成功,重启后生效~',
|
||||||
|
filesImported: extractedFiles.size,
|
||||||
|
filesBackedUp: backupFiles.size
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导入配置失败:', error);
|
||||||
|
const msg = (error as Error).message;
|
||||||
|
return sendError(res, `导入配置失败: ${msg}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -653,3 +653,13 @@ export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => {
|
|||||||
return sendError(res, '删除字体文件失败');
|
return sendError(res, '删除字体文件失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 检查WebUI字体文件是否存在
|
||||||
|
export const CheckWebUIFontExistHandler: RequestHandler = async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const exists = await WebUiConfig.CheckWebUIFontExist();
|
||||||
|
return sendSuccess(res, exists);
|
||||||
|
} catch (_error) {
|
||||||
|
return sendError(res, '检查字体文件失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ const CACHE_TTL = 10 * 60 * 1000; // 10分钟缓存
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 从多个源获取插件列表,使用镜像系统
|
* 从多个源获取插件列表,使用镜像系统
|
||||||
* 带10分钟缓存
|
* 带10分钟缓存,支持强制刷新
|
||||||
*/
|
*/
|
||||||
async function fetchPluginList (): Promise<PluginStoreList> {
|
async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginStoreList> {
|
||||||
// 检查缓存
|
// 检查缓存(如果不是强制刷新)
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
|
if (!forceRefresh && pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
|
||||||
//console.log('Using cached plugin list');
|
//console.log('Using cached plugin list');
|
||||||
return pluginListCache;
|
return pluginListCache;
|
||||||
}
|
}
|
||||||
@@ -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 {
|
try {
|
||||||
const data = await fetchPluginList();
|
// 支持 forceRefresh 查询参数强制刷新缓存
|
||||||
|
const forceRefresh = req.query['forceRefresh'] === 'true';
|
||||||
|
const data = await fetchPluginList(forceRefresh);
|
||||||
return sendSuccess(res, data);
|
return sendSuccess(res, data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return sendError(res, 'Failed to fetch plugin store list: ' + e.message);
|
return sendError(res, 'Failed to fetch plugin store list: ' + e.message);
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper,
|
|||||||
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
|
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
|
||||||
|
|
||||||
if (!fs.existsSync(configPath)) {
|
if (!fs.existsSync(configPath)) {
|
||||||
logger.log('[NapCat Update] No pending updates found');
|
//logger.log('[NapCat Update] No pending updates found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
UploadHandler,
|
UploadHandler,
|
||||||
UploadWebUIFontHandler,
|
UploadWebUIFontHandler,
|
||||||
DeleteWebUIFontHandler, // 添加上传处理器
|
DeleteWebUIFontHandler, // 添加上传处理器
|
||||||
|
CheckWebUIFontExistHandler, // Add this
|
||||||
} from '../api/File';
|
} from '../api/File';
|
||||||
|
|
||||||
const router: Router = Router();
|
const router: Router = Router();
|
||||||
@@ -46,4 +47,5 @@ router.post('/upload', UploadHandler);
|
|||||||
|
|
||||||
router.post('/font/upload/webui', UploadWebUIFontHandler);
|
router.post('/font/upload/webui', UploadWebUIFontHandler);
|
||||||
router.post('/font/delete/webui', DeleteWebUIFontHandler);
|
router.post('/font/delete/webui', DeleteWebUIFontHandler);
|
||||||
|
router.get('/font/exists/webui', CheckWebUIFontExistHandler); // Add this
|
||||||
export { router as FileRouter };
|
export { router as FileRouter };
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
|
||||||
import { OB11GetConfigHandler, OB11SetConfigHandler } from '@/napcat-webui-backend/src/api/OB11Config';
|
import { OB11GetConfigHandler, OB11SetConfigHandler } from '@/napcat-webui-backend/src/api/OB11Config';
|
||||||
|
import { BackupExportConfigHandler, BackupImportConfigHandler } from '@/napcat-webui-backend/src/api/BackupConfig';
|
||||||
|
|
||||||
const router: Router = Router();
|
const router: Router = Router();
|
||||||
|
|
||||||
|
// 使用内存存储,配合流式处理
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage()
|
||||||
|
});
|
||||||
|
|
||||||
// router:读取配置
|
// router:读取配置
|
||||||
router.post('/GetConfig', OB11GetConfigHandler);
|
router.post('/GetConfig', OB11GetConfigHandler);
|
||||||
// router:写入配置
|
// router:写入配置
|
||||||
router.post('/SetConfig', OB11SetConfigHandler);
|
router.post('/SetConfig', OB11SetConfigHandler);
|
||||||
|
// router:导出配置
|
||||||
|
router.get('/ExportConfig', BackupExportConfigHandler);
|
||||||
|
// router:导入配置
|
||||||
|
router.post('/ImportConfig', upload.single('configFile'), BackupImportConfigHandler);
|
||||||
|
|
||||||
export { router as OB11ConfigRouter };
|
export { router as OB11ConfigRouter };
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,30 @@ const SUPPORTED_FONT_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf'];
|
|||||||
|
|
||||||
// 清理旧的字体文件
|
// 清理旧的字体文件
|
||||||
const cleanOldFontFiles = (fontsPath: string) => {
|
const cleanOldFontFiles = (fontsPath: string) => {
|
||||||
for (const ext of SUPPORTED_FONT_EXTENSIONS) {
|
try {
|
||||||
const fontPath = path.join(fontsPath, `webui${ext}`);
|
// 确保字体目录存在
|
||||||
try {
|
if (!fs.existsSync(fontsPath)) {
|
||||||
if (fs.existsSync(fontPath)) {
|
return;
|
||||||
fs.unlinkSync(fontPath);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 忽略删除失败
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 遍历目录下所有文件
|
||||||
|
const files = fs.readdirSync(fontsPath);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// 检查文件名是否以 webui 或 CustomFont 开头,且是支持的字体扩展名
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
const name = path.basename(file, ext);
|
||||||
|
|
||||||
|
if (SUPPORTED_FONT_EXTENSIONS.includes(ext) && (name === 'webui' || name === 'CustomFont')) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(path.join(fontsPath, file));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to delete old font file ${file}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to clean old font files:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,9 +51,9 @@ export const webUIFontStorage = multer.diskStorage({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
filename: (_, file, cb) => {
|
filename: (_, file, cb) => {
|
||||||
// 保留原始扩展名,统一文件名为 webui
|
// 强制文件名为 CustomFont,保留原始扩展名
|
||||||
const ext = path.extname(file.originalname).toLowerCase();
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
cb(null, `webui${ext}`);
|
cb(null, `CustomFont${ext}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,6 @@
|
|||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"quill": "^2.0.3",
|
"quill": "^2.0.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-color": "^2.19.3",
|
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-dropzone": "^14.3.5",
|
"react-dropzone": "^14.3.5",
|
||||||
"react-error-boundary": "^5.0.0",
|
"react-error-boundary": "^5.0.0",
|
||||||
|
|||||||
@@ -1,36 +1,409 @@
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
import { Input } from "@heroui/input";
|
||||||
import React from 'react';
|
import { Popover, PopoverContent, PopoverTrigger } from "@heroui/popover";
|
||||||
import { ColorResult, SketchPicker } from 'react-color';
|
import React, { useCallback, useEffect, useRef, useState, memo } from "react";
|
||||||
|
|
||||||
// 假定 heroui 提供的 Popover组件
|
|
||||||
|
|
||||||
interface ColorPickerProps {
|
interface ColorPickerProps {
|
||||||
color: string
|
color: string;
|
||||||
onChange: (color: ColorResult) => void
|
onChange: (color: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 转换 HSL 字符串到对象
|
||||||
|
const parseHsl = (hslStr: string) => {
|
||||||
|
const match = hslStr.match(/hsl\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)%,\s*(\d+(?:\.\d+)?)%\)/);
|
||||||
|
if (match) {
|
||||||
|
return { h: parseFloat(match[1]), s: parseFloat(match[2]), l: parseFloat(match[3]) };
|
||||||
|
}
|
||||||
|
return { h: 0, s: 0, l: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 转换 HEX 到 HSL
|
||||||
|
const hexToHsl = (hex: string) => {
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
if (hex.length === 4) {
|
||||||
|
r = parseInt("0x" + hex[1] + hex[1]);
|
||||||
|
g = parseInt("0x" + hex[2] + hex[2]);
|
||||||
|
b = parseInt("0x" + hex[3] + hex[3]);
|
||||||
|
} else if (hex.length === 7) {
|
||||||
|
r = parseInt("0x" + hex[1] + hex[2]);
|
||||||
|
g = parseInt("0x" + hex[3] + hex[4]);
|
||||||
|
b = parseInt("0x" + hex[5] + hex[6]);
|
||||||
|
}
|
||||||
|
r /= 255;
|
||||||
|
g /= 255;
|
||||||
|
b /= 255;
|
||||||
|
const cmin = Math.min(r, g, b),
|
||||||
|
cmax = Math.max(r, g, b),
|
||||||
|
delta = cmax - cmin;
|
||||||
|
let h = 0,
|
||||||
|
s = 0,
|
||||||
|
l = 0;
|
||||||
|
|
||||||
|
if (delta === 0) h = 0;
|
||||||
|
else if (cmax === r) h = ((g - b) / delta) % 6;
|
||||||
|
else if (cmax === g) h = (b - r) / delta + 2;
|
||||||
|
else h = (r - g) / delta + 4;
|
||||||
|
|
||||||
|
h = Math.round(h * 60);
|
||||||
|
if (h < 0) h += 360;
|
||||||
|
|
||||||
|
l = (cmax + cmin) / 2;
|
||||||
|
s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
|
||||||
|
s = +(s * 100).toFixed(1);
|
||||||
|
l = +(l * 100).toFixed(1);
|
||||||
|
|
||||||
|
return { h, s, l };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 转换 HSL 到 HEX
|
||||||
|
const hslToHex = (h: number, s: number, l: number) => {
|
||||||
|
s /= 100;
|
||||||
|
l /= 100;
|
||||||
|
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
|
const m = l - c / 2;
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
|
||||||
|
if (0 <= h && h < 60) {
|
||||||
|
r = c; g = x; b = 0;
|
||||||
|
} else if (60 <= h && h < 120) {
|
||||||
|
r = x; g = c; b = 0;
|
||||||
|
} else if (120 <= h && h < 180) {
|
||||||
|
r = 0; g = c; b = x;
|
||||||
|
} else if (180 <= h && h < 240) {
|
||||||
|
r = 0; g = x; b = c;
|
||||||
|
} else if (240 <= h && h < 300) {
|
||||||
|
r = x; g = 0; b = c;
|
||||||
|
} else if (300 <= h && h < 360) {
|
||||||
|
r = c; g = 0; b = x;
|
||||||
|
}
|
||||||
|
r = Math.round((r + m) * 255);
|
||||||
|
g = Math.round((g + m) * 255);
|
||||||
|
b = Math.round((b + m) * 255);
|
||||||
|
|
||||||
|
const toHex = (n: number) => {
|
||||||
|
const hex = n.toString(16);
|
||||||
|
return hex.length === 1 ? "0" + hex : hex;
|
||||||
|
};
|
||||||
|
return "#" + toHex(r) + toHex(g) + toHex(b);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PanelProps {
|
||||||
|
hsl: { h: number, s: number, l: number; };
|
||||||
|
onChange: (newHsl: { h: number, s: number, l: number; }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 饱和度/亮度面板
|
||||||
|
const SatLightPanel = memo(({ hsl, onChange }: PanelProps) => {
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const hslRef = useRef(hsl);
|
||||||
|
useEffect(() => { hslRef.current = hsl; }, [hsl]);
|
||||||
|
|
||||||
|
const updateColor = useCallback((clientX: number, clientY: number) => {
|
||||||
|
if (!panelRef.current) return;
|
||||||
|
const rect = panelRef.current.getBoundingClientRect();
|
||||||
|
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||||
|
const y = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
||||||
|
|
||||||
|
const s_hsv = x;
|
||||||
|
const v_hsv = 1 - y;
|
||||||
|
|
||||||
|
let l_hsl = v_hsv * (1 - s_hsv / 2);
|
||||||
|
let s_hsl = 0;
|
||||||
|
if (l_hsl === 0 || l_hsl === 1) {
|
||||||
|
s_hsl = 0;
|
||||||
|
} else {
|
||||||
|
s_hsl = (v_hsv - l_hsl) / Math.min(l_hsl, 1 - l_hsl);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({ h: hslRef.current.h, s: s_hsl * 100, l: l_hsl * 100 });
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
const handleStart = (clientX: number, clientY: number) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
updateColor(clientX, clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleStart(e.clientX, e.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
handleStart(touch.clientX, touch.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (isDragging) {
|
||||||
|
e.preventDefault();
|
||||||
|
updateColor(e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
|
if (isDragging) {
|
||||||
|
e.preventDefault();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
updateColor(touch.clientX, touch.clientY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
window.addEventListener("mouseup", handleEnd);
|
||||||
|
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||||
|
window.addEventListener("touchend", handleEnd);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
window.removeEventListener("mouseup", handleEnd);
|
||||||
|
window.removeEventListener("touchmove", handleTouchMove);
|
||||||
|
window.removeEventListener("touchend", handleEnd);
|
||||||
|
};
|
||||||
|
}, [isDragging, updateColor]);
|
||||||
|
|
||||||
|
const l_val = hsl.l / 100;
|
||||||
|
const s_val = hsl.s / 100;
|
||||||
|
const v_hsv = l_val + s_val * Math.min(l_val, 1 - l_val);
|
||||||
|
const s_hsv = v_hsv === 0 ? 0 : 2 * (1 - l_val / v_hsv);
|
||||||
|
|
||||||
|
const markerX = s_hsv * 100;
|
||||||
|
const markerY = (1 - v_hsv) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="w-full h-40 rounded-lg relative cursor-crosshair overflow-hidden shadow-inner touch-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "hsl(" + hsl.h + ", 100%, 50%)",
|
||||||
|
backgroundImage: "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)"
|
||||||
|
}}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full border-2 border-white shadow-md absolute transform -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: markerX + "%",
|
||||||
|
top: markerY + "%",
|
||||||
|
backgroundColor: "hsl(" + hsl.h + ", " + hsl.s + "%, " + hsl.l + "%)"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
SatLightPanel.displayName = "SatLightPanel";
|
||||||
|
|
||||||
|
const HueSlider = memo(({ hsl, onChange }: PanelProps) => {
|
||||||
|
const sliderRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const hslRef = useRef(hsl);
|
||||||
|
useEffect(() => { hslRef.current = hsl; }, [hsl]);
|
||||||
|
|
||||||
|
const updateHue = useCallback((clientX: number) => {
|
||||||
|
if (!sliderRef.current) return;
|
||||||
|
const rect = sliderRef.current.getBoundingClientRect();
|
||||||
|
let x = (clientX - rect.left) / rect.width;
|
||||||
|
x = Math.max(0, Math.min(1, x));
|
||||||
|
onChange({ ...hslRef.current, h: x * 360 });
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
const handleStart = (clientX: number) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
updateHue(clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleStart(e.clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
handleStart(touch.clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (isDragging) {
|
||||||
|
e.preventDefault();
|
||||||
|
updateHue(e.clientX);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
|
if (isDragging) {
|
||||||
|
e.preventDefault();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
updateHue(touch.clientX);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
window.addEventListener("mouseup", handleEnd);
|
||||||
|
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||||
|
window.addEventListener("touchend", handleEnd);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
window.removeEventListener("mouseup", handleEnd);
|
||||||
|
window.removeEventListener("touchmove", handleTouchMove);
|
||||||
|
window.removeEventListener("touchend", handleEnd);
|
||||||
|
};
|
||||||
|
}, [isDragging, updateHue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sliderRef}
|
||||||
|
className="w-full h-4 rounded-full relative cursor-pointer mt-3 shadow-inner touch-none"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)"
|
||||||
|
}}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full border-2 border-white shadow-md absolute top-0 transform -translate-x-1/2 pointer-events-none bg-white"
|
||||||
|
style={{ left: (hsl.h / 360) * 100 + "%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
HueSlider.displayName = "HueSlider";
|
||||||
|
|
||||||
const ColorPicker: React.FC<ColorPickerProps> = ({ color, onChange }) => {
|
const ColorPicker: React.FC<ColorPickerProps> = ({ color, onChange }) => {
|
||||||
const handleChange = (colorResult: ColorResult) => {
|
const [hsl, setHsl] = useState(parseHsl(color));
|
||||||
onChange(colorResult);
|
const [hex, setHex] = useState(hslToHex(hsl.h, hsl.s, hsl.l));
|
||||||
|
const isDraggingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDraggingRef.current) return;
|
||||||
|
const newHsl = parseHsl(color);
|
||||||
|
if (Math.abs(newHsl.h - hsl.h) > 0.1 || Math.abs(newHsl.s - hsl.s) > 0.1 || Math.abs(newHsl.l - hsl.l) > 0.1) {
|
||||||
|
setHsl(newHsl);
|
||||||
|
setHex(hslToHex(newHsl.h, newHsl.s, newHsl.l));
|
||||||
|
}
|
||||||
|
}, [color]);
|
||||||
|
|
||||||
|
const handleHslChange = useCallback((newHsl: { h: number, s: number, l: number; }) => {
|
||||||
|
setHsl(newHsl);
|
||||||
|
setHex(hslToHex(newHsl.h, newHsl.s, newHsl.l));
|
||||||
|
onChange("hsl(" + Math.round(newHsl.h) + ", " + Math.round(newHsl.s) + "%, " + Math.round(newHsl.l) + "%)");
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
const handleHexChange = (value: string) => {
|
||||||
|
setHex(value);
|
||||||
|
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||||
|
const newHsl = hexToHsl(value);
|
||||||
|
handleHslChange(newHsl);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover triggerScaleOnOpen={false}>
|
<Popover triggerScaleOnOpen={false} placement="bottom">
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<div
|
<div className="flex items-center gap-2 cursor-pointer group">
|
||||||
className='w-36 h-8 rounded-md cursor-pointer border border-content4'
|
<div
|
||||||
style={{ background: color }}
|
className="w-10 h-10 rounded-lg shadow-sm border-2 border-default-200 transition-transform group-hover:scale-105"
|
||||||
/>
|
style={{ background: color }}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-mono text-default-500">{hex}</span>
|
||||||
|
<span className="text-xs font-mono text-default-400">HSL({Math.round(hsl.h)}, {Math.round(hsl.s)}%, {Math.round(hsl.l)}%)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent>
|
<PopoverContent className="w-72 p-4 bg-background/80 backdrop-blur-xl border border-default-200 shadow-2xl rounded-2xl"
|
||||||
<SketchPicker
|
onMouseDownCapture={() => { isDraggingRef.current = true; }}
|
||||||
color={color}
|
onMouseUpCapture={() => { isDraggingRef.current = false; }}
|
||||||
onChange={handleChange}
|
onTouchStartCapture={() => { isDraggingRef.current = true; }}
|
||||||
className='!bg-transparent !shadow-none'
|
onTouchEndCapture={() => { isDraggingRef.current = false; }}
|
||||||
/>
|
>
|
||||||
|
<div className="flex flex-col w-full gap-2">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm font-bold text-default-700">选择颜色</span>
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full border border-default-200 shadow-sm"
|
||||||
|
style={{ background: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SatLightPanel hsl={hsl} onChange={handleHslChange} />
|
||||||
|
<HueSlider hsl={hsl} onChange={handleHslChange} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-2 mt-2 items-center">
|
||||||
|
<span className="text-xs text-default-500 col-span-1">HEX</span>
|
||||||
|
<Input
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
value={hex}
|
||||||
|
onChange={(e) => handleHexChange(e.target.value)}
|
||||||
|
className="col-span-3 font-mono"
|
||||||
|
classNames={{
|
||||||
|
input: "text-xs uppercase",
|
||||||
|
inputWrapper: "h-8 min-h-8"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-2 items-center">
|
||||||
|
<span className="text-xs text-default-500 col-span-1">HSL</span>
|
||||||
|
<div className="col-span-3 flex gap-1">
|
||||||
|
<Input
|
||||||
|
size="sm" variant="flat" type="number"
|
||||||
|
value={Math.round(hsl.h).toString()}
|
||||||
|
onChange={(e) => handleHslChange({ ...hsl, h: Number(e.target.value) })}
|
||||||
|
endContent={<span className="text-xs text-default-400">H</span>}
|
||||||
|
classNames={{ input: "text-xs", inputWrapper: "h-8 min-h-8 px-1" }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
size="sm" variant="flat" type="number"
|
||||||
|
value={Math.round(hsl.s).toString()}
|
||||||
|
onChange={(e) => handleHslChange({ ...hsl, s: Number(e.target.value) })}
|
||||||
|
endContent={<span className="text-xs text-default-400">S</span>}
|
||||||
|
classNames={{ input: "text-xs", inputWrapper: "h-8 min-h-8 px-1" }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
size="sm" variant="flat" type="number"
|
||||||
|
value={Math.round(hsl.l).toString()}
|
||||||
|
onChange={(e) => handleHslChange({ ...hsl, l: Number(e.target.value) })}
|
||||||
|
endContent={<span className="text-xs text-default-400">L</span>}
|
||||||
|
classNames={{ input: "text-xs", inputWrapper: "h-8 min-h-8 px-1" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 mt-2 flex-wrap justify-between">
|
||||||
|
{["#006FEE", "#17C964", "#F5A524", "#F31260", "#7828C8", "#000000", "#FFFFFF"].map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
className="w-6 h-6 rounded-full border border-default-200 shadow-sm transition-transform hover:scale-110 active:scale-95"
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
onClick={() => handleHexChange(c)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ColorPicker;
|
export default ColorPicker;
|
||||||
@@ -4,7 +4,7 @@ import clsx from 'clsx';
|
|||||||
import key from '@/const/key';
|
import key from '@/const/key';
|
||||||
|
|
||||||
export interface ContainerProps {
|
export interface ContainerProps {
|
||||||
title: string;
|
title: React.ReactNode;
|
||||||
tag?: React.ReactNode;
|
tag?: React.ReactNode;
|
||||||
action: React.ReactNode;
|
action: React.ReactNode;
|
||||||
enableSwitch: React.ReactNode;
|
enableSwitch: React.ReactNode;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button } from '@heroui/button';
|
import { Button } from '@heroui/button';
|
||||||
import { Chip } from '@heroui/chip';
|
import { Chip } from '@heroui/chip';
|
||||||
|
import { Tooltip } from '@heroui/tooltip';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { IoMdDownload, IoMdRefresh, IoMdCheckmarkCircle } from 'react-icons/io';
|
import { IoMdDownload, IoMdRefresh, IoMdCheckmarkCircle } from 'react-icons/io';
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
|||||||
onInstall,
|
onInstall,
|
||||||
installStatus = 'not-installed',
|
installStatus = 'not-installed',
|
||||||
}) => {
|
}) => {
|
||||||
const { name, version, author, description, tags, id } = data;
|
const { name, version, author, description, tags, id, homepage } = data;
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
|
|
||||||
const handleInstall = () => {
|
const handleInstall = () => {
|
||||||
@@ -41,7 +42,7 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
|||||||
return {
|
return {
|
||||||
text: '更新',
|
text: '更新',
|
||||||
icon: <IoMdDownload size={16} />,
|
icon: <IoMdDownload size={16} />,
|
||||||
color: 'success' as const,
|
color: 'default' as const,
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
@@ -53,11 +54,31 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buttonConfig = getButtonConfig();
|
const buttonConfig = getButtonConfig();
|
||||||
|
const titleContent = homepage ? (
|
||||||
|
<Tooltip
|
||||||
|
content="跳转到插件主页"
|
||||||
|
placement="top"
|
||||||
|
showArrow
|
||||||
|
offset={8}
|
||||||
|
delay={200}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={homepage}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-inherit inline-block bg-no-repeat bg-left-bottom [background-image:repeating-linear-gradient(90deg,currentColor_0_2px,transparent_2px_5px)] [background-size:0%_2px] hover:[background-size:100%_2px] transition-[background-size] duration-200 ease-out"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
name
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisplayCardContainer
|
<DisplayCardContainer
|
||||||
className='w-full max-w-[420px]'
|
className='w-full max-w-[420px]'
|
||||||
title={name}
|
title={titleContent}
|
||||||
tag={
|
tag={
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
{installStatus === 'installed' && (
|
{installStatus === 'installed' && (
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
|
|||||||
export default GenericForm;
|
export default GenericForm;
|
||||||
export function random_token (length: number) {
|
export function random_token (length: number) {
|
||||||
const chars =
|
const chars =
|
||||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()-_=+[]{}|;:,.<>?';
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~';
|
||||||
let result = '';
|
let result = '';
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
|||||||
@@ -260,14 +260,14 @@ const NewVersionTip = (props: NewVersionTipProps) => {
|
|||||||
<div className="cursor-pointer flex items-center justify-center" onClick={updateStatus === 'updating' ? undefined : showUpdateDialog}>
|
<div className="cursor-pointer flex items-center justify-center" onClick={updateStatus === 'updating' ? undefined : showUpdateDialog}>
|
||||||
<Chip
|
<Chip
|
||||||
size="sm"
|
size="sm"
|
||||||
color="danger"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
classNames={{
|
classNames={{
|
||||||
content: "font-bold text-[10px] px-1 flex items-center justify-center",
|
content: "font-bold text-[10px] px-1 flex items-center justify-center",
|
||||||
base: "h-5 min-h-5 min-w-[42px]"
|
base: "h-5 min-h-5 min-w-[42px]"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{updateStatus === 'updating' ? <Spinner size="sm" color="danger" classNames={{ wrapper: "w-3 h-3" }} /> : 'New'}
|
{updateStatus === 'updating' ? <Spinner size="sm" color="primary" classNames={{ wrapper: "w-3 h-3" }} /> : 'New'}
|
||||||
</Chip>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -218,4 +218,11 @@ export default class FileManager {
|
|||||||
);
|
);
|
||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async checkWebUIFontExists () {
|
||||||
|
const { data } = await serverRequest.get<ServerResponse<boolean>>(
|
||||||
|
'/File/font/exists/webui'
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,9 +140,11 @@ export default class PluginManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取插件商店列表
|
* 获取插件商店列表
|
||||||
|
* @param forceRefresh 是否强制刷新(跳过服务端缓存)
|
||||||
*/
|
*/
|
||||||
public static async getPluginStoreList (): Promise<PluginStoreList> {
|
public static async getPluginStoreList (forceRefresh: boolean = false): Promise<PluginStoreList> {
|
||||||
const { data } = await serverRequest.get<ServerResponse<PluginStoreList>>('/Plugin/Store/List');
|
const params = forceRefresh ? { forceRefresh: 'true' } : {};
|
||||||
|
const { data } = await serverRequest.get<ServerResponse<PluginStoreList>>('/Plugin/Store/List', { params });
|
||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Button } from '@heroui/button';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { LuDownload, LuUpload } from 'react-icons/lu';
|
||||||
|
import { requestServerWithFetch } from '@/utils/request';
|
||||||
|
|
||||||
|
// 导入配置
|
||||||
|
const handleImportConfig = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// 检查文件类型
|
||||||
|
if (!file.name.endsWith('.zip')) {
|
||||||
|
toast.error('请选择zip格式的配置文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('configFile', file);
|
||||||
|
|
||||||
|
const response = await requestServerWithFetch('/OB11Config/ImportConfig', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || '导入配置失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
// 检查是否成功导入
|
||||||
|
if (result.code === 0) {
|
||||||
|
toast.success(result.data?.message || '配置导入成功。');
|
||||||
|
} else {
|
||||||
|
toast.error(`配置导入失败: ${result.data?.message || '未知错误'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message;
|
||||||
|
toast.error(`导入配置失败: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
// 重置文件输入
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出配置
|
||||||
|
const handleExportConfig = async () => {
|
||||||
|
try {
|
||||||
|
const response = await requestServerWithFetch('/OB11Config/ExportConfig', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('导出配置失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建下载链接
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
const fileName = response.headers.get('Content-Disposition')?.split('=')[1]?.replace(/"/g, '') || 'config_backup.zip';
|
||||||
|
a.download = fileName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
|
||||||
|
toast.success('配置导出成功');
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message;
|
||||||
|
|
||||||
|
toast.error(`导出配置失败: ${msg}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const BackupConfigCard: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<div>
|
||||||
|
<h3 className='text-lg font-medium mb-4'>备份与恢复</h3>
|
||||||
|
<p className='text-sm text-default-500 mb-4'>
|
||||||
|
您可以通过导入/导出配置文件来备份和恢复NapCat的所有设置
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='flex flex-wrap gap-3'>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className="bg-primary hover:bg-primary/90 text-white"
|
||||||
|
radius='full'
|
||||||
|
onPress={handleExportConfig}
|
||||||
|
title="导出配置"
|
||||||
|
>
|
||||||
|
<LuDownload size={20} />
|
||||||
|
</Button>
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
onChange={handleImportConfig}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className="bg-primary hover:bg-primary/90 text-white"
|
||||||
|
radius='full'
|
||||||
|
as="span"
|
||||||
|
title="导入配置"
|
||||||
|
>
|
||||||
|
<LuUpload size={20} />
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-4 p-3 bg-warning/10 border border-warning/20 rounded-lg'>
|
||||||
|
<div className='flex items-start gap-2'>
|
||||||
|
<p className='text-sm text-warning'>
|
||||||
|
导入配置会覆盖当前所有设置,请谨慎操作。导入前建议先导出当前配置作为备份。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BackupConfigCard;
|
||||||
@@ -13,6 +13,7 @@ import ServerConfigCard from './server';
|
|||||||
import SSLConfigCard from './ssl';
|
import SSLConfigCard from './ssl';
|
||||||
import ThemeConfigCard from './theme';
|
import ThemeConfigCard from './theme';
|
||||||
import WebUIConfigCard from './webui';
|
import WebUIConfigCard from './webui';
|
||||||
|
import BackupConfigCard from './backup';
|
||||||
|
|
||||||
export interface ConfigPageProps {
|
export interface ConfigPageProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -108,6 +109,11 @@ export default function ConfigPage () {
|
|||||||
<ThemeConfigCard />
|
<ThemeConfigCard />
|
||||||
</ConfigPageItem>
|
</ConfigPageItem>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab title='备份与恢复' key='backup'>
|
||||||
|
<ConfigPageItem>
|
||||||
|
<BackupConfigCard />
|
||||||
|
</ConfigPageItem>
|
||||||
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Accordion, AccordionItem } from '@heroui/accordion';
|
|
||||||
import { Button } from '@heroui/button';
|
import { Button } from '@heroui/button';
|
||||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||||
import { Select, SelectItem } from '@heroui/select';
|
import { Select, SelectItem } from '@heroui/select';
|
||||||
import { Chip } from '@heroui/chip';
|
import { Chip } from '@heroui/chip';
|
||||||
|
import { Tab, Tabs } from '@heroui/tabs';
|
||||||
import { useRequest } from 'ahooks';
|
import { useRequest } from 'ahooks';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||||
@@ -162,6 +162,7 @@ const ThemeConfigCard = () => {
|
|||||||
|
|
||||||
const [dataLoaded, setDataLoaded] = useState(false);
|
const [dataLoaded, setDataLoaded] = useState(false);
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
const [customFontExists, setCustomFontExists] = useState(false);
|
||||||
|
|
||||||
// 使用 useRef 存储 style 标签引用和状态
|
// 使用 useRef 存储 style 标签引用和状态
|
||||||
const styleTagRef = useRef<HTMLStyleElement | null>(null);
|
const styleTagRef = useRef<HTMLStyleElement | null>(null);
|
||||||
@@ -213,6 +214,10 @@ const ThemeConfigCard = () => {
|
|||||||
}
|
}
|
||||||
setDataLoaded(true);
|
setDataLoaded(true);
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
|
// 检查自定义字体是否存在
|
||||||
|
FileManager.checkWebUIFontExists().then(exists => {
|
||||||
|
setCustomFontExists(exists);
|
||||||
|
}).catch(err => console.error('Failed to check custom font:', err));
|
||||||
}, [data, setOnebotValue]);
|
}, [data, setOnebotValue]);
|
||||||
|
|
||||||
// 实时应用字体预设(预览)
|
// 实时应用字体预设(预览)
|
||||||
@@ -293,138 +298,180 @@ const ThemeConfigCard = () => {
|
|||||||
<title>主题配置 - NapCat WebUI</title>
|
<title>主题配置 - NapCat WebUI</title>
|
||||||
|
|
||||||
{/* 顶部操作栏 */}
|
{/* 顶部操作栏 */}
|
||||||
<div className='sticky top-0 z-20 bg-background/80 backdrop-blur-md border-b border-divider'>
|
<div className='w-full px-4 pt-4 pb-2'>
|
||||||
<div className='flex items-center justify-between p-4'>
|
<div className='flex items-center justify-between'>
|
||||||
<div className='flex items-center gap-3 flex-wrap'>
|
<div className='flex flex-col gap-1'>
|
||||||
<div className='flex items-center gap-2 text-sm'>
|
<h1 className='text-xl font-bold text-default-900 tracking-tight'>外观设置</h1>
|
||||||
<span className='text-default-400'>当前主题:</span>
|
<div className='flex items-center gap-3 text-tiny text-default-500'>
|
||||||
<Chip size='sm' color='primary' variant='flat'>
|
<div className='flex items-center gap-1.5'>
|
||||||
{savedThemeName || '加载中...'}
|
<IoIosColorPalette className='text-primary' size={16} />
|
||||||
</Chip>
|
<span className='font-medium text-default-700'>{savedThemeName || '加载中...'}</span>
|
||||||
|
</div>
|
||||||
|
<div className='w-px h-2.5 bg-default-300' />
|
||||||
|
<div className='flex items-center gap-1.5'>
|
||||||
|
<FaFont className='text-secondary' size={12} />
|
||||||
|
<span className='font-medium text-default-700'>{savedFontModeDisplayName}</span>
|
||||||
|
</div>
|
||||||
|
{hasUnsavedChanges && (
|
||||||
|
<>
|
||||||
|
<div className='w-px h-2.5 bg-default-300' />
|
||||||
|
<div className='flex items-center gap-1'>
|
||||||
|
<div className='w-1.5 h-1.5 rounded-full bg-warning animate-pulse' />
|
||||||
|
<span className='text-warning font-semibold'>待保存</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-2 text-sm'>
|
|
||||||
<span className='text-default-400'>字体:</span>
|
|
||||||
<Chip size='sm' color='secondary' variant='flat'>
|
|
||||||
{savedFontModeDisplayName}
|
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
{hasUnsavedChanges && (
|
|
||||||
<Chip size='sm' color='warning' variant='solid'>
|
|
||||||
有未保存的更改
|
|
||||||
</Chip>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
radius='full'
|
|
||||||
variant='flat'
|
variant='flat'
|
||||||
className='font-medium bg-default-100 text-default-600 dark:bg-default-50/50'
|
color='default'
|
||||||
|
className='font-medium bg-default-100 hover:bg-default-200 h-9'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
reset();
|
reset();
|
||||||
toast.success('已重置');
|
toast.success('已重置');
|
||||||
}}
|
}}
|
||||||
isDisabled={!hasUnsavedChanges}
|
isDisabled={!hasUnsavedChanges}
|
||||||
>
|
>
|
||||||
取消更改
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
color='primary'
|
color='primary'
|
||||||
radius='full'
|
className='font-medium shadow-lg shadow-primary/20 px-6 h-9'
|
||||||
className='font-medium shadow-md shadow-primary/20'
|
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
onPress={() => onSubmit()}
|
onPress={() => onSubmit()}
|
||||||
isDisabled={!hasUnsavedChanges}
|
isDisabled={!hasUnsavedChanges}
|
||||||
>
|
>
|
||||||
保存
|
保存应用
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className='w-px h-6 bg-divider mx-1 hidden sm:block'></div>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
isIconOnly
|
isIconOnly
|
||||||
radius='full'
|
variant='light'
|
||||||
variant='flat'
|
className='text-default-500 hover:text-default-900 hidden sm:flex'
|
||||||
className='text-default-500 bg-default-100 dark:bg-default-50/50'
|
|
||||||
onPress={onRefresh}
|
onPress={onRefresh}
|
||||||
>
|
>
|
||||||
<IoMdRefresh size={18} />
|
<IoMdRefresh size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='p-4'>
|
<div className='px-4 pt-0 pb-4 w-full h-full'>
|
||||||
<Accordion variant='splitted' defaultExpandedKeys={['font', 'select']}>
|
<Tabs
|
||||||
<AccordionItem
|
aria-label="Theme Config Options"
|
||||||
key='font'
|
color="primary"
|
||||||
aria-label='Font Settings'
|
variant="underlined"
|
||||||
title='字体设置'
|
disableAnimation
|
||||||
subtitle='自定义WebUI显示的字体'
|
classNames={{
|
||||||
className='shadow-small'
|
tabList: "gap-8 w-full relative rounded-none p-0 border-b border-divider overflow-x-auto no-scrollbar",
|
||||||
startContent={<FaFont />}
|
cursor: "w-full bg-primary h-[3px] -bottom-[1.5px]",
|
||||||
>
|
tab: "max-w-fit px-0 h-12 hover:opacity-100 opacity-70 data-[selected=true]:opacity-100",
|
||||||
<div className='flex flex-col gap-4'>
|
tabContent: "font-semibold py-2",
|
||||||
<Controller
|
panel: "py-4"
|
||||||
control={control}
|
}}
|
||||||
name='theme.fontMode'
|
>
|
||||||
render={({ field }) => (
|
<Tab
|
||||||
<Select
|
key="font"
|
||||||
label='字体预设'
|
title={
|
||||||
selectedKeys={field.value ? [field.value] : ['aacute']}
|
<div className="flex items-center space-x-2">
|
||||||
onChange={(e) => field.onChange(e.target.value)}
|
<FaFont />
|
||||||
className='max-w-xs'
|
<span>字体设置</span>
|
||||||
disallowEmptySelection
|
|
||||||
>
|
|
||||||
<SelectItem key='aacute'>Aa 偷吃可爱长大的</SelectItem>
|
|
||||||
<SelectItem key='system'>系统默认</SelectItem>
|
|
||||||
<SelectItem key='custom'>自定义字体</SelectItem>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className='p-3 rounded-lg bg-default-100 dark:bg-default-50/30'>
|
|
||||||
<div className='text-sm text-default-500 mb-2'>
|
|
||||||
上传自定义字体(仅在选择"自定义字体"时生效)
|
|
||||||
</div>
|
|
||||||
<FileInput
|
|
||||||
label='上传字体文件'
|
|
||||||
placeholder='选择字体文件 (.woff/.woff2/.ttf/.otf)'
|
|
||||||
accept='.ttf,.otf,.woff,.woff2'
|
|
||||||
onChange={async (file) => {
|
|
||||||
try {
|
|
||||||
await FileManager.uploadWebUIFont(file);
|
|
||||||
toast.success('上传成功,即将刷新页面');
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 1000);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('上传失败: ' + (error as Error).message);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDelete={async () => {
|
|
||||||
try {
|
|
||||||
await FileManager.deleteWebUIFont();
|
|
||||||
toast.success('删除成功,即将刷新页面');
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 1000);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('删除失败: ' + (error as Error).message);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem
|
|
||||||
key='select'
|
|
||||||
aria-label='Pick Color'
|
|
||||||
title='选择主题'
|
|
||||||
subtitle='点击主题卡片即可预览,记得保存'
|
|
||||||
className='shadow-small'
|
|
||||||
startContent={<IoIosColorPalette />}
|
|
||||||
>
|
>
|
||||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3'>
|
<Card className='shadow-sm border border-default-100 bg-background/60 backdrop-blur-md w-full'>
|
||||||
|
<CardBody className='p-6'>
|
||||||
|
<div className='flex flex-col gap-6 w-full'>
|
||||||
|
<div>
|
||||||
|
<h3 className='text-lg font-medium mb-1'>WebUI 字体</h3>
|
||||||
|
<p className='text-sm text-default-500 mb-4'>自定义界面显示的字体风格</p>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name='theme.fontMode'
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
label='选择字体'
|
||||||
|
variant='bordered'
|
||||||
|
selectedKeys={field.value ? [field.value] : ['aacute']}
|
||||||
|
onChange={(e) => field.onChange(e.target.value)}
|
||||||
|
className='max-w-xs'
|
||||||
|
disallowEmptySelection
|
||||||
|
>
|
||||||
|
<SelectItem key='aacute'>Aa 偷吃可爱长大的</SelectItem>
|
||||||
|
<SelectItem key='system'>系统默认</SelectItem>
|
||||||
|
<SelectItem key='custom'>自定义字体</SelectItem>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{theme.fontMode === 'custom' && (
|
||||||
|
<div className='p-4 rounded-xl bg-default-50 border border-default-100'>
|
||||||
|
<div className='flex items-center justify-between mb-4'>
|
||||||
|
<div className='text-sm font-medium'>自定义字体文件</div>
|
||||||
|
{customFontExists && (
|
||||||
|
<Chip size='sm' color='success' variant='flat' startContent={<FaCheck size={10} />}>
|
||||||
|
已上传
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileInput
|
||||||
|
label='上传字体文件'
|
||||||
|
placeholder='拖拽或点击上传 (.woff/.woff2/.ttf/.otf)'
|
||||||
|
accept='.ttf,.otf,.woff,.woff2'
|
||||||
|
onChange={async (file) => {
|
||||||
|
try {
|
||||||
|
if (customFontExists) {
|
||||||
|
try {
|
||||||
|
await FileManager.deleteWebUIFont();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to delete existing font before upload:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await FileManager.uploadWebUIFont(file);
|
||||||
|
toast.success('上传成功,即将刷新页面');
|
||||||
|
setTimeout(() => window.location.reload(), 1000);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('上传失败: ' + (error as Error).message);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDelete={async () => {
|
||||||
|
try {
|
||||||
|
await FileManager.deleteWebUIFont();
|
||||||
|
toast.success('删除成功,即将刷新页面');
|
||||||
|
setTimeout(() => window.location.reload(), 1000);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('删除失败: ' + (error as Error).message);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className='text-xs text-default-400 mt-2'>
|
||||||
|
注意:上传新字体会覆盖旧字体文件,更改后需要刷新页面生效。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</Tab>
|
||||||
|
|
||||||
|
<Tab
|
||||||
|
key="theme"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<IoIosColorPalette size={18} />
|
||||||
|
<span>选择主题</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4'>
|
||||||
{themes.map((t) => (
|
{themes.map((t) => (
|
||||||
<PreviewThemeCard
|
<PreviewThemeCard
|
||||||
key={t.name}
|
key={t.name}
|
||||||
@@ -436,64 +483,77 @@ const ThemeConfigCard = () => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AccordionItem>
|
</Tab>
|
||||||
|
|
||||||
<AccordionItem
|
<Tab
|
||||||
key='pick'
|
key="custom-color"
|
||||||
aria-label='Pick Color'
|
title={
|
||||||
title='自定义配色'
|
<div className="flex items-center space-x-2">
|
||||||
subtitle='精细调整每个颜色变量'
|
<FaPaintbrush />
|
||||||
className='shadow-small'
|
<span>自定义配色</span>
|
||||||
startContent={<FaPaintbrush />}
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className='space-y-4'>
|
<div className='grid grid-cols-1 lg:grid-cols-2 gap-6'>
|
||||||
{(['light', 'dark'] as const).map((mode) => (
|
{(['light', 'dark'] as const).map((mode) => (
|
||||||
<div
|
<Card key={mode} className={clsx('border shadow-sm', mode === 'dark' ? 'bg-[#18181b] border-zinc-800' : 'bg-white border-zinc-200')}>
|
||||||
key={mode}
|
<CardHeader className='pb-0 pt-4 px-4 flex-col items-start'>
|
||||||
className={clsx(
|
<div className='flex items-center gap-2 mb-1'>
|
||||||
'p-4 rounded-lg',
|
{mode === 'dark' ? <MdDarkMode className="text-zinc-400" size={20} /> : <MdLightMode className="text-orange-400" size={20} />}
|
||||||
mode === 'dark' ? 'bg-zinc-900 text-white' : 'bg-zinc-100 text-black'
|
<h4 className={clsx('font-bold text-large', mode === 'dark' ? 'text-white' : 'text-black')}>
|
||||||
)}
|
{mode === 'dark' ? '深色模式' : '浅色模式'}
|
||||||
>
|
</h4>
|
||||||
<h3 className='flex items-center justify-center gap-2 p-2 rounded-md bg-opacity-20 mb-4 font-medium'>
|
</div>
|
||||||
{mode === 'dark' ? <MdDarkMode size={20} /> : <MdLightMode size={20} />}
|
<p className={clsx('text-tiny', mode === 'dark' ? 'text-zinc-400' : 'text-zinc-500')}>
|
||||||
{mode === 'dark' ? '深色模式' : '浅色模式'}
|
调整{mode === 'dark' ? '深色' : '浅色'}主题下的颜色变量
|
||||||
</h3>
|
</p>
|
||||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
|
</CardHeader>
|
||||||
{colorKeys.map((colorKey) => (
|
<CardBody className='p-4'>
|
||||||
<div
|
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
|
||||||
key={colorKey}
|
{colorKeys.map((colorKey) => (
|
||||||
className='flex items-center gap-2 p-2 rounded bg-black/5 dark:bg-white/5'
|
<div
|
||||||
>
|
key={colorKey}
|
||||||
<Controller
|
className={clsx(
|
||||||
control={control}
|
'flex items-center gap-3 p-2 rounded-lg border transition-colors',
|
||||||
name={`theme.${mode}.${colorKey}`}
|
mode === 'dark' ? 'bg-zinc-900/50 border-zinc-800 hover:bg-zinc-900' : 'bg-zinc-50 border-zinc-100 hover:bg-zinc-100'
|
||||||
render={({ field: { value, onChange } }) => {
|
)}
|
||||||
const hslArray = value?.split(' ') ?? [0, 0, 0];
|
>
|
||||||
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
|
<Controller
|
||||||
return (
|
control={control}
|
||||||
<ColorPicker
|
name={`theme.${mode}.${colorKey}`}
|
||||||
color={color}
|
render={({ field: { value, onChange } }) => {
|
||||||
onChange={(result) => {
|
const hslArray = value?.split(' ') ?? [0, 0, 0];
|
||||||
onChange(
|
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
|
||||||
`${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%`
|
return (
|
||||||
);
|
<ColorPicker
|
||||||
}}
|
color={color}
|
||||||
/>
|
onChange={(hslString) => {
|
||||||
);
|
const match = hslString.match(/hsl\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)%,\s*(\d+(?:\.\d+)?)%\)/);
|
||||||
}}
|
if (match) {
|
||||||
/>
|
onChange(`${match[1]} ${match[2]}% ${match[3]}%`);
|
||||||
<span className='text-xs font-mono truncate flex-1' title={colorKey}>
|
}
|
||||||
{colorKey.replace('--heroui-', '')}
|
}}
|
||||||
</span>
|
/>
|
||||||
</div>
|
);
|
||||||
))}
|
}}
|
||||||
</div>
|
/>
|
||||||
</div>
|
<div className='flex flex-col overflow-hidden'>
|
||||||
|
<span className={clsx('text-xs font-medium truncate', mode === 'dark' ? 'text-zinc-300' : 'text-zinc-700')}>
|
||||||
|
{colorKey.replace('--heroui-', '')}
|
||||||
|
</span>
|
||||||
|
<span className={clsx('text-[10px] truncate', mode === 'dark' ? 'text-zinc-500' : 'text-zinc-400')}>
|
||||||
|
Variable
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AccordionItem>
|
</Tab>
|
||||||
</Accordion>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function ExtensionPage () {
|
|||||||
pluginName: page.pluginName,
|
pluginName: page.pluginName,
|
||||||
path: page.path,
|
path: page.path,
|
||||||
icon: page.icon,
|
icon: page.icon,
|
||||||
description: page.description
|
description: page.description,
|
||||||
}));
|
}));
|
||||||
}, [extensionPages]);
|
}, [extensionPages]);
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export default function ExtensionPage () {
|
|||||||
const path = pathParts.join(':').replace(/^\//, '');
|
const path = pathParts.join(':').replace(/^\//, '');
|
||||||
// 获取认证 token
|
// 获取认证 token
|
||||||
const token = localStorage.getItem('token') || '';
|
const token = localStorage.getItem('token') || '';
|
||||||
return `/api/Plugin/page/${pluginId}/${path}?webui_token=${encodeURIComponent(token)}`;
|
return `/api/Plugin/page/${pluginId}/${path}?webui_token=${token}`;
|
||||||
}, [selectedTab]);
|
}, [selectedTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,71 +89,72 @@ export default function ExtensionPage () {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>扩展页面 - NapCat WebUI</title>
|
<title>扩展页面 - NapCat WebUI</title>
|
||||||
<div className="p-2 md:p-4 relative h-full flex flex-col">
|
<div className='p-2 md:p-4 relative h-[calc(100vh-6rem)] md:h-[calc(100vh-4rem)] flex flex-col'>
|
||||||
<PageLoading loading={loading} />
|
<PageLoading loading={loading} />
|
||||||
|
|
||||||
<div className="flex mb-4 items-center gap-4">
|
<div className='flex mb-4 items-center justify-between gap-4 flex-wrap'>
|
||||||
<div className="flex items-center gap-2 text-default-600">
|
<div className='flex items-center gap-4'>
|
||||||
<MdExtension size={24} />
|
<div className='flex items-center gap-2 text-default-600'>
|
||||||
<span className="text-lg font-medium">插件扩展页面</span>
|
<MdExtension size={24} />
|
||||||
|
<span className='text-lg font-medium'>插件扩展页面</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
|
||||||
|
radius='full'
|
||||||
|
onPress={refresh}
|
||||||
|
>
|
||||||
|
<IoMdRefresh size={24} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{extensionPages.length > 0 && (
|
||||||
isIconOnly
|
|
||||||
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>
|
|
||||||
|
|
||||||
{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
|
<Tabs
|
||||||
aria-label="Extension Pages"
|
aria-label='Extension Pages'
|
||||||
className="max-w-full"
|
className='max-w-full'
|
||||||
selectedKey={selectedTab}
|
selectedKey={selectedTab}
|
||||||
onSelectionChange={(key) => setSelectedTab(key as string)}
|
onSelectionChange={(key) => setSelectedTab(key as string)}
|
||||||
classNames={{
|
classNames={{
|
||||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md flex-wrap',
|
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
|
||||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
||||||
panel: 'flex-1 min-h-0 p-0'
|
panel: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-2">
|
<div className='flex items-center gap-2'>
|
||||||
{tab.icon && <span>{tab.icon}</span>}
|
{tab.icon && <span>{tab.icon}</span>}
|
||||||
<span>{tab.title}</span>
|
<span>{tab.title}</span>
|
||||||
<span className="text-xs text-default-400">({tab.pluginName})</span>
|
<span className='text-xs text-default-400'>({tab.pluginName})</span>
|
||||||
</div>
|
</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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ export default function PluginStorePage () {
|
|||||||
const [pendingInstallPlugin, setPendingInstallPlugin] = useState<PluginStoreItem | null>(null);
|
const [pendingInstallPlugin, setPendingInstallPlugin] = useState<PluginStoreItem | null>(null);
|
||||||
const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(undefined);
|
const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const loadPlugins = async () => {
|
const loadPlugins = async (forceRefresh: boolean = false) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await PluginManager.getPluginStoreList();
|
const data = await PluginManager.getPluginStoreList(forceRefresh);
|
||||||
setPlugins(data.plugins);
|
setPlugins(data.plugins);
|
||||||
|
|
||||||
// 检查插件管理器是否已加载
|
// 检查插件管理器是否已加载
|
||||||
@@ -238,7 +238,7 @@ export default function PluginStorePage () {
|
|||||||
isIconOnly
|
isIconOnly
|
||||||
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
||||||
radius="full"
|
radius="full"
|
||||||
onPress={loadPlugins}
|
onPress={() => loadPlugins(true)}
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
>
|
>
|
||||||
<IoMdRefresh size={24} />
|
<IoMdRefresh size={24} />
|
||||||
@@ -287,7 +287,7 @@ export default function PluginStorePage () {
|
|||||||
<Spinner size='lg' />
|
<Spinner size='lg' />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
aria-label="Plugin Store Categories"
|
aria-label="Plugin Store Categories"
|
||||||
className="max-w-full"
|
className="max-w-full"
|
||||||
|
|||||||
@@ -180,11 +180,14 @@ export const applyFont = (mode: string) => {
|
|||||||
|
|
||||||
if (mode === 'aacute') {
|
if (mode === 'aacute') {
|
||||||
root.style.setProperty('--font-family-base', "'Aa偷吃可爱长大的', var(--font-family-fallbacks)", 'important');
|
root.style.setProperty('--font-family-base', "'Aa偷吃可爱长大的', var(--font-family-fallbacks)", 'important');
|
||||||
|
root.style.setProperty('--font-family-mono', "'Aa偷吃可爱长大的', var(--font-family-fallbacks)", 'important');
|
||||||
} else if (mode === 'custom') {
|
} else if (mode === 'custom') {
|
||||||
root.style.setProperty('--font-family-base', "'CustomFont', var(--font-family-fallbacks)", 'important');
|
root.style.setProperty('--font-family-base', "'CustomFont', var(--font-family-fallbacks)", 'important');
|
||||||
|
root.style.setProperty('--font-family-mono', "'CustomFont', var(--font-family-fallbacks)", 'important');
|
||||||
} else {
|
} else {
|
||||||
// system or default - restore default
|
// system or default - restore default
|
||||||
root.style.setProperty('--font-family-base', 'var(--font-family-fallbacks)', 'important');
|
root.style.setProperty('--font-family-base', 'var(--font-family-fallbacks)', 'important');
|
||||||
|
root.style.setProperty('--font-family-mono', 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', 'important');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
51
pnpm-lock.yaml
generated
51
pnpm-lock.yaml
generated
@@ -354,6 +354,9 @@ importers:
|
|||||||
napcat-core:
|
napcat-core:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../napcat-core
|
version: link:../napcat-core
|
||||||
|
napcat-image-size:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../napcat-image-size
|
||||||
devDependencies:
|
devDependencies:
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.0.9
|
specifier: ^4.0.9
|
||||||
@@ -634,9 +637,6 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.0
|
version: 19.2.0
|
||||||
react-color:
|
|
||||||
specifier: ^2.19.3
|
|
||||||
version: 2.19.3(react@19.2.0)
|
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.0(react@19.2.0)
|
version: 19.2.0(react@19.2.0)
|
||||||
@@ -1859,11 +1859,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
|
|
||||||
'@icons/material@0.2.4':
|
|
||||||
resolution: {integrity: sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==}
|
|
||||||
peerDependencies:
|
|
||||||
react: '*'
|
|
||||||
|
|
||||||
'@img/colour@1.0.0':
|
'@img/colour@1.0.0':
|
||||||
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
|
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -5154,9 +5149,6 @@ packages:
|
|||||||
markdown-table@3.0.4:
|
markdown-table@3.0.4:
|
||||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||||
|
|
||||||
material-colors@1.2.6:
|
|
||||||
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
|
||||||
|
|
||||||
math-intrinsics@1.1.0:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -5859,11 +5851,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
react-color@2.19.3:
|
|
||||||
resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==}
|
|
||||||
peerDependencies:
|
|
||||||
react: '*'
|
|
||||||
|
|
||||||
react-dom@19.2.0:
|
react-dom@19.2.0:
|
||||||
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
|
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -5969,11 +5956,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
reactcss@1.2.3:
|
|
||||||
resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==}
|
|
||||||
peerDependencies:
|
|
||||||
react: '*'
|
|
||||||
|
|
||||||
read-cache@1.0.0:
|
read-cache@1.0.0:
|
||||||
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
||||||
|
|
||||||
@@ -6488,9 +6470,6 @@ packages:
|
|||||||
tinybench@2.9.0:
|
tinybench@2.9.0:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
tinycolor2@1.6.0:
|
|
||||||
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
|
|
||||||
|
|
||||||
tinyexec@0.3.2:
|
tinyexec@0.3.2:
|
||||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||||
|
|
||||||
@@ -8382,10 +8361,6 @@ snapshots:
|
|||||||
|
|
||||||
'@humanwhocodes/retry@0.4.3': {}
|
'@humanwhocodes/retry@0.4.3': {}
|
||||||
|
|
||||||
'@icons/material@0.2.4(react@19.2.0)':
|
|
||||||
dependencies:
|
|
||||||
react: 19.2.0
|
|
||||||
|
|
||||||
'@img/colour@1.0.0': {}
|
'@img/colour@1.0.0': {}
|
||||||
|
|
||||||
'@img/sharp-darwin-arm64@0.34.5':
|
'@img/sharp-darwin-arm64@0.34.5':
|
||||||
@@ -12298,8 +12273,6 @@ snapshots:
|
|||||||
|
|
||||||
markdown-table@3.0.4: {}
|
markdown-table@3.0.4: {}
|
||||||
|
|
||||||
material-colors@1.2.6: {}
|
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
mdast-util-find-and-replace@3.0.2:
|
mdast-util-find-and-replace@3.0.2:
|
||||||
@@ -13214,17 +13187,6 @@ snapshots:
|
|||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
strip-json-comments: 2.0.1
|
strip-json-comments: 2.0.1
|
||||||
|
|
||||||
react-color@2.19.3(react@19.2.0):
|
|
||||||
dependencies:
|
|
||||||
'@icons/material': 0.2.4(react@19.2.0)
|
|
||||||
lodash: 4.17.21
|
|
||||||
lodash-es: 4.17.21
|
|
||||||
material-colors: 1.2.6
|
|
||||||
prop-types: 15.8.1
|
|
||||||
react: 19.2.0
|
|
||||||
reactcss: 1.2.3(react@19.2.0)
|
|
||||||
tinycolor2: 1.6.0
|
|
||||||
|
|
||||||
react-dom@19.2.0(react@19.2.0):
|
react-dom@19.2.0(react@19.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
@@ -13329,11 +13291,6 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.0: {}
|
react@19.2.0: {}
|
||||||
|
|
||||||
reactcss@1.2.3(react@19.2.0):
|
|
||||||
dependencies:
|
|
||||||
lodash: 4.17.21
|
|
||||||
react: 19.2.0
|
|
||||||
|
|
||||||
read-cache@1.0.0:
|
read-cache@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
pify: 2.3.0
|
pify: 2.3.0
|
||||||
@@ -14023,8 +13980,6 @@ snapshots:
|
|||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
tinycolor2@1.6.0: {}
|
|
||||||
|
|
||||||
tinyexec@0.3.2: {}
|
tinyexec@0.3.2: {}
|
||||||
|
|
||||||
tinyglobby@0.2.15:
|
tinyglobby@0.2.15:
|
||||||
|
|||||||
@@ -6,9 +6,6 @@
|
|||||||
"lib": [
|
"lib": [
|
||||||
"ES2021"
|
"ES2021"
|
||||||
],
|
],
|
||||||
"typeRoots": [
|
|
||||||
"./node_modules/@types"
|
|
||||||
],
|
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user