Compare commits

...

25 Commits

Author SHA1 Message Date
手瓜一十雪
f6c79370cb Replace Type.Void() with Type.Object({})
Replace Type.Void() payload/return schemas with Type.Object({}) in several OneBot action extensions to represent empty object schemas and avoid void-type validation issues. Updated files: BotExit (payload & return), GetClientkey, GetFriendWithCategory, GetGroupAddRequest, GetRkey, GetRobotUinRange, GetUnidirectionalFriendList.
2026-02-02 13:17:06 +08:00
手瓜一十雪
39460e4acb Rename image-size alias to napcat-image-size
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Update Vite path alias from '@/image-size' to '@/napcat-image-size' in napcat-framework and napcat-shell vite.config.ts files to match the package directory ../napcat-image-size and ensure consistent import paths.
2026-02-01 17:46:40 +08:00
手瓜一十雪
f971c312b9 Add TIFF parser and buffer-based image size APIs
Introduce a TIFF parser and integrate it into the image detection/size parsing pipeline. Add buffer-based APIs (detectImageTypeFromBuffer, imageSizeFromBuffer, imageSizeFromBufferFallBack) and a helper to convert Buffer to a Readable stream; refactor parser registry into a type-to-parser map and a first-byte fast-path map for quicker detection. Harden WebP parsing with safer length checks. Add sample image resources and a comprehensive Vitest test suite (packages/napcat-test) with updated package dependency and resolve aliases. pnpm-lock updated to link the new package.
2026-02-01 17:42:58 +08:00
手瓜一十雪
2c3a304440 Split image parsers into separate files
Move PNG/JPEG/BMP/GIF/WEBP parser implementations out of index.ts into dedicated files under packages/napcat-image-size/src/parser. Export ImageParser and matchMagic from index.ts, import the new BmpParser, GifParser, JpegParser, PngParser and WebpParser classes, and update the parsers array to instantiate those classes. Keeps existing parsing logic and stream-based size detection while cleaning up index.ts for modularity.
2026-02-01 17:13:19 +08:00
手瓜一十雪
286b0e03f7 Add node types to package tsconfig; drop base typeRoots
Enable Node typings for packages/napcat-image-size by adding "compilerOptions.types": ["node"] to its tsconfig.json. Remove the restrictive "typeRoots" entry from tsconfig.base.json so type resolution can be controlled per-package and won't be forced globally.
2026-02-01 17:08:46 +08:00
手瓜一十雪
447f86e2b5 Use tabs, redesign theme settings UI
Replace the Accordion-based layout with a Tabs component and overhaul the Theme settings page UI. Rework the top header to a compact title/status area showing current theme, font and unsaved state; restyle action buttons and refresh icon. Convert font settings into a Card with improved FileInput flow (attempt delete before upload, better success/error toasts and page reload), and present theme previews and custom color editors as Cards per light/dark mode with updated ColorPicker handling. Update imports accordingly and apply various layout / class refinements.
2026-02-01 14:53:00 +08:00
手瓜一十雪
0592f1a99a Replace react-color with custom HSL ColorPicker
Remove the react-color dependency and add a custom ColorPicker implementation. The new component uses HSL parsing and hex<->HSL conversion, provides SatLightPanel and HueSlider subcomponents with mouse/touch drag support, and integrates @heroui/input for inline HEX/HSL editing. The ColorPicker onChange now emits an "hsl(...)" string; theme config parsing was updated to convert that string into the existing "h s% l%" format. Also update package.json to drop react-color.
2026-02-01 14:22:52 +08:00
手瓜一十雪
90e3936204 Support custom WebUI fonts and UI additions
Backend: add CheckWebUIFontExist API and route; set --font-family-mono CSS variable in InitWebUi for aacute/custom/default. Improve webui font uploader: force saved filename to CustomFont, robustly clean old webui/CustomFont files, and log failures.

