Compare commits

..

13 Commits

Author SHA1 Message Date
手瓜一十雪
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
19 changed files with 527 additions and 91 deletions

View File

@@ -518,5 +518,13 @@
"9.9.26-44725": {
"appid": 537337569,
"qua": "V1_WIN_NQ_9.9.26_44725_GW_B"
},
"9.9.27-45627": {
"appid": 537340060,
"qua": "V1_WIN_NQ_9.9.26_45627_GW_B"
},
"6.9.88-44725": {
"appid": 537337594,
"qua": "V1_MAC_NQ_6.9.88_44725_GW_B"
}
}

View File

@@ -154,5 +154,17 @@
"9.9.26-44725-x64": {
"send": "0A18D0C",
"recv": "1D4BF0D"
},
"9.9.27-45627-x64": {
"send": "0A697CC",
"recv": "1E86AC1"
},
"6.9.88-44725-x64": {
"send": "2756EF6",
"recv": "0A36152"
},
"6.9.88-44725-arm64": {
"send": "2313C68",
"recv": "09693E4"
}
}

View File

@@ -662,5 +662,17 @@
"9.9.26-44725-x64": {
"send": "2CEBB20",
"recv": "2CEF0A0"
},
"9.9.27-45627-x64": {
"send": "2E59CC0",
"recv": "2E5D240"
},
"6.9.88-44725-x64": {
"send": "451FE90",
"recv": "4522A40"
},
"6.9.88-44725-arm64": {
"send": "3D79168",
"recv": "3D7BA78"
}
}

View File

@@ -2,14 +2,14 @@ import * as crypto from 'node:crypto';
import { PacketMsg } from '@/napcat-core/packet/message/message';
interface ForwardMsgJson {
app: string
app: string;
config: ForwardMsgJsonConfig,
desc: string,
extra: ForwardMsgJsonExtra,
meta: ForwardMsgJsonMeta,
prompt: string,
ver: string,
view: string
view: string;
}
interface ForwardMsgJsonConfig {
@@ -17,7 +17,7 @@ interface ForwardMsgJsonConfig {
forward: number,
round: number,
type: string,
width: number
width: number;
}
interface ForwardMsgJsonExtra {
@@ -26,17 +26,17 @@ interface ForwardMsgJsonExtra {
}
interface ForwardMsgJsonMeta {
detail: ForwardMsgJsonMetaDetail
detail: ForwardMsgJsonMetaDetail;
}
interface ForwardMsgJsonMetaDetail {
news: {
text: string
text: string;
}[],
resid: string,
source: string,
summary: string,
uniseq: string
uniseq: string;
}
interface ForwardAdaptMsg {
@@ -50,8 +50,8 @@ interface ForwardAdaptMsgElement {
}
export class ForwardMsgBuilder {
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
const id = crypto.randomUUID();
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
const id = uuid ?? crypto.randomUUID();
const isGroupMsg = msg.some(m => m.isGroupMsg);
if (!source) {
source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录');
@@ -104,13 +104,19 @@ export class ForwardMsgBuilder {
return this.build(resId, []);
}
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
return this.build(resId, packetMsg.map(msg => ({
senderName: msg.senderName,
isGroupMsg: msg.groupId !== undefined,
msg: msg.msg.map(m => ({
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
})),
})), source, news, summary, prompt);
})),
source,
news,
summary,
prompt,
uuid,
);
}
}

View File

