Compare commits

...

3 Commits

Author SHA1 Message Date
手瓜一十雪
2f8569f30c Add Registry20 GUID management and DPAPI support
Introduce tools and UI to read, write, backup and restore QQ Registry20 GUIDs using Windows DPAPI. Adds a Python CLI (guid_tool.py) for local Registry20 operations and a new TypeScript package napcat-dpapi with native bindings for DPAPI. Implements Registry20Utils in the webui-backend to protect/unprotect Registry20, plus backup/restore/delete helpers. Exposes new backend API handlers and routes (get/set GUID, backups, restore, reset, restart) and integrates frontend GUIDManager component and qq_manager controller methods. Propagates QQ data path via WebUiDataRuntime (setter/getter) and wires it up in framework/shell; updates Vite alias and package.json to include the new dpapi workspace. Includes native addon binaries for win32 x64/arm64 and basic tsconfig/readme/license for the new package.
2026-02-12 17:08:24 +08:00
林小槐
82d0c51716 feat(webui): 插件商店增加插件详情弹窗并支持通过 url 传递 id 直接打开 (#1615)
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
* feat(webui): 插件商店增加插件详情弹窗并支持通过 url 传递 id 直接打开

* fix(webui):type check
2026-02-11 12:12:06 +08:00
手瓜一十雪
37fb2d68d7 Prefer QQAppId/ marker when parsing AppID
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
Add parseAppidFromMajorV2 to napcat-common to scan a Major file for the "QQAppId/" marker and extract a null-terminated numeric AppID. Update qq-basic-info to import and prefer this new parser (falling back to the existing parseAppidFromMajor). Also correct the getMajorPath argument order when obtaining the major file path. This enables detection of AppID from a newer Major format while preserving legacy fallback behavior.
2026-02-08 09:55:31 +08:00
28 changed files with 1414 additions and 22 deletions

3
.gitignore vendored
View File

@@ -10,6 +10,9 @@ devconfig/*
!.vscode/extensions.json !.vscode/extensions.json
.idea/* .idea/*
# macOS
.DS_Store
# Build # Build
*.db *.db
checkVersion.sh checkVersion.sh

View File

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

View File

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

View 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.

View File

@@ -0,0 +1,4 @@
# @primno/dpapi
## 协议与说明
全部遵守原仓库要求

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

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

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "."
},
"include": [
"./**/*.ts"
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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, '![$2]($1)');
cleaned = cleaned.replace(/<img[^>]*\\balt=["']([^"']+)["'][^>]*\\bsrc=["']([^"']+)["'][^>]*>/gi, '![$1]($2)');
cleaned = cleaned.replace(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi, '![]($1)');
// 移除其他 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>
);
}

View File

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

View File

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

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