mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 08:10:25 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f8569f30c | ||
|
|
82d0c51716 | ||
|
|
37fb2d68d7 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,6 +10,9 @@ devconfig/*
|
|||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea/*
|
.idea/*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
*.db
|
*.db
|
||||||
checkVersion.sh
|
checkVersion.sh
|
||||||
|
|||||||
@@ -184,6 +184,35 @@ export function stringifyWithBigInt (obj: any) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseAppidFromMajorV2 (nodeMajor: string): string | undefined {
|
||||||
|
const marker = Buffer.from('QQAppId/', 'utf-8');
|
||||||
|
const filePath = path.resolve(nodeMajor);
|
||||||
|
const fileContent = fs.readFileSync(filePath);
|
||||||
|
|
||||||
|
let searchPosition = 0;
|
||||||
|
while (true) {
|
||||||
|
const index = fileContent.indexOf(marker, searchPosition);
|
||||||
|
if (index === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = index + marker.length;
|
||||||
|
const end = fileContent.indexOf(0x00, start);
|
||||||
|
if (end === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const content = fileContent.subarray(start, end);
|
||||||
|
const str = content.toString('utf-8');
|
||||||
|
if (/^\d+$/.test(str)) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchPosition = end + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseAppidFromMajor (nodeMajor: string): string | undefined {
|
export function parseAppidFromMajor (nodeMajor: string): string | undefined {
|
||||||
const hexSequence = 'A4 09 00 00 00 35';
|
const hexSequence = 'A4 09 00 00 00 35';
|
||||||
const sequenceBytes = Buffer.from(hexSequence.replace(/ /g, ''), 'hex');
|
const sequenceBytes = Buffer.from(hexSequence.replace(/ /g, ''), 'hex');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { systemPlatform } from 'napcat-common/src/system';
|
import { systemPlatform } from 'napcat-common/src/system';
|
||||||
import { getDefaultQQVersionConfigInfo, getQQPackageInfoPath, getQQVersionConfigPath, parseAppidFromMajor } from 'napcat-common/src/helper';
|
import { getDefaultQQVersionConfigInfo, getQQPackageInfoPath, getQQVersionConfigPath, parseAppidFromMajor, parseAppidFromMajorV2 } from 'napcat-common/src/helper';
|
||||||
import AppidTable from '@/napcat-core/external/appid.json';
|
import AppidTable from '@/napcat-core/external/appid.json';
|
||||||
import { LogWrapper } from './log';
|
import { LogWrapper } from './log';
|
||||||
import { getMajorPath } from '@/napcat-core/index';
|
import { getMajorPath } from '@/napcat-core/index';
|
||||||
@@ -107,7 +107,13 @@ export class QQBasicInfoWrapper {
|
|||||||
if (!this.QQMainPath) {
|
if (!this.QQMainPath) {
|
||||||
throw new Error('QQMainPath未定义 无法通过Major获取Appid');
|
throw new Error('QQMainPath未定义 无法通过Major获取Appid');
|
||||||
}
|
}
|
||||||
const majorPath = getMajorPath(QQVersion, this.QQMainPath);
|
const majorPath = getMajorPath(this.QQMainPath, QQVersion);
|
||||||
|
// 优先通过 QQAppId/ 标记搜索
|
||||||
|
const appidV2 = parseAppidFromMajorV2(majorPath);
|
||||||
|
if (appidV2) {
|
||||||
|
return appidV2;
|
||||||
|
}
|
||||||
|
// 回落到旧方式
|
||||||
const appid = parseAppidFromMajor(majorPath);
|
const appid = parseAppidFromMajor(majorPath);
|
||||||
return appid;
|
return appid;
|
||||||
}
|
}
|
||||||
|
|||||||
21
packages/napcat-dpapi/LICENSE
Normal file
21
packages/napcat-dpapi/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 Xavier Monin
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
4
packages/napcat-dpapi/README.md
Normal file
4
packages/napcat-dpapi/README.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# @primno/dpapi
|
||||||
|
|
||||||
|
## 协议与说明
|
||||||
|
全部遵守原仓库要求
|
||||||
65
packages/napcat-dpapi/index.ts
Normal file
65
packages/napcat-dpapi/index.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* napcat-dpapi - Windows DPAPI wrapper
|
||||||
|
*
|
||||||
|
* Loads the native @primno+dpapi.node addon from the runtime
|
||||||
|
* native/dpapi/ directory using process.dlopen, consistent
|
||||||
|
* with how other native modules (ffmpeg, packet, pty) are loaded.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path, { dirname } from 'node:path';
|
||||||
|
|
||||||
|
export type DataProtectionScope = 'CurrentUser' | 'LocalMachine';
|
||||||
|
|
||||||
|
export interface DpapiBindings {
|
||||||
|
protectData (dataToEncrypt: Uint8Array, optionalEntropy: Uint8Array | null, scope: DataProtectionScope): Uint8Array;
|
||||||
|
unprotectData (encryptData: Uint8Array, optionalEntropy: Uint8Array | null, scope: DataProtectionScope): Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dpapiBindings: DpapiBindings | null = null;
|
||||||
|
let loadError: Error | null = null;
|
||||||
|
|
||||||
|
function getAddonPath (): string {
|
||||||
|
// At runtime, import.meta.url resolves to dist/ directory.
|
||||||
|
// Native files are at dist/native/dpapi/{platform}-{arch}/@primno+dpapi.node
|
||||||
|
const importDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platform = process.platform; // 'win32'
|
||||||
|
const arch = process.arch; // 'x64' or 'arm64'
|
||||||
|
return path.join(importDir, 'native', 'dpapi', `${platform}-${arch}`, '@primno+dpapi.node');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDpapi (): DpapiBindings {
|
||||||
|
if (dpapiBindings) {
|
||||||
|
return dpapiBindings;
|
||||||
|
}
|
||||||
|
if (loadError) {
|
||||||
|
throw loadError;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const addonPath = getAddonPath();
|
||||||
|
const nativeModule: { exports: DpapiBindings } = { exports: {} as DpapiBindings };
|
||||||
|
process.dlopen(nativeModule, addonPath);
|
||||||
|
dpapiBindings = nativeModule.exports;
|
||||||
|
return dpapiBindings;
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e as Error;
|
||||||
|
throw new Error(`Failed to load DPAPI native addon: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isPlatformSupported = process.platform === 'win32';
|
||||||
|
|
||||||
|
export function protectData (data: Uint8Array, optionalEntropy: Uint8Array | null, scope: DataProtectionScope): Uint8Array {
|
||||||
|
return loadDpapi().protectData(data, optionalEntropy, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unprotectData (data: Uint8Array, optionalEntropy: Uint8Array | null, scope: DataProtectionScope): Uint8Array {
|
||||||
|
return loadDpapi().unprotectData(data, optionalEntropy, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Dpapi = {
|
||||||
|
protectData,
|
||||||
|
unprotectData,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dpapi;
|
||||||
18
packages/napcat-dpapi/package.json
Normal file
18
packages/napcat-dpapi/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "napcat-dpapi",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/napcat-dpapi/tsconfig.json
Normal file
10
packages/napcat-dpapi/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"./**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -78,6 +78,7 @@ export async function NCoreInitFramework (
|
|||||||
|
|
||||||
// 启动WebUi
|
// 启动WebUi
|
||||||
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework);
|
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework);
|
||||||
|
WebUiDataRuntime.setQQDataPath(loaderObject.core.dataPath);
|
||||||
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
||||||
// 使用 NapCatAdapterManager 统一管理协议适配器
|
// 使用 NapCatAdapterManager 统一管理协议适配器
|
||||||
const adapterManager = new NapCatAdapterManager(loaderObject.core, loaderObject.context, pathWrapper);
|
const adapterManager = new NapCatAdapterManager(loaderObject.core, loaderObject.context, pathWrapper);
|
||||||
|
|||||||
BIN
packages/napcat-native/dpapi/win32-arm64/@primno+dpapi.node
Normal file
BIN
packages/napcat-native/dpapi/win32-arm64/@primno+dpapi.node
Normal file
Binary file not shown.
BIN
packages/napcat-native/dpapi/win32-x64/@primno+dpapi.node
Normal file
BIN
packages/napcat-native/dpapi/win32-x64/@primno+dpapi.node
Normal file
Binary file not shown.
@@ -425,6 +425,7 @@ export async function NCoreInitShell () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const [dataPath, dataPathGlobal] = getDataPaths(wrapper);
|
const [dataPath, dataPathGlobal] = getDataPaths(wrapper);
|
||||||
|
WebUiDataRuntime.setQQDataPath(dataPath);
|
||||||
const systemPlatform = getPlatformType();
|
const systemPlatform = getPlatformType();
|
||||||
|
|
||||||
if (!basicInfoWrapper.QQVersionAppid || !basicInfoWrapper.QQVersionQua) throw new Error('QQVersionAppid or QQVersionQua is not defined');
|
if (!basicInfoWrapper.QQVersionAppid || !basicInfoWrapper.QQVersionQua) throw new Error('QQVersionAppid or QQVersionQua is not defined');
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const ShellBaseConfig = (source_map: boolean = false) =>
|
|||||||
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
||||||
'@/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-dpapi': resolve(__dirname, '../napcat-dpapi'),
|
||||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||||
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||||
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"multer": "^2.0.1",
|
"multer": "^2.0.1",
|
||||||
"napcat-common": "workspace:*",
|
"napcat-common": "workspace:*",
|
||||||
|
"napcat-dpapi": "workspace:*",
|
||||||
"napcat-pty": "workspace:*",
|
"napcat-pty": "workspace:*",
|
||||||
"ws": "^8.18.3"
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
|||||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||||
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
||||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||||
|
import { Registry20Utils } from '@/napcat-webui-backend/src/utils/guid';
|
||||||
|
|
||||||
|
// 获取 Registry20 路径的辅助函数
|
||||||
|
const getRegistryPath = () => {
|
||||||
|
// 优先从 WebUiDataRuntime 获取早期设置的 dataPath
|
||||||
|
let dataPath = WebUiDataRuntime.getQQDataPath();
|
||||||
|
if (!dataPath) {
|
||||||
|
// 回退: 从 OneBotContext 获取
|
||||||
|
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||||
|
dataPath = oneBotContext?.core?.dataPath;
|
||||||
|
}
|
||||||
|
if (!dataPath) {
|
||||||
|
throw new Error('QQ data path not available yet');
|
||||||
|
}
|
||||||
|
return Registry20Utils.getRegistryPath(dataPath);
|
||||||
|
};
|
||||||
|
|
||||||
// 获取QQ登录二维码
|
// 获取QQ登录二维码
|
||||||
export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
|
export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
|
||||||
@@ -147,3 +163,106 @@ export const QQPasswordLoginHandler: RequestHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
return sendSuccess(res, null);
|
return sendSuccess(res, null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 重置设备信息
|
||||||
|
export const QQResetDeviceIDHandler: RequestHandler = async (_, res) => {
|
||||||
|
try {
|
||||||
|
const registryPath = getRegistryPath();
|
||||||
|
// 自动备份
|
||||||
|
try {
|
||||||
|
await Registry20Utils.backup(registryPath);
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略备份错误(例如文件不存在)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Registry20Utils.delete(registryPath);
|
||||||
|
return sendSuccess(res, { message: 'Device ID reset successfully (Registry20 deleted)' });
|
||||||
|
} catch (e) {
|
||||||
|
return sendError(res, `Failed to reset Device ID: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取设备 GUID
|
||||||
|
export const QQGetDeviceGUIDHandler: RequestHandler = async (_, res) => {
|
||||||
|
try {
|
||||||
|
const registryPath = getRegistryPath();
|
||||||
|
const guid = await Registry20Utils.readGuid(registryPath);
|
||||||
|
return sendSuccess(res, { guid });
|
||||||
|
} catch (e) {
|
||||||
|
// 可能是文件不存在,或者非 Windows 平台,或者解密失败
|
||||||
|
return sendError(res, `Failed to get GUID: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置设备 GUID
|
||||||
|
export const QQSetDeviceGUIDHandler: RequestHandler = async (req, res) => {
|
||||||
|
const { guid } = req.body;
|
||||||
|
if (!guid || typeof guid !== 'string' || guid.length !== 32) {
|
||||||
|
return sendError(res, 'Invalid GUID format, must be 32 hex characters');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const registryPath = getRegistryPath();
|
||||||
|
// 自动备份
|
||||||
|
try {
|
||||||
|
await Registry20Utils.backup(registryPath);
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
await Registry20Utils.writeGuid(registryPath, guid);
|
||||||
|
return sendSuccess(res, { message: 'GUID set successfully' });
|
||||||
|
} catch (e) {
|
||||||
|
return sendError(res, `Failed to set GUID: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取备份列表
|
||||||
|
export const QQGetGUIDBackupsHandler: RequestHandler = async (_, res) => {
|
||||||
|
try {
|
||||||
|
const registryPath = getRegistryPath();
|
||||||
|
const backups = Registry20Utils.getBackups(registryPath);
|
||||||
|
return sendSuccess(res, backups);
|
||||||
|
} catch (e) {
|
||||||
|
return sendError(res, `Failed to get backups: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 恢复备份
|
||||||
|
export const QQRestoreGUIDBackupHandler: RequestHandler = async (req, res) => {
|
||||||
|
const { backupName } = req.body;
|
||||||
|
if (!backupName) {
|
||||||
|
return sendError(res, 'Backup name is required');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const registryPath = getRegistryPath();
|
||||||
|
await Registry20Utils.restore(registryPath, backupName);
|
||||||
|
return sendSuccess(res, { message: 'Restored successfully' });
|
||||||
|
} catch (e) {
|
||||||
|
return sendError(res, `Failed to restore: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建备份
|
||||||
|
export const QQCreateGUIDBackupHandler: RequestHandler = async (_, res) => {
|
||||||
|
try {
|
||||||
|
const registryPath = getRegistryPath();
|
||||||
|
const backupPath = await Registry20Utils.backup(registryPath);
|
||||||
|
return sendSuccess(res, { message: 'Backup created', path: backupPath });
|
||||||
|
} catch (e) {
|
||||||
|
return sendError(res, `Failed to backup: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重启NapCat
|
||||||
|
export const QQRestartNapCatHandler: RequestHandler = async (_, res) => {
|
||||||
|
try {
|
||||||
|
const result = await WebUiDataRuntime.requestRestartProcess();
|
||||||
|
if (result.result) {
|
||||||
|
return sendSuccess(res, { message: result.message || 'Restart initiated' });
|
||||||
|
} else {
|
||||||
|
return sendError(res, result.message || 'Restart failed');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return sendError(res, `Restart error: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const LoginRuntime: LoginRuntimeType = {
|
|||||||
},
|
},
|
||||||
QQLoginError: '',
|
QQLoginError: '',
|
||||||
QQVersion: 'unknown',
|
QQVersion: 'unknown',
|
||||||
|
QQDataPath: '',
|
||||||
OneBotContext: null,
|
OneBotContext: null,
|
||||||
onQQLoginStatusChange: async (status: boolean) => {
|
onQQLoginStatusChange: async (status: boolean) => {
|
||||||
LoginRuntime.QQLoginStatus = status;
|
LoginRuntime.QQLoginStatus = status;
|
||||||
@@ -167,6 +168,14 @@ export const WebUiDataRuntime = {
|
|||||||
return LoginRuntime.QQVersion;
|
return LoginRuntime.QQVersion;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setQQDataPath (dataPath: string) {
|
||||||
|
LoginRuntime.QQDataPath = dataPath;
|
||||||
|
},
|
||||||
|
|
||||||
|
getQQDataPath (): string {
|
||||||
|
return LoginRuntime.QQDataPath;
|
||||||
|
},
|
||||||
|
|
||||||
setWebUiConfigQuickFunction (func: LoginRuntimeType['WebUiConfigQuickFunction']): void {
|
setWebUiConfigQuickFunction (func: LoginRuntimeType['WebUiConfigQuickFunction']): void {
|
||||||
LoginRuntime.WebUiConfigQuickFunction = func;
|
LoginRuntime.WebUiConfigQuickFunction = func;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import {
|
|||||||
setAutoLoginAccountHandler,
|
setAutoLoginAccountHandler,
|
||||||
QQRefreshQRcodeHandler,
|
QQRefreshQRcodeHandler,
|
||||||
QQPasswordLoginHandler,
|
QQPasswordLoginHandler,
|
||||||
|
QQResetDeviceIDHandler,
|
||||||
|
QQRestartNapCatHandler,
|
||||||
|
QQGetDeviceGUIDHandler,
|
||||||
|
QQSetDeviceGUIDHandler,
|
||||||
|
QQGetGUIDBackupsHandler,
|
||||||
|
QQRestoreGUIDBackupHandler,
|
||||||
|
QQCreateGUIDBackupHandler,
|
||||||
} from '@/napcat-webui-backend/src/api/QQLogin';
|
} from '@/napcat-webui-backend/src/api/QQLogin';
|
||||||
|
|
||||||
const router: Router = Router();
|
const router: Router = Router();
|
||||||
@@ -34,5 +41,19 @@ router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
|
|||||||
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
||||||
// router:密码登录
|
// router:密码登录
|
||||||
router.post('/PasswordLogin', QQPasswordLoginHandler);
|
router.post('/PasswordLogin', QQPasswordLoginHandler);
|
||||||
|
// router:重置设备信息
|
||||||
|
router.post('/ResetDeviceID', QQResetDeviceIDHandler);
|
||||||
|
// router:重启NapCat
|
||||||
|
router.post('/RestartNapCat', QQRestartNapCatHandler);
|
||||||
|
// router:获取设备GUID
|
||||||
|
router.post('/GetDeviceGUID', QQGetDeviceGUIDHandler);
|
||||||
|
// router:设置设备GUID
|
||||||
|
router.post('/SetDeviceGUID', QQSetDeviceGUIDHandler);
|
||||||
|
// router:获取GUID备份列表
|
||||||
|
router.post('/GetGUIDBackups', QQGetGUIDBackupsHandler);
|
||||||
|
// router:恢复GUID备份
|
||||||
|
router.post('/RestoreGUIDBackup', QQRestoreGUIDBackupHandler);
|
||||||
|
// router:创建GUID备份
|
||||||
|
router.post('/CreateGUIDBackup', QQCreateGUIDBackupHandler);
|
||||||
|
|
||||||
export { router as QQLoginRouter };
|
export { router as QQLoginRouter };
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface LoginRuntimeType {
|
|||||||
QQLoginInfo: SelfInfo;
|
QQLoginInfo: SelfInfo;
|
||||||
QQLoginError: string;
|
QQLoginError: string;
|
||||||
QQVersion: string;
|
QQVersion: string;
|
||||||
|
QQDataPath: string;
|
||||||
onQQLoginStatusChange: (status: boolean) => Promise<void>;
|
onQQLoginStatusChange: (status: boolean) => Promise<void>;
|
||||||
onWebUiTokenChange: (token: string) => Promise<void>;
|
onWebUiTokenChange: (token: string) => Promise<void>;
|
||||||
onRefreshQRCode: () => Promise<void>;
|
onRefreshQRCode: () => Promise<void>;
|
||||||
|
|||||||
116
packages/napcat-webui-backend/src/utils/guid.ts
Normal file
116
packages/napcat-webui-backend/src/utils/guid.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { protectData, unprotectData } from 'napcat-dpapi';
|
||||||
|
|
||||||
|
const GUID_HEADER = Buffer.from([0x00, 0x00, 0x00, 0x14]);
|
||||||
|
const XOR_KEY = 0x10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unprotects data using Windows DPAPI via napcat-dpapi.
|
||||||
|
*/
|
||||||
|
function dpapiUnprotect (filePath: string): Buffer {
|
||||||
|
const encrypted = fs.readFileSync(filePath);
|
||||||
|
return Buffer.from(unprotectData(encrypted, null, 'CurrentUser'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protects data using Windows DPAPI and writes to file.
|
||||||
|
*/
|
||||||
|
function dpapiProtectAndWrite (filePath: string, data: Buffer): void {
|
||||||
|
const encrypted = protectData(data, null, 'CurrentUser');
|
||||||
|
fs.writeFileSync(filePath, Buffer.from(encrypted));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Registry20Utils {
|
||||||
|
static getRegistryPath (dataPath: string): string {
|
||||||
|
return path.join(dataPath, 'nt_qq', 'global', 'nt_data', 'msf', 'Registry20');
|
||||||
|
}
|
||||||
|
|
||||||
|
static readGuid (registryPath: string): string {
|
||||||
|
if (!fs.existsSync(registryPath)) {
|
||||||
|
throw new Error('Registry20 file not found');
|
||||||
|
}
|
||||||
|
if (os.platform() !== 'win32') {
|
||||||
|
throw new Error('Registry20 decryption is only supported on Windows');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decrypted = dpapiUnprotect(registryPath);
|
||||||
|
|
||||||
|
if (decrypted.length < 20) {
|
||||||
|
throw new Error(`Decrypted data too short (got ${decrypted.length} bytes, need 20)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode payload: header(4) + obfuscated_guid(16)
|
||||||
|
const payload = decrypted.subarray(4, 20);
|
||||||
|
const guidBuf = Buffer.alloc(16);
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
const payloadByte = payload[i] ?? 0;
|
||||||
|
guidBuf[i] = (~(payloadByte ^ XOR_KEY)) & 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
return guidBuf.toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
static writeGuid (registryPath: string, guidHex: string): void {
|
||||||
|
if (guidHex.length !== 32) {
|
||||||
|
throw new Error('Invalid GUID length, must be 32 hex chars');
|
||||||
|
}
|
||||||
|
if (os.platform() !== 'win32') {
|
||||||
|
throw new Error('Registry20 encryption is only supported on Windows');
|
||||||
|
}
|
||||||
|
|
||||||
|
const guidBytes = Buffer.from(guidHex, 'hex');
|
||||||
|
const payload = Buffer.alloc(16);
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
const guidByte = guidBytes[i] ?? 0;
|
||||||
|
payload[i] = XOR_KEY ^ (~guidByte & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = Buffer.concat([GUID_HEADER, payload]);
|
||||||
|
|
||||||
|
// Create directory if not exists
|
||||||
|
const dir = path.dirname(registryPath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
dpapiProtectAndWrite(registryPath, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getBackups (registryPath: string): string[] {
|
||||||
|
const dir = path.dirname(registryPath);
|
||||||
|
const baseName = path.basename(registryPath);
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
|
||||||
|
return fs.readdirSync(dir)
|
||||||
|
.filter(f => f.startsWith(`${baseName}.bak.`))
|
||||||
|
.sort()
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
static backup (registryPath: string): string {
|
||||||
|
if (!fs.existsSync(registryPath)) {
|
||||||
|
throw new Error('Registry20 does not exist');
|
||||||
|
}
|
||||||
|
const timestamp = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14);
|
||||||
|
const backupPath = `${registryPath}.bak.${timestamp}`;
|
||||||
|
fs.copyFileSync(registryPath, backupPath);
|
||||||
|
return backupPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
static restore (registryPath: string, backupFileName: string): void {
|
||||||
|
const dir = path.dirname(registryPath);
|
||||||
|
const backupPath = path.join(dir, backupFileName);
|
||||||
|
if (!fs.existsSync(backupPath)) {
|
||||||
|
throw new Error('Backup file not found');
|
||||||
|
}
|
||||||
|
fs.copyFileSync(backupPath, registryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
static delete (registryPath: string): void {
|
||||||
|
if (fs.existsSync(registryPath)) {
|
||||||
|
fs.unlinkSync(registryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ function getAuthorAvatar (homepage?: string, downloadUrl?: string): string | und
|
|||||||
export interface PluginStoreCardProps {
|
export interface PluginStoreCardProps {
|
||||||
data: PluginStoreItem;
|
data: PluginStoreItem;
|
||||||
onInstall: () => void;
|
onInstall: () => void;
|
||||||
|
onViewDetail?: () => void;
|
||||||
installStatus?: InstallStatus;
|
installStatus?: InstallStatus;
|
||||||
installedVersion?: string;
|
installedVersion?: string;
|
||||||
}
|
}
|
||||||
@@ -62,6 +63,7 @@ export interface PluginStoreCardProps {
|
|||||||
const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||||
data,
|
data,
|
||||||
onInstall,
|
onInstall,
|
||||||
|
onViewDetail,
|
||||||
installStatus = 'not-installed',
|
installStatus = 'not-installed',
|
||||||
installedVersion,
|
installedVersion,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -91,7 +93,10 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
shadow='sm'
|
shadow='sm'
|
||||||
>
|
>
|
||||||
<CardBody className='p-4 flex flex-col gap-3'>
|
<CardBody
|
||||||
|
className={clsx('p-4 flex flex-col gap-3', onViewDetail && 'cursor-pointer')}
|
||||||
|
onClick={onViewDetail}
|
||||||
|
>
|
||||||
{/* Header: Avatar + Name + Author */}
|
{/* Header: Avatar + Name + Author */}
|
||||||
<div className='flex items-start gap-3'>
|
<div className='flex items-start gap-3'>
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -232,7 +237,10 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|
||||||
<CardFooter className='px-4 pb-4 pt-0'>
|
<CardFooter
|
||||||
|
className='px-4 pb-4 pt-0'
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
{installStatus === 'installed'
|
{installStatus === 'installed'
|
||||||
? (
|
? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
317
packages/napcat-webui-frontend/src/components/guid_manager.tsx
Normal file
317
packages/napcat-webui-frontend/src/components/guid_manager.tsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import { Button } from '@heroui/button';
|
||||||
|
import { Input } from '@heroui/input';
|
||||||
|
import { Divider } from '@heroui/divider';
|
||||||
|
import { Chip } from '@heroui/chip';
|
||||||
|
import { Listbox, ListboxItem } from '@heroui/listbox';
|
||||||
|
import { Spinner } from '@heroui/spinner';
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { MdContentCopy, MdDelete, MdRefresh, MdSave, MdRestorePage, MdBackup } from 'react-icons/md';
|
||||||
|
|
||||||
|
import QQManager from '@/controllers/qq_manager';
|
||||||
|
import useDialog from '@/hooks/use-dialog';
|
||||||
|
|
||||||
|
interface GUIDManagerProps {
|
||||||
|
/** 是否显示重启按钮 */
|
||||||
|
showRestart?: boolean;
|
||||||
|
/** 紧凑模式(用于弹窗场景) */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GUIDManager: React.FC<GUIDManagerProps> = ({ showRestart = true, compact = false }) => {
|
||||||
|
const dialog = useDialog();
|
||||||
|
const [currentGUID, setCurrentGUID] = useState<string>('');
|
||||||
|
const [inputGUID, setInputGUID] = useState<string>('');
|
||||||
|
const [backups, setBackups] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
|
||||||
|
const isValidGUID = (guid: string) => /^[0-9a-fA-F]{32}$/.test(guid);
|
||||||
|
|
||||||
|
const fetchGUID = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await QQManager.getDeviceGUID();
|
||||||
|
setCurrentGUID(data.guid);
|
||||||
|
setInputGUID(data.guid);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message;
|
||||||
|
setCurrentGUID('');
|
||||||
|
setInputGUID('');
|
||||||
|
if (!msg.includes('not found')) {
|
||||||
|
toast.error(`获取 GUID 失败: ${msg}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchBackups = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await QQManager.getGUIDBackups();
|
||||||
|
setBackups(data);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGUID();
|
||||||
|
fetchBackups();
|
||||||
|
}, [fetchGUID, fetchBackups]);
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (currentGUID) {
|
||||||
|
navigator.clipboard.writeText(currentGUID);
|
||||||
|
toast.success('已复制到剪贴板');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!isValidGUID(inputGUID)) {
|
||||||
|
toast.error('GUID 格式无效,需要 32 位十六进制字符');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await QQManager.setDeviceGUID(inputGUID);
|
||||||
|
setCurrentGUID(inputGUID);
|
||||||
|
toast.success('GUID 已设置,重启后生效');
|
||||||
|
await fetchBackups();
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message;
|
||||||
|
toast.error(`设置 GUID 失败: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
dialog.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content: '删除 Registry20 后,QQ 将在下次启动时生成新的设备标识。确定要删除吗?',
|
||||||
|
confirmText: '删除',
|
||||||
|
cancelText: '取消',
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await QQManager.resetDeviceID();
|
||||||
|
setCurrentGUID('');
|
||||||
|
setInputGUID('');
|
||||||
|
toast.success('已删除,重启后生效');
|
||||||
|
await fetchBackups();
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message;
|
||||||
|
toast.error(`删除失败: ${msg}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackup = async () => {
|
||||||
|
try {
|
||||||
|
await QQManager.createGUIDBackup();
|
||||||
|
toast.success('备份已创建');
|
||||||
|
await fetchBackups();
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message;
|
||||||
|
toast.error(`备份失败: ${msg}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestore = (backupName: string) => {
|
||||||
|
dialog.confirm({
|
||||||
|
title: '确认恢复',
|
||||||
|
content: `确定要从备份 "${backupName}" 恢复吗?当前的 Registry20 将被覆盖。`,
|
||||||
|
confirmText: '恢复',
|
||||||
|
cancelText: '取消',
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await QQManager.restoreGUIDBackup(backupName);
|
||||||
|
toast.success('已恢复,重启后生效');
|
||||||
|
await fetchGUID();
|
||||||
|
await fetchBackups();
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message;
|
||||||
|
toast.error(`恢复失败: ${msg}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestart = () => {
|
||||||
|
dialog.confirm({
|
||||||
|
title: '确认重启',
|
||||||
|
content: '确定要重启 NapCat 吗?这将导致当前连接断开。',
|
||||||
|
confirmText: '重启',
|
||||||
|
cancelText: '取消',
|
||||||
|
onConfirm: async () => {
|
||||||
|
setRestarting(true);
|
||||||
|
try {
|
||||||
|
await QQManager.restartNapCat();
|
||||||
|
toast.success('重启指令已发送');
|
||||||
|
} catch (error) {
|
||||||
|
const msg = (error as Error).message;
|
||||||
|
toast.error(`重启失败: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
setRestarting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className='flex items-center justify-center py-8'>
|
||||||
|
<Spinner label='加载中...' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-${compact ? '3' : '4'}`}>
|
||||||
|
{/* 当前 GUID 显示 */}
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<div className='text-sm font-medium text-default-700'>当前设备 GUID</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
{currentGUID ? (
|
||||||
|
<Chip variant='flat' color='primary' className='font-mono text-xs max-w-full'>
|
||||||
|
{currentGUID}
|
||||||
|
</Chip>
|
||||||
|
) : (
|
||||||
|
<Chip variant='flat' color='warning' className='text-xs'>
|
||||||
|
未设置 / 不存在
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
{currentGUID && (
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size='sm'
|
||||||
|
variant='light'
|
||||||
|
onPress={handleCopy}
|
||||||
|
aria-label='复制GUID'
|
||||||
|
>
|
||||||
|
<MdContentCopy size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size='sm'
|
||||||
|
variant='light'
|
||||||
|
onPress={fetchGUID}
|
||||||
|
aria-label='刷新'
|
||||||
|
>
|
||||||
|
<MdRefresh size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* 设置 GUID */}
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<div className='text-sm font-medium text-default-700'>设置 GUID</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Input
|
||||||
|
size='sm'
|
||||||
|
variant='bordered'
|
||||||
|
placeholder='输入32位十六进制 GUID'
|
||||||
|
value={inputGUID}
|
||||||
|
onValueChange={setInputGUID}
|
||||||
|
isInvalid={inputGUID.length > 0 && !isValidGUID(inputGUID)}
|
||||||
|
errorMessage={inputGUID.length > 0 && !isValidGUID(inputGUID) ? '需要32位十六进制字符' : undefined}
|
||||||
|
classNames={{
|
||||||
|
input: 'font-mono text-sm',
|
||||||
|
}}
|
||||||
|
maxLength={32}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
color='primary'
|
||||||
|
variant='flat'
|
||||||
|
isLoading={saving}
|
||||||
|
isDisabled={!isValidGUID(inputGUID) || inputGUID === currentGUID}
|
||||||
|
onPress={handleSave}
|
||||||
|
startContent={<MdSave size={16} />}
|
||||||
|
>
|
||||||
|
保存 GUID
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
color='danger'
|
||||||
|
variant='flat'
|
||||||
|
isDisabled={!currentGUID}
|
||||||
|
onPress={handleDelete}
|
||||||
|
startContent={<MdDelete size={16} />}
|
||||||
|
>
|
||||||
|
删除 GUID
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
color='secondary'
|
||||||
|
variant='flat'
|
||||||
|
isDisabled={!currentGUID}
|
||||||
|
onPress={handleBackup}
|
||||||
|
startContent={<MdBackup size={16} />}
|
||||||
|
>
|
||||||
|
手动备份
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className='text-xs text-default-400'>
|
||||||
|
修改或删除 GUID 后需重启 NapCat 才能生效,操作前会自动备份
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 备份恢复 */}
|
||||||
|
{backups.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<div className='text-sm font-medium text-default-700'>
|
||||||
|
备份列表
|
||||||
|
<span className='text-xs text-default-400 ml-2'>(点击恢复)</span>
|
||||||
|
</div>
|
||||||
|
<div className='max-h-[160px] overflow-y-auto rounded-lg border border-default-200'>
|
||||||
|
<Listbox
|
||||||
|
aria-label='备份列表'
|
||||||
|
selectionMode='none'
|
||||||
|
onAction={(key) => handleRestore(key as string)}
|
||||||
|
>
|
||||||
|
{backups.map((name) => (
|
||||||
|
<ListboxItem
|
||||||
|
key={name}
|
||||||
|
startContent={<MdRestorePage size={16} className='text-default-400' />}
|
||||||
|
className='font-mono text-xs'
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</ListboxItem>
|
||||||
|
))}
|
||||||
|
</Listbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 重启 */}
|
||||||
|
{showRestart && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
color='warning'
|
||||||
|
variant='flat'
|
||||||
|
isLoading={restarting}
|
||||||
|
onPress={handleRestart}
|
||||||
|
startContent={<MdRefresh size={16} />}
|
||||||
|
>
|
||||||
|
重启 NapCat
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GUIDManager;
|
||||||
@@ -1,43 +1,175 @@
|
|||||||
import Markdown from 'react-markdown';
|
import Markdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
|
const TailwindMarkdown: React.FC<{ content: string; }> = ({ content }) => {
|
||||||
return (
|
return (
|
||||||
<Markdown
|
<Markdown
|
||||||
className='prose prose-sm sm:prose lg:prose-lg xl:prose-xl'
|
className='prose prose-sm sm:prose lg:prose-lg xl:prose-xl max-w-none'
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={{
|
components={{
|
||||||
h1: ({ node: _node, ...props }) => (
|
h1: ({ node: _node, ...props }) => (
|
||||||
<h1 className='text-2xl font-bold' {...props} />
|
<h1
|
||||||
|
className='text-3xl font-bold mt-6 mb-4 pb-2 border-b-2 border-primary/20 text-default-900 first:mt-0'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
h2: ({ node: _node, ...props }) => (
|
h2: ({ node: _node, ...props }) => (
|
||||||
<h2 className='text-xl font-bold' {...props} />
|
<h2
|
||||||
|
className='text-2xl font-bold mt-6 mb-3 pb-2 border-b border-default-200 text-default-800'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
h3: ({ node: _node, ...props }) => (
|
h3: ({ node: _node, ...props }) => (
|
||||||
<h3 className='text-lg font-bold' {...props} />
|
<h3
|
||||||
|
className='text-xl font-semibold mt-5 mb-2 text-default-800'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
h4: ({ node: _node, ...props }) => (
|
||||||
|
<h4
|
||||||
|
className='text-lg font-semibold mt-4 mb-2 text-default-700'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
h5: ({ node: _node, ...props }) => (
|
||||||
|
<h5
|
||||||
|
className='text-base font-semibold mt-3 mb-2 text-default-700'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
h6: ({ node: _node, ...props }) => (
|
||||||
|
<h6
|
||||||
|
className='text-sm font-semibold mt-3 mb-2 text-default-600'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
p: ({ node: _node, ...props }) => (
|
||||||
|
<p
|
||||||
|
className='my-3 leading-7 text-default-700 first:mt-0 last:mb-0'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
p: ({ node: _node, ...props }) => <p className='m-0' {...props} />,
|
|
||||||
a: ({ node: _node, ...props }) => (
|
a: ({ node: _node, ...props }) => (
|
||||||
<a
|
<a
|
||||||
className='text-primary-500 inline-block hover:underline'
|
className='text-primary font-medium hover:text-primary-600 underline decoration-primary/30 hover:decoration-primary transition-colors'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
ul: ({ node: _node, ...props }) => (
|
ul: ({ node: _node, ...props }) => (
|
||||||
<ul className='list-disc list-inside' {...props} />
|
<ul
|
||||||
),
|
className='my-3 ml-6 space-y-2 list-disc marker:text-primary'
|
||||||
ol: ({ node: _node, ...props }) => (
|
|
||||||
<ol className='list-decimal list-inside' {...props} />
|
|
||||||
),
|
|
||||||
blockquote: ({ node: _node, ...props }) => (
|
|
||||||
<blockquote
|
|
||||||
className='border-l-4 border-default-300 pl-4 italic'
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
code: ({ node: _node, ...props }) => (
|
ol: ({ node: _node, ...props }) => (
|
||||||
<code className='bg-default-100 p-1 rounded text-xs' {...props} />
|
<ol
|
||||||
|
className='my-3 ml-6 space-y-2 list-decimal marker:text-primary marker:font-semibold'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
li: ({ node: _node, ...props }) => (
|
||||||
|
<li
|
||||||
|
className='leading-7 text-default-700 pl-2'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
blockquote: ({ node: _node, ...props }) => (
|
||||||
|
<blockquote
|
||||||
|
className='my-4 pl-4 pr-4 py-2 border-l-4 border-primary/50 bg-primary/5 rounded-r-lg italic text-default-600'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
pre: ({ node: _node, ...props }) => (
|
||||||
|
<pre
|
||||||
|
className='my-4 p-4 bg-default-100 dark:bg-default-50 rounded-xl overflow-x-auto text-sm border border-default-200 shadow-sm'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
code: ({ node: _node, inline, ...props }: any) => {
|
||||||
|
if (inline) {
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
className='px-1.5 py-0.5 mx-0.5 bg-primary/10 text-primary-700 dark:text-primary-600 rounded text-sm font-mono border border-primary/20'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
className='text-sm font-mono text-default-800'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
img: ({ node: _node, ...props }) => (
|
||||||
|
<img
|
||||||
|
className='max-w-full h-auto rounded-lg my-4 shadow-md hover:shadow-xl transition-shadow border border-default-200'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
hr: ({ node: _node, ...props }) => (
|
||||||
|
<hr
|
||||||
|
className='my-8 border-0 h-px bg-gradient-to-r from-transparent via-default-300 to-transparent'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
table: ({ node: _node, ...props }) => (
|
||||||
|
<div className='my-4 overflow-x-auto rounded-lg border border-default-200 shadow-sm'>
|
||||||
|
<table
|
||||||
|
className='min-w-full divide-y divide-default-200'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
thead: ({ node: _node, ...props }) => (
|
||||||
|
<thead
|
||||||
|
className='bg-default-100'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
tbody: ({ node: _node, ...props }) => (
|
||||||
|
<tbody
|
||||||
|
className='divide-y divide-default-200 bg-white dark:bg-default-50'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
tr: ({ node: _node, ...props }) => (
|
||||||
|
<tr
|
||||||
|
className='hover:bg-default-50 transition-colors'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
th: ({ node: _node, ...props }) => (
|
||||||
|
<th
|
||||||
|
className='px-4 py-3 text-left text-xs font-semibold text-default-700 uppercase tracking-wider'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
td: ({ node: _node, ...props }) => (
|
||||||
|
<td
|
||||||
|
className='px-4 py-3 text-sm text-default-700'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
strong: ({ node: _node, ...props }) => (
|
||||||
|
<strong
|
||||||
|
className='font-bold text-default-900'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
em: ({ node: _node, ...props }) => (
|
||||||
|
<em
|
||||||
|
className='italic text-default-700'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
del: ({ node: _node, ...props }) => (
|
||||||
|
<del
|
||||||
|
className='line-through text-default-500'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -101,4 +101,36 @@ export default class QQManager {
|
|||||||
passwordMd5,
|
passwordMd5,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async resetDeviceID () {
|
||||||
|
await serverRequest.post<ServerResponse<null>>('/QQLogin/ResetDeviceID');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async restartNapCat () {
|
||||||
|
await serverRequest.post<ServerResponse<null>>('/QQLogin/RestartNapCat');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getDeviceGUID () {
|
||||||
|
const data = await serverRequest.post<ServerResponse<{ guid: string; }>>('/QQLogin/GetDeviceGUID');
|
||||||
|
return data.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async setDeviceGUID (guid: string) {
|
||||||
|
await serverRequest.post<ServerResponse<null>>('/QQLogin/SetDeviceGUID', { guid });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getGUIDBackups () {
|
||||||
|
const data = await serverRequest.post<ServerResponse<string[]>>('/QQLogin/GetGUIDBackups');
|
||||||
|
return data.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async restoreGUIDBackup (backupName: string) {
|
||||||
|
await serverRequest.post<ServerResponse<null>>('/QQLogin/RestoreGUIDBackup', { backupName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async createGUIDBackup () {
|
||||||
|
const data = await serverRequest.post<ServerResponse<{ path: string; }>>('/QQLogin/CreateGUIDBackup');
|
||||||
|
return data.data.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Input } from '@heroui/input';
|
import { Input } from '@heroui/input';
|
||||||
import { Button } from '@heroui/button';
|
import { Button } from '@heroui/button';
|
||||||
|
import { Divider } from '@heroui/divider';
|
||||||
import { useRequest } from 'ahooks';
|
import { useRequest } from 'ahooks';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
import SaveButtons from '@/components/button/save_buttons';
|
import SaveButtons from '@/components/button/save_buttons';
|
||||||
|
import GUIDManager from '@/components/guid_manager';
|
||||||
import PageLoading from '@/components/page_loading';
|
import PageLoading from '@/components/page_loading';
|
||||||
|
|
||||||
import QQManager from '@/controllers/qq_manager';
|
import QQManager from '@/controllers/qq_manager';
|
||||||
@@ -131,6 +133,14 @@ const LoginConfigCard = () => {
|
|||||||
重启进程将关闭当前 Worker 进程,等待 3 秒后启动新进程
|
重启进程将关闭当前 Worker 进程,等待 3 秒后启动新进程
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Divider className='mt-6' />
|
||||||
|
<div className='flex-shrink-0 w-full mt-4'>
|
||||||
|
<div className='mb-3 text-sm text-default-600'>设备 GUID 管理</div>
|
||||||
|
<div className='text-xs text-default-400 mb-3'>
|
||||||
|
GUID 是设备登录唯一识别码,存储在 Registry20 文件中。修改后需重启生效。
|
||||||
|
</div>
|
||||||
|
<GUIDManager showRestart={false} />
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal';
|
||||||
|
import { Button } from '@heroui/button';
|
||||||
|
import { Chip } from '@heroui/chip';
|
||||||
|
import { Avatar } from '@heroui/avatar';
|
||||||
|
import { Tooltip } from '@heroui/tooltip';
|
||||||
|
import { Spinner } from '@heroui/spinner';
|
||||||
|
import { IoMdCheckmarkCircle, IoMdOpen, IoMdDownload } from 'react-icons/io';
|
||||||
|
import { MdUpdate } from 'react-icons/md';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { PluginStoreItem } from '@/types/plugin-store';
|
||||||
|
import { InstallStatus } from '@/components/display_card/plugin_store_card';
|
||||||
|
import TailwindMarkdown from '@/components/tailwind_markdown';
|
||||||
|
|
||||||
|
interface PluginDetailModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
plugin: PluginStoreItem | null;
|
||||||
|
installStatus?: InstallStatus;
|
||||||
|
installedVersion?: string;
|
||||||
|
onInstall?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取作者头像 URL */
|
||||||
|
function getAuthorAvatar (homepage?: string, downloadUrl?: string): string | undefined {
|
||||||
|
// 1. 尝试从 downloadUrl 提取 GitHub 用户名
|
||||||
|
if (downloadUrl) {
|
||||||
|
try {
|
||||||
|
const url = new URL(downloadUrl);
|
||||||
|
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||||
|
const parts = url.pathname.split('/').filter(Boolean);
|
||||||
|
if (parts.length >= 1) {
|
||||||
|
return `https://github.com/${parts[0]}.png`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 尝试从 homepage 提取
|
||||||
|
if (homepage) {
|
||||||
|
try {
|
||||||
|
const url = new URL(homepage);
|
||||||
|
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||||
|
const parts = url.pathname.split('/').filter(Boolean);
|
||||||
|
if (parts.length >= 1) {
|
||||||
|
return `https://github.com/${parts[0]}.png`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return `https://api.iowen.cn/favicon/${url.hostname}.png`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取 GitHub 仓库信息 */
|
||||||
|
function extractGitHubRepo (url?: string): { owner: string; repo: string; } | null {
|
||||||
|
if (!url) return null;
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
if (urlObj.hostname === 'github.com' || urlObj.hostname === 'www.github.com') {
|
||||||
|
const parts = urlObj.pathname.split('/').filter(Boolean);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return { owner: parts[0], repo: parts[1] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 GitHub API 获取 README */
|
||||||
|
async function fetchGitHubReadme (owner: string, repo: string): Promise<string> {
|
||||||
|
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/readme`, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.github.v3.raw',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch README');
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理 README 中的 HTML 标签,保留 Markdown */
|
||||||
|
function cleanReadmeHtml (content: string): string {
|
||||||
|
// 移除 HTML 注释
|
||||||
|
let cleaned = content.replace(/<!--[\s\S]*?-->/g, '');
|
||||||
|
|
||||||
|
// 保留常见的 Markdown 友好的 HTML 标签(img, br),其他的移除标签但保留内容
|
||||||
|
// 移除 style 和 script 标签及其内容
|
||||||
|
cleaned = cleaned.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
|
||||||
|
cleaned = cleaned.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||||
|
|
||||||
|
// 将其他 HTML 标签替换为空格或换行(保留内容)
|
||||||
|
// 保留 img 标签(转为 markdown)- 尝试提取 alt 和 src 属性
|
||||||
|
cleaned = cleaned.replace(/<img[^>]*\\bsrc=["']([^"']+)["'][^>]*\\balt=["']([^"']+)["'][^>]*>/gi, '');
|
||||||
|
cleaned = cleaned.replace(/<img[^>]*\\balt=["']([^"']+)["'][^>]*\\bsrc=["']([^"']+)["'][^>]*>/gi, '');
|
||||||
|
cleaned = cleaned.replace(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi, '');
|
||||||
|
|
||||||
|
// 移除其他 HTML 标签,但保留内容
|
||||||
|
cleaned = cleaned.replace(/<\/?[^>]+(>|$)/g, '');
|
||||||
|
|
||||||
|
// 清理多余的空行(超过2个连续换行)
|
||||||
|
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
|
||||||
|
|
||||||
|
return cleaned.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PluginDetailModal ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
plugin,
|
||||||
|
installStatus = 'not-installed',
|
||||||
|
installedVersion,
|
||||||
|
onInstall,
|
||||||
|
}: PluginDetailModalProps) {
|
||||||
|
const [readme, setReadme] = useState<string>('');
|
||||||
|
const [readmeLoading, setReadmeLoading] = useState(false);
|
||||||
|
const [readmeError, setReadmeError] = useState(false);
|
||||||
|
|
||||||
|
// 获取 GitHub 仓库信息(需要在 hooks 之前计算)
|
||||||
|
const githubRepo = plugin ? extractGitHubRepo(plugin.homepage) : null;
|
||||||
|
|
||||||
|
// 当模态框打开且有 GitHub 链接时,获取 README
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !githubRepo) {
|
||||||
|
setReadme('');
|
||||||
|
setReadmeError(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadReadme = async () => {
|
||||||
|
setReadmeLoading(true);
|
||||||
|
setReadmeError(false);
|
||||||
|
try {
|
||||||
|
const content = await fetchGitHubReadme(githubRepo.owner, githubRepo.repo);
|
||||||
|
// 清理 HTML 标签后再设置
|
||||||
|
setReadme(cleanReadmeHtml(content));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch README:', error);
|
||||||
|
setReadmeError(true);
|
||||||
|
} finally {
|
||||||
|
setReadmeLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadReadme();
|
||||||
|
}, [isOpen, githubRepo?.owner, githubRepo?.repo]);
|
||||||
|
|
||||||
|
if (!plugin) return null;
|
||||||
|
|
||||||
|
const { name, version, author, description, tags, homepage, downloadUrl, minVersion } = plugin;
|
||||||
|
const avatarUrl = getAuthorAvatar(homepage, downloadUrl) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
size='4xl'
|
||||||
|
scrollBehavior='inside'
|
||||||
|
classNames={{
|
||||||
|
backdrop: 'z-[200]',
|
||||||
|
wrapper: 'z-[200]',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalContent>
|
||||||
|
{(onModalClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader className='flex flex-col gap-3 pb-2'>
|
||||||
|
{/* 插件头部信息 */}
|
||||||
|
<div className='flex items-start gap-4'>
|
||||||
|
<Avatar
|
||||||
|
src={avatarUrl}
|
||||||
|
name={author || '?'}
|
||||||
|
size='lg'
|
||||||
|
isBordered
|
||||||
|
color='primary'
|
||||||
|
radius='lg'
|
||||||
|
className='flex-shrink-0'
|
||||||
|
/>
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<h2 className='text-2xl font-bold text-default-900'>{name}</h2>
|
||||||
|
{homepage && (
|
||||||
|
<Tooltip content='访问项目主页'>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size='sm'
|
||||||
|
variant='flat'
|
||||||
|
color='primary'
|
||||||
|
as='a'
|
||||||
|
href={homepage}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
>
|
||||||
|
<IoMdOpen size={18} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className='text-sm text-default-500 mt-1'>
|
||||||
|
by <span className='font-medium'>{author || '未知作者'}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 标签和版本信息 */}
|
||||||
|
<div className='flex items-center gap-2 mt-2 flex-wrap'>
|
||||||
|
<Chip size='sm' color='primary' variant='flat'>
|
||||||
|
v{version}
|
||||||
|
</Chip>
|
||||||
|
{tags?.map((tag) => (
|
||||||
|
<Chip
|
||||||
|
key={tag}
|
||||||
|
size='sm'
|
||||||
|
variant='flat'
|
||||||
|
className='bg-default-100 text-default-600'
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
{installStatus === 'update-available' && installedVersion && (
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
color='warning'
|
||||||
|
variant='shadow'
|
||||||
|
className='animate-pulse'
|
||||||
|
>
|
||||||
|
可更新
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
{installStatus === 'installed' && (
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
color='success'
|
||||||
|
variant='flat'
|
||||||
|
startContent={<IoMdCheckmarkCircle size={14} />}
|
||||||
|
>
|
||||||
|
已安装
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody className='gap-4'>
|
||||||
|
{/* 插件描述 */}
|
||||||
|
<div>
|
||||||
|
<h3 className='text-sm font-semibold text-default-700 mb-2'>插件描述</h3>
|
||||||
|
<p className='text-sm text-default-600 leading-relaxed'>
|
||||||
|
{description || '暂无描述'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 插件信息 */}
|
||||||
|
<div>
|
||||||
|
<h3 className='text-sm font-semibold text-default-700 mb-3'>插件信息</h3>
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm'>
|
||||||
|
<div className='flex justify-between items-center'>
|
||||||
|
<span className='text-default-500'>最新版本:</span>
|
||||||
|
<span className='font-medium text-default-900'>v{version}</span>
|
||||||
|
</div>
|
||||||
|
{installedVersion && (
|
||||||
|
<div className='flex justify-between items-center'>
|
||||||
|
<span className='text-default-500'>已安装版本:</span>
|
||||||
|
<span className='font-medium text-default-900'>v{installedVersion}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{minVersion && (
|
||||||
|
<div className='flex justify-between items-center'>
|
||||||
|
<span className='text-default-500'>最低要求版本:</span>
|
||||||
|
<span className='font-medium text-default-900'>v{minVersion}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className='flex justify-between items-center'>
|
||||||
|
<span className='text-default-500'>插件 ID:</span>
|
||||||
|
<span className='font-mono text-xs text-default-900'>{plugin.id}</span>
|
||||||
|
</div>
|
||||||
|
{downloadUrl && (
|
||||||
|
<div className='flex justify-between items-center'>
|
||||||
|
<span className='text-default-500'>下载地址:</span>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='flat'
|
||||||
|
color='primary'
|
||||||
|
as='a'
|
||||||
|
href={downloadUrl}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
startContent={<IoMdDownload size={14} />}
|
||||||
|
>
|
||||||
|
下载插件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GitHub README 显示 */}
|
||||||
|
{githubRepo && (
|
||||||
|
<>
|
||||||
|
<div className='mt-2'>
|
||||||
|
<h3 className='text-sm font-semibold text-default-700 mb-3'>详情</h3>
|
||||||
|
{readmeLoading && (
|
||||||
|
<div className='flex justify-center items-center py-12'>
|
||||||
|
<Spinner size='lg' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{readmeError && (
|
||||||
|
<div className='text-center py-8'>
|
||||||
|
<p className='text-sm text-default-500 mb-3'>
|
||||||
|
无法加载 README
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
color='primary'
|
||||||
|
variant='flat'
|
||||||
|
as='a'
|
||||||
|
href={homepage}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
startContent={<IoMdOpen />}
|
||||||
|
>
|
||||||
|
在 GitHub 查看
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!readmeLoading && !readmeError && readme && (
|
||||||
|
<div className='rounded-lg border border-default-200 p-4 bg-default-50'>
|
||||||
|
<TailwindMarkdown content={readme} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant='light' onPress={onModalClose}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
{installStatus === 'installed'
|
||||||
|
? (
|
||||||
|
<Button
|
||||||
|
color='success'
|
||||||
|
variant='flat'
|
||||||
|
startContent={<IoMdCheckmarkCircle size={18} />}
|
||||||
|
isDisabled
|
||||||
|
>
|
||||||
|
已安装
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
: installStatus === 'update-available'
|
||||||
|
? (
|
||||||
|
<Button
|
||||||
|
color='warning'
|
||||||
|
variant='shadow'
|
||||||
|
startContent={<MdUpdate size={18} />}
|
||||||
|
onPress={() => {
|
||||||
|
onInstall?.();
|
||||||
|
onModalClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
更新到 v{version}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<Button
|
||||||
|
color='primary'
|
||||||
|
variant='shadow'
|
||||||
|
startContent={<IoMdDownload size={18} />}
|
||||||
|
onPress={() => {
|
||||||
|
onInstall?.();
|
||||||
|
onModalClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
立即安装
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,10 +9,12 @@ import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
|
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
|
||||||
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
|
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
|
||||||
import MirrorSelectorModal from '@/components/mirror_selector_modal';
|
import MirrorSelectorModal from '@/components/mirror_selector_modal';
|
||||||
|
import PluginDetailModal from '@/pages/dashboard/plugin_detail_modal';
|
||||||
import { PluginStoreItem } from '@/types/plugin-store';
|
import { PluginStoreItem } from '@/types/plugin-store';
|
||||||
import useDialog from '@/hooks/use-dialog';
|
import useDialog from '@/hooks/use-dialog';
|
||||||
import key from '@/const/key';
|
import key from '@/const/key';
|
||||||
@@ -42,6 +44,7 @@ export default function PluginStorePage () {
|
|||||||
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
|
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
|
||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
// 快捷键支持: Ctrl+F 聚焦搜索框
|
// 快捷键支持: Ctrl+F 聚焦搜索框
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,6 +82,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 [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||||
|
const [selectedPlugin, setSelectedPlugin] = useState<PluginStoreItem | null>(null);
|
||||||
|
|
||||||
const loadPlugins = async (forceRefresh: boolean = false) => {
|
const loadPlugins = async (forceRefresh: boolean = false) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -100,6 +107,22 @@ export default function PluginStorePage () {
|
|||||||
loadPlugins();
|
loadPlugins();
|
||||||
}, [currentStoreSource]);
|
}, [currentStoreSource]);
|
||||||
|
|
||||||
|
// 处理 URL 参数中的插件 ID,自动打开详情
|
||||||
|
useEffect(() => {
|
||||||
|
const pluginId = searchParams.get('pluginId');
|
||||||
|
if (pluginId && plugins.length > 0 && !detailModalOpen) {
|
||||||
|
// 查找对应的插件
|
||||||
|
const targetPlugin = plugins.find(p => p.id === pluginId);
|
||||||
|
if (targetPlugin) {
|
||||||
|
setSelectedPlugin(targetPlugin);
|
||||||
|
setDetailModalOpen(true);
|
||||||
|
// 移除 URL 参数(可选)
|
||||||
|
// searchParams.delete('pluginId');
|
||||||
|
// setSearchParams(searchParams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [plugins, searchParams, detailModalOpen]);
|
||||||
|
|
||||||
// 按标签分类和搜索
|
// 按标签分类和搜索
|
||||||
const categorizedPlugins = useMemo(() => {
|
const categorizedPlugins = useMemo(() => {
|
||||||
let filtered = plugins;
|
let filtered = plugins;
|
||||||
@@ -383,6 +406,10 @@ export default function PluginStorePage () {
|
|||||||
installStatus={installInfo.status}
|
installStatus={installInfo.status}
|
||||||
installedVersion={installInfo.installedVersion}
|
installedVersion={installInfo.installedVersion}
|
||||||
onInstall={() => { handleInstall(plugin); }}
|
onInstall={() => { handleInstall(plugin); }}
|
||||||
|
onViewDetail={() => {
|
||||||
|
setSelectedPlugin(plugin);
|
||||||
|
setDetailModalOpen(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -421,6 +448,28 @@ export default function PluginStorePage () {
|
|||||||
type='file'
|
type='file'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 插件详情弹窗 */}
|
||||||
|
<PluginDetailModal
|
||||||
|
isOpen={detailModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDetailModalOpen(false);
|
||||||
|
setSelectedPlugin(null);
|
||||||
|
// 清除 URL 参数
|
||||||
|
if (searchParams.has('pluginId')) {
|
||||||
|
searchParams.delete('pluginId');
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
plugin={selectedPlugin}
|
||||||
|
installStatus={selectedPlugin ? getPluginInstallInfo(selectedPlugin).status : 'not-installed'}
|
||||||
|
installedVersion={selectedPlugin ? getPluginInstallInfo(selectedPlugin).installedVersion : undefined}
|
||||||
|
onInstall={() => {
|
||||||
|
if (selectedPlugin) {
|
||||||
|
handleInstall(selectedPlugin);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 插件下载进度条全局居中样式 */}
|
{/* 插件下载进度条全局居中样式 */}
|
||||||
{installProgress.show && (
|
{installProgress.show && (
|
||||||
<div className='fixed inset-0 flex items-center justify-center z-[9999] animate-in fade-in duration-300'>
|
<div className='fixed inset-0 flex items-center justify-center z-[9999] animate-in fade-in duration-300'>
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import CryptoJS from 'crypto-js';
|
import CryptoJS from 'crypto-js';
|
||||||
|
import { MdSettings } from 'react-icons/md';
|
||||||
|
|
||||||
import logo from '@/assets/images/logo.png';
|
import logo from '@/assets/images/logo.png';
|
||||||
|
import GUIDManager from '@/components/guid_manager';
|
||||||
|
import Modal from '@/components/modal';
|
||||||
|
|
||||||
import HoverEffectCard from '@/components/effect_card';
|
import HoverEffectCard from '@/components/effect_card';
|
||||||
import { title } from '@/components/primitives';
|
import { title } from '@/components/primitives';
|
||||||
@@ -174,6 +177,8 @@ export default function QQLoginPage () {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [showGUIDManager, setShowGUIDManager] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
onUpdateQrCode();
|
onUpdateQrCode();
|
||||||
@@ -210,7 +215,12 @@ export default function QQLoginPage () {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ThemeSwitch className='absolute right-4 top-4' />
|
<div className='absolute right-4 top-4 flex items-center gap-2'>
|
||||||
|
<Button isIconOnly variant="light" aria-label="Settings" onPress={() => setShowGUIDManager(true)}>
|
||||||
|
<MdSettings size={22} />
|
||||||
|
</Button>
|
||||||
|
<ThemeSwitch />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardBody className='flex gap-5 p-10 pt-0'>
|
<CardBody className='flex gap-5 p-10 pt-0'>
|
||||||
@@ -266,6 +276,15 @@ export default function QQLoginPage () {
|
|||||||
</HoverEffectCard>
|
</HoverEffectCard>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</PureLayout>
|
</PureLayout>
|
||||||
|
{showGUIDManager && (
|
||||||
|
<Modal
|
||||||
|
title='设备 GUID 管理'
|
||||||
|
content={<GUIDManager compact showRestart />}
|
||||||
|
size='lg'
|
||||||
|
hideFooter
|
||||||
|
onClose={() => setShowGUIDManager(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -128,6 +128,12 @@ importers:
|
|||||||
specifier: ^22.0.1
|
specifier: ^22.0.1
|
||||||
version: 22.19.1
|
version: 22.19.1
|
||||||
|
|
||||||
|
packages/napcat-dpapi:
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.0.1
|
||||||
|
version: 22.19.1
|
||||||
|
|
||||||
packages/napcat-framework:
|
packages/napcat-framework:
|
||||||
dependencies:
|
dependencies:
|
||||||
napcat-adapter:
|
napcat-adapter:
|
||||||
@@ -441,6 +447,9 @@ importers:
|
|||||||
napcat-common:
|
napcat-common:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../napcat-common
|
version: link:../napcat-common
|
||||||
|
napcat-dpapi:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../napcat-dpapi
|
||||||
napcat-pty:
|
napcat-pty:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../napcat-pty
|
version: link:../napcat-pty
|
||||||
|
|||||||
Reference in New Issue
Block a user