@@ -18,6 +18,7 @@ import { OidbPacket } from '@/napcat-core/packet/transformer/base';
import { ImageOcrResult } from '@/napcat-core/packet/entities/ocrResult';
import { gunzipSync } from 'zlib';
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
export class PacketOperationContext {
private readonly context: PacketContext;
@@ -26,7 +27,7 @@ export class PacketOperationContext {
this.context = context;
}
async sendPacket<T extends boolean = false>(pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
async sendPacket<T extends boolean = false> (pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
return await this.context.client.sendOidbPacket(pkt, rsp);
}
@@ -94,12 +95,15 @@ export class PacketOperationContext {
.filter(Boolean)
);
const res = await Promise.allSettled(reqList);
this.context.logger.info(`上传资源${res.length}个,失败${res.filter((r) => r.status === 'rejected').length}`);
res.forEach((result, index) => {
if (result.status === 'rejected') {
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
}
});
const failedCount = res.filter((r) => r.status === 'rejected').length;
if (failedCount > 0) {
this.context.logger.warn(`上传资源${res.length}个,失败${failedCount}`);
res.forEach((result, index) => {
if (result.status === 'rejected') {
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
}
});
}
}
async UploadImage (img: PacketMsgPicElement) {
@@ -224,7 +228,15 @@ export class PacketOperationContext {
const res = trans.UploadForwardMsg.parse(resp);
return res.result.resId;
}
async UploadForwardMsgV2 (msg: UploadForwardMsgParams[], groupUin: number = 0) {
//await this.SendPreprocess(msg, groupUin);
// 遍历上传资源
await Promise.allSettled(msg.map(async (item) => { return await this.SendPreprocess(item.actionMsg, groupUin); }));
const req = trans.UploadForwardMsgV2.build(this.context.napcore.basicInfo.uid, msg, groupUin);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.UploadForwardMsg.parse(resp);
return res.result.resId;
}
async MoveGroupFile (
groupUin: number,
fileUUID: string,

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 FetchC2CMessage } from './FetchC2CMessage';
export { default as DownloadForwardMsg } from './DownloadForwardMsg';
export { default as UploadForwardMsgV2 } from './UploadForwardMsgV2';

View File

@@ -17,6 +17,7 @@ import { rawMsgWithSendMsg } from 'napcat-core/packet/message/converter';
import { Static, Type } from '@sinclair/typebox';
import { MsgActionsExamples } from '@/napcat-onebot/action/msg/examples';
import { OB11MessageMixTypeSchema } from '@/napcat-onebot/types/message';
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
export const SendMsgPayloadSchema = Type.Object({
message_type: Type.Optional(Type.Union([Type.Literal('private'), Type.Literal('group')], { description: '消息类型 (private/group)' })),
@@ -211,10 +212,14 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
}, dp: number = 0): Promise<{
finallySendElements: SendArkElement,
res_id?: string,
uuid?: string,
packetMsg: PacketMsg[],
deleteAfterSentFiles: string[],
innerPacketMsg?: Array<{ uuid: string, packetMsg: PacketMsg[]; }>;
} | null> {
const packetMsg: PacketMsg[] = [];
const delFiles: string[] = [];
const innerMsg: Array<{ uuid: string, packetMsg: PacketMsg[]; }> = new Array();
for (const node of messageNodes) {
if (dp >= 3) {
this.core.context.logger.logWarn('转发消息深度超过3层将停止解析');
@@ -232,6 +237,13 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
}, dp + 1);
sendElements = uploadReturnData?.finallySendElements ? [uploadReturnData.finallySendElements] : [];
delFiles.push(...(uploadReturnData?.deleteAfterSentFiles || []));
if (uploadReturnData?.uuid) {
innerMsg.push({ uuid: uploadReturnData.uuid, packetMsg: uploadReturnData.packetMsg });
uploadReturnData.innerPacketMsg?.forEach(m => {
innerMsg.push({ uuid: m.uuid, packetMsg: m.packetMsg });
});
}
} else {
const sendElementsCreateReturn = await this.obContext.apis.MsgApi.createSendElements(OB11Data, msgPeer);
sendElements = sendElementsCreateReturn.sendElements;
@@ -273,8 +285,19 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
this.core.context.logger.logWarn('handleForwardedNodesPacket 元素为空!');
return null;
}
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsg(packetMsg, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt);
const uploadMsgData: UploadForwardMsgParams[] = [{
actionCommand: 'MultiMsg',
actionMsg: packetMsg,
}];
innerMsg.forEach(({ uuid, packetMsg: msg }) => {
uploadMsgData.push({
actionCommand: uuid,
actionMsg: msg,
});
});
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsgV2(uploadMsgData, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
const uuid = crypto.randomUUID();
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt, uuid);
return {
deleteAfterSentFiles: delFiles,
finallySendElements: {
@@ -285,6 +308,9 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
},
} as SendArkElement,
res_id: resid,
uuid: uuid,
packetMsg: packetMsg,
innerPacketMsg: innerMsg,
};
}