Frontend: add FileManager.checkWebUIFontExists; update theme settings to show upload UI only when 'custom' is selected, display uploaded status, attempt delete-before-upload, reload after actions, and adjust Accordion props. ColorPicker: enable pointer events on PopoverContent to allow dragging. applyFont now sets --font-family-mono for all modes.
2026-02-01 14:00:27 +08:00
Qiao
1239f622d2 feat(webui): 插件卡片添加仓库主页跳转功能 (#1569)
* 为插件接口添加主页字段并优化展示组件

本次更新在 PluginPackageJson 接口及相关类型中新增了一个可选的 `homepage` 字段,允许插件指定其主页 URL。插件展示组件已更新,新增了一个指向主页的 GitHub 链接按钮,以提升用户对插件资源的访问便捷性。此外,PluginConfigModal 中新增了一个问题反馈按钮,该按钮直接链接到插件的主页,从而优化了用户支持与反馈机制。

* 优化标题区域样式,确保长标题正确截断显示省略号

* 移除插件相关接口中的可选主页字段,并优化展示组件以简化代码结构。更新了插件展示卡片的样式,确保更好的用户体验。

* 修改 PluginStoreCard 组件,新增 displayId 优化包名展示,并调整卡片样式以提升响应式表现。更新不同屏幕尺寸的最大宽度设置,确保包名截断显示且悬停可查看完整内容。

* Revert "修改 PluginStoreCard 组件,新增 displayId 优化包名展示,并调整卡片样式以提升响应式表现。更新不同屏幕尺寸的最大宽度设置,确保包名截断显示且悬停可查看完整内容。"

This reverts commit 0301421bc8.

* Revert "移除插件相关接口中的可选主页字段,并优化展示组件以简化代码结构。更新了插件展示卡片的样式,确保更好的用户体验。"

This reverts commit 1d22f19fa6.

* Revert "优化标题区域样式,确保长标题正确截断显示省略号"

This reverts commit 8a0912b5b9.

* Revert "为插件接口添加主页字段并优化展示组件"

This reverts commit 4e5dddde90.

* 再说丑我打死你

---------

Co-authored-by: 手瓜一十雪 <nanaeonn@outlook.com>
2026-02-01 13:47:18 +08:00
手瓜一十雪
d511e2bb3f Load .env, prefer WEBUI secret, add build script
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Load local .env in napcat-shell and prioritize NAPCAT_WEBUI_SECRET_KEY across the app. Changes include:
- package.json: add build:shell:config script to build shell and copy env for dev builds.
- packages/napcat-develop/config/.env and loadNapCat.cjs: update default WEBUI secret to `napcatqq` and set the same env in the loader.
- packages/napcat-shell/napcat.ts: read config/.env into process.env at startup, rework pathWrapper/logger initialization, and minor formatting cleanups.
- packages/napcat-webui-backend/index.ts: InitWebUi now prefers NAPCAT_WEBUI_SECRET_KEY from the environment (updates config and logs the change); only generates a random token if no env override and current token is default.
These changes make it easier to override the WebUI token for development and ensure the token is propagated when building the shell for dev workflows.
2026-02-01 11:24:25 +08:00
手瓜一十雪
ff93aa3dc7 Add dev config and copy-env build script
Add development config files and wiring to include them in the shell build. Creates packages/napcat-develop/config/.env and packages/napcat-develop/config/onebot11.json, adds a root script `build:shell:config` that builds napcat-shell and then runs napcat-develop's `copy-env` script. Update packages/napcat-develop/package.json to add the `copy-env` script (uses xcopy to copy config into the napcat-shell dist), tidy exports and metadata. This ensures dev configuration is packaged into napcat-shell during the build process.
2026-02-01 11:01:53 +08:00
手瓜一十雪
cc8891b6a1 fix: #1575 2026-02-01 10:33:58 +08:00
手瓜一十雪
7c65b1eaf1 Revert "增加个网络配置导出导入 (#1567)"
This reverts commit c0bcced5fb.
2026-02-01 10:22:15 +08:00
香草味的纳西妲喵
ebe3e9c63c feat(webui): 新增配置全量备份与恢复功能。 (#1571)
* feat(webui): 新增配置全量备份与恢复功能。

* chore: Remove dependencies "archiver"

* feat(webui): 增加上传文件大小限制配置并优化上传处理

* Use memory-based zip import/export and multer

Replace disk-based zip handling with in-memory streaming to avoid temp files: remove unzipper/@types(unzipper) deps from package.json; update BackupConfig to stream-export configs with compressing.zip.Stream and to import by extracting uploaded zip buffer via compressing.zip.UncompressStream into in-memory Buffers. Backup of existing config is kept in-memory instead of copying to tmp, and imported files are written with path normalization checks. Router changed to use multer.memoryStorage() for uploads (remove dynamic tmp/disk upload logic and uploadSizeLimit usage). Also remove uploadSizeLimit from config schema.

* Revert "chore: Remove dependencies "archiver""

This reverts commit 890736d3c7.

* Regenerate pnpm-lock.yaml (prune entries)

Regenerated pnpm-lock.yaml to reflect the current dependency resolution. This update prunes many removed/unused lock entries (notably archiver, unzipper and related @types, older/deprecated packages such as rimraf v2/fstream/bluebird, etc.) and removes platform 'libc' metadata from several platform-specific packages. There are no package.json changes; run `pnpm install` to sync your local node_modules with the updated lockfile.

---------

Co-authored-by: 手瓜一十雪 <nanaeonn@outlook.com>
2026-02-01 10:21:19 +08:00
冷曦
d33a872c42 修改合并消息上传资源日志 (#1573)
当上传资源有失败时为warn
全部成功则不输出日志
2026-02-01 09:53:40 +08:00
手瓜一十雪
9377dc3d52 Update version keys to 9.9.27-45627
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Rename release keys for build 45627 to 9.9.27-45627 across external metadata. Updated keys in packages/napcat-core/external/appid.json, napi2native.json, and packet.json (including x64 entries). No other payload values were modified.
2026-01-31 22:04:08 +08:00
手瓜一十雪
17322bb5a4 Add mappings for 9.9.26-45627 and 6.9.88-44725
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Add support for new client builds by updating external mappings. appid.json: add 9.9.26-45627 (appid 537340060) and 6.9.88-44725 (appid 537337594) with QUA strings. napi2native.json: add send/recv entries for 9.9.26-45627-x64, 6.9.88-44725-x64 and 6.9.88-44725-arm64. packet.json: add corresponding send/recv offsets for the same builds/architectures. These additions enable handling of the new versions in napcat-core.
2026-01-31 15:55:36 +08:00
冷曦
c0bcced5fb 增加个网络配置导出导入 (#1567)
* 增加个网络配置导出导入

重装容器时可以直接导出导入

* Remove unused import for useRef in network.tsx
2026-01-31 15:28:18 +08:00
手瓜一十雪
805c1d5ea2 Default plugins disabled; skip loading disabled
Change plugin loader to treat plugins as disabled by default (unless the id/dir is 'napcat-plugin-builtin') by using nullish coalescing when reading statusConfig. Add an early-return guard in the plugin manager/adapter that logs and skips loading when entry.enable is false. This prevents disabled plugins from being loaded automatically and provides a clear log message when skipped.
2026-01-31 15:26:56 +08:00
手瓜一十雪
b3399b07ad Silence update log; change update UI colors
Comment out the noisy '[NapCat Update] No pending updates found' log in UpdateNapCat.ts. Update frontend color choices: switch the plugin store action color from 'success' to 'default', and change the NewVersion chip and spinner from 'danger' to 'primary' in system_info.tsx. These tweaks reduce alarming red styling and quiet an unnecessary backend log.
2026-01-31 15:15:01 +08:00
手瓜一十雪
71f8504849 Refactor extension page layout and tab handling
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Improves the layout of the extension page by adjusting container heights and restructuring the header to better support responsive design. Moves the tab navigation to the header and displays only the selected extension page in the main content area, simplifying the rendering logic and improving user experience.
2026-01-30 19:41:27 +08:00
手瓜一十雪
3b7ca1a08f Remove flex-wrap from tabList class in ExtensionPage
The 'flex-wrap' class was removed from the tabList classNames in the ExtensionPage component, likely to prevent tab items from wrapping onto multiple lines and to maintain a single-line tab layout.
2026-01-30 19:35:46 +08:00
手瓜一十雪
57f3c4dd31 Support nested innerPacketMsg in SendMsgBase
Adds handling for innerPacketMsg arrays within uploadReturnData, allowing nested packet messages to be included in the result. This change ensures that all relevant inner messages are processed and returned.
2026-01-30 19:25:01 +08:00
时瑾
5b20ebb7b0 fix: webui 随机token仅生成不会被url编码的随机字符 (#1565)
* fix: webui 随机token仅生成不会被url编码的随机字符

* fix: 移除调试模块中的encodeURIComponent
2026-01-30 18:51:13 +08:00
手瓜一十雪
3a3eaeec7c Add UploadForwardMsgV2 support for multi-message forwarding
Introduces UploadForwardMsgV2 transformer and integrates it into the message sending flow to support forwarding multiple messages with custom action commands. Updates related interfaces and logic to handle UUIDs and nested forwarded messages, improving flexibility and extensibility for message forwarding operations.
2026-01-30 18:47:45 +08:00
65 changed files with 2244 additions and 697 deletions

View File

@@ -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",

View File

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

View File

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

View File

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

View File

@@ -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,
);
} }
} }

View File

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

View File

@@ -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();

View File

@@ -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';

View 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

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

View File

@@ -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);
} }

View File

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

View File

@@ -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'),
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 962 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -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;
}

View 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);
}
});
});
}
}

View 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);
}
});
});
}
}

View 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);
}
});
});
}
}

View 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);
}
});
});
}
}

View 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;
}
}

View 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);
}
});
});
}
}

View File

@@ -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 = {};

View File

@@ -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';

View File

@@ -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 = ['用户扩展'];

View File

@@ -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 = ['群组接口'];

View File

@@ -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 = ['系统扩展'];

View File

@@ -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 () {

View File

@@ -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 = ['用户扩展'];

View File

@@ -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,
}; };
} }

View File

@@ -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);
} }

View File

@@ -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',

View File

@@ -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);
} }

View File

@@ -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);

View File

@@ -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'),
}, },
}, },

View 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 });
});
});
});

View File

@@ -11,6 +11,7 @@
"vitest": "^4.0.9" "vitest": "^4.0.9"
}, },
"dependencies": { "dependencies": {
"napcat-core": "workspace:*" "napcat-core": "workspace:*",
"napcat-image-size": "workspace:*"
} }
} }

View File

@@ -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'),
}, },
}, },
}); });

View File

@@ -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 += '}';

View 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}`);
}
};

View File

@@ -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, '检查字体文件失败');
}
};

View File

@@ -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);

View File

@@ -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;
} }

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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}`);
}, },
}); });

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;

View File

@@ -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' && (

View File

@@ -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));

View File

@@ -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>

View File

@@ -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;
}
} }

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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>
); );

View File

@@ -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>
</> </>
); );

View File

@@ -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>

View File

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

View File

@@ -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
View File

@@ -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:

View File

@@ -6,9 +6,6 @@
"lib": [ "lib": [
"ES2021" "ES2021"
], ],
"typeRoots": [
"./node_modules/@types"
],
"esModuleInterop": true, "esModuleInterop": true,
"outDir": "dist", "outDir": "dist",
"noEmit": false, "noEmit": false,