View File

@@ -316,6 +316,11 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
entry = newEntry;
}
if (!entry.enable) {
this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`);
return false;
}
return await this.loadPlugin(entry);
}

View File

@@ -123,8 +123,8 @@ export class PluginLoader {
const entryFile = this.findEntryFile(pluginDir, packageJson);
const entryPath = entryFile ? path.join(pluginDir, entryFile) : undefined;
// 获取启用状态(默认启用
const enable = statusConfig[pluginId] !== false;
// 获取启用状态(默认禁用,内置插件除外
const enable = statusConfig[pluginId] ?? (pluginId === 'napcat-plugin-builtin');
// 创建插件条目
const entry: PluginEntry = {
@@ -159,7 +159,7 @@ export class PluginLoader {
id: dirname, // 使用目录名作为 ID
fileId: dirname,
pluginPath: path.join(this.pluginPath, dirname),
enable: statusConfig[dirname] !== false,
enable: statusConfig[dirname] ?? (dirname === 'napcat-plugin-builtin'),
loaded: false,
runtime: {
status: 'error',

View File

@@ -285,6 +285,11 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
entry = newEntry;
}
if (!entry.enable) {
this.logger.log(`[PluginManager] Skipping loading disabled plugin: ${pluginId}`);
return false;
}
return await this.loadPlugin(entry);
}

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

@@ -340,7 +340,7 @@ export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper,
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
if (!fs.existsSync(configPath)) {
logger.log('[NapCat Update] No pending updates found');
//logger.log('[NapCat Update] No pending updates found');
return;
}

View File

@@ -1,11 +1,24 @@
import { Router } from 'express';
import multer from 'multer';
import { OB11GetConfigHandler, OB11SetConfigHandler } from '@/napcat-webui-backend/src/api/OB11Config';
import { BackupExportConfigHandler, BackupImportConfigHandler } from '@/napcat-webui-backend/src/api/BackupConfig';
const router: Router = Router();
// 使用内存存储,配合流式处理
const upload = multer({
storage: multer.memoryStorage()
});
// router:读取配置
router.post('/GetConfig', OB11GetConfigHandler);
// router:写入配置
router.post('/SetConfig', OB11SetConfigHandler);
// router:导出配置
router.get('/ExportConfig', BackupExportConfigHandler);
// router:导入配置
router.post('/ImportConfig', upload.single('configFile'), BackupImportConfigHandler);
export { router as OB11ConfigRouter };

View File

@@ -41,7 +41,7 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
return {
text: '更新',
icon: <IoMdDownload size={16} />,
color: 'success' as const,
color: 'default' as const,
};
default:
return {

View File

@@ -260,14 +260,14 @@ const NewVersionTip = (props: NewVersionTipProps) => {
<div className="cursor-pointer flex items-center justify-center" onClick={updateStatus === 'updating' ? undefined : showUpdateDialog}>
<Chip
size="sm"
color="danger"
color="primary"
variant="flat"
classNames={{
content: "font-bold text-[10px] px-1 flex items-center justify-center",
base: "h-5 min-h-5 min-w-[42px]"
}}
>
{updateStatus === 'updating' ? <Spinner size="sm" color="danger" classNames={{ wrapper: "w-3 h-3" }} /> : 'New'}
{updateStatus === 'updating' ? <Spinner size="sm" color="primary" classNames={{ wrapper: "w-3 h-3" }} /> : 'New'}
</Chip>
</div>
</Tooltip>

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 ThemeConfigCard from './theme';
import WebUIConfigCard from './webui';
import BackupConfigCard from './backup';
export interface ConfigPageProps {
children?: React.ReactNode;
@@ -108,6 +109,11 @@ export default function ConfigPage () {
<ThemeConfigCard />
</ConfigPageItem>
</Tab>
<Tab title='备份与恢复' key='backup'>
<ConfigPageItem>
<BackupConfigCard />
</ConfigPageItem>
</Tab>
</Tabs>
</section>
);

View File

@@ -89,75 +89,74 @@ export default function ExtensionPage () {
return (
<>
<title> - NapCat WebUI</title>
<div className='p-2 md:p-4 relative h-full flex flex-col'>
<div className='p-2 md:p-4 relative h-[calc(100vh-6rem)] md:h-[calc(100vh-4rem)] flex flex-col'>
<PageLoading loading={loading} />
<div className='flex mb-4 items-center gap-4'>
<div className='flex items-center gap-2 text-default-600'>
<MdExtension size={24} />
<span className='text-lg font-medium'></span>
<div className='flex mb-4 items-center justify-between gap-4 flex-wrap'>
<div className='flex items-center gap-4'>
<div className='flex items-center gap-2 text-default-600'>
<MdExtension size={24} />
<span className='text-lg font-medium'></span>
</div>
<Button
isIconOnly
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
radius='full'
onPress={refresh}
>
<IoMdRefresh size={24} />
</Button>
</div>
<Button
isIconOnly
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
radius='full'
onPress={refresh}
>
<IoMdRefresh size={24} />
</Button>
{extensionPages.length > 0 && (
<Tabs
aria-label='Extension Pages'
className='max-w-full'
selectedKey={selectedTab}
onSelectionChange={(key) => setSelectedTab(key as string)}
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
panel: 'hidden',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={
<div className='flex items-center gap-2'>
{tab.icon && <span>{tab.icon}</span>}
<span>{tab.title}</span>
<span className='text-xs text-default-400'>({tab.pluginName})</span>
</div>
}
/>
))}
</Tabs>
)}
</div>
{extensionPages.length === 0 && !loading
? (
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
<MdExtension size={64} className='mb-4 opacity-50' />
<p className='text-lg'></p>
<p className='text-sm mt-2'> WebUI </p>
</div>
)
: (
<div className='flex-1 flex flex-col min-h-0'>
<Tabs
aria-label='Extension Pages'
className='max-w-full'
selectedKey={selectedTab}
onSelectionChange={(key) => setSelectedTab(key as string)}
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md flex-wrap',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
panel: 'flex-1 min-h-0 p-0',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={
<div className='flex items-center gap-2'>
{tab.icon && <span>{tab.icon}</span>}
<span>{tab.title}</span>
<span className='text-xs text-default-400'>({tab.pluginName})</span>
</div>
}
>
<div className='relative w-full h-[calc(100vh-220px)] bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden'>
{iframeLoading && (
<div className='absolute inset-0 flex items-center justify-center bg-default-100/50 z-10'>
<Spinner size='lg' />
</div>
)}
<iframe
src={currentPageUrl}
className='w-full h-full border-0'
onLoad={handleIframeLoad}
title={tab.title}
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
/>
</div>
</Tab>
))}
</Tabs>
</div>
)}
{extensionPages.length === 0 && !loading ? (
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
<MdExtension size={64} className='mb-4 opacity-50' />
<p className='text-lg'></p>
<p className='text-sm mt-2'> WebUI </p>
</div>
) : (
<div className='flex-1 min-h-0 bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden relative'>
{iframeLoading && (
<div className='absolute inset-0 flex items-center justify-center bg-default-100/50 z-10'>
<Spinner size='lg' />
</div>
)}
<iframe
src={currentPageUrl}
className='w-full h-full border-0'
onLoad={handleIframeLoad}
title='extension-page'
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
/>
</div>
)}
</div>
</>
);