Compare commits

...

14 Commits

Author SHA1 Message Date
手瓜一十雪
37fb2d68d7 Prefer QQAppId/ marker when parsing AppID
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
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
手瓜一十雪
a240f93784 Add appid entry for 9.9.27-45758
Add a new mapping to packages/napcat-core/external/appid.json for version 9.9.27-45758 (Windows). The entry sets appid to 537340213 and qua to "V1_WIN_NQ_9.9.27_45758_GW_B".
2026-02-08 09:41:14 +08:00
时瑾
172a75b514 fix(webui-backend): sanitize plugin ID to prevent path injection (CodeQL js/path-injection)
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
2026-02-07 13:52:15 +08:00
时瑾
beef1233fa fix(ci): 修复 Update apifox 步骤中因 OpenAPI 文件过大导致的参数列表过长错误 2026-02-07 13:44:25 +08:00
时瑾
8f69e2424a fix(backend): 修复插件列表接口新增字段导致的类型错误 2026-02-07 13:38:53 +08:00
时瑾
f3156b1843 Merge branch 'main' of https://github.com/NapNeko/NapCatQQ 2026-02-07 13:32:58 +08:00
时瑾
61a16b44a4 style(webui): 优化插件商店与插件管理界面 UI/UX
- 重构插件卡片样式,采用毛玻璃效果与主题色交互
- 优化插件商店搜索栏布局,增加对顶部搜索及 Ctrl+F 快捷键的支持
- 实现智能头像提取逻辑,支持从 GitHub、自定义域名(Favicon)及 Vercel 自动生成
- 增加插件描述溢出预览(悬停提示及点击展开功能)
- 修复标签溢出处理,支持 Tooltip 完整显示
- 增强后端插件列表 API,支持返回主页及仓库信息
- 修复部分类型错误与代码规范问题
2026-02-07 13:30:50 +08:00
Nepenthe
7b17ef44dd Merge pull request #1598 from faithleysath/feat/schema-enhancement-refactor
refactor(schema): 重构 schema 组件引用与 OpenAPI 生成流程,并补齐消息 union 类型
2026-02-07 13:27:58 +08:00
吴天一
2c166299a3 fix(schema): 添加 data 字段描述到 BaseResponseSchema 2026-02-07 13:21:06 +08:00
吴天一
75d2611eda fix(schema): 将响应 data 字段标记为必填 2026-02-07 09:27:47 +08:00
吴天一
e2d2e65620 fix(schema): 修复 properties 容器误判导致错误注入 type 2026-02-06 21:37:50 +08:00
吴天一
f479fccf3e refactor(schema): 提取并复用 OpenAPI 响应示例,减少重复定义
在 napcat-schema 中引入 ActionExamples,统一复用公共错误示例定义。

新增默认成功示例 Success_Default,并注册到 components.examples。

将公共错误示例(1400/1401/1404)集中注册到 components.examples,统一响应示例来源。

优化 buildResponseExamples:当 returnExample 为 null、空对象或空数组时,自动引用默认成功示例;当错误示例命中公共定义时,优先使用  引用组件示例,未命中时保持内联示例。

未提供 errorExamples 时,默认引用 #/components/examples/Error_1400,减少重复定义并提升文档一致性。

本次变更不改变接口响应结构,主要改进 OpenAPI 示例复用策略与文档可维护性。
2026-02-06 17:07:24 +08:00
吴天一
45652612b4 refactor(schema): 重构消息 schema 与 OpenAPI 生成流程
- 为 OneBot action 相关对象补充稳定 $id,提升 schema 复用与组件引用能力
- 补齐消息联合类型,纳入 location/xml/miniapp 等消息段定义
- 重构 napcat-schema 中 OpenAPI 生成逻辑,优化组件注册与缺失项报告
- openapi schema 文件行数从 36433 缩减到 23137(减少 13296 行,约 36.5%)
- 统一 schema 结构与描述,降低后续扩展与文档生成维护成本
2026-02-06 16:12:34 +08:00
pohgxz
54266f97f8 fix: 尝试修复 Update apifox 错误
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
2026-02-05 22:47:02 +08:00
16 changed files with 1441 additions and 445 deletions

View File

@@ -458,24 +458,23 @@ jobs:
pnpm i
pnpm run build:openapi
# 读取并压缩 openapi.json 内容
OPENAPI_CONTENT=$(cat packages/napcat-schema/openapi.json | tr -d '\n\r\t' | sed 's/ */ /g' | sed 's/"/\\"/g')
# 构建 JSON 数据
JSON_DATA=$(printf '{
"input": "%s",
"options": {
# 使用 jq 安全地构建大型 JSON 数据并保存到文件
jq -n --rawfile input packages/napcat-schema/dist/openapi.json \
'{
input: $input,
options: {
"endpointOverwriteBehavior": "OVERWRITE_EXISTING",
"schemaOverwriteBehavior": "OVERWRITE_EXISTING",
"updateFolderOfChangedEndpoint": true,
"moduleId": 1140714,
"deleteUnmatchedResources": true
}
}' "$OPENAPI_CONTENT")
}' > apifox_payload.json
# 通过文件形式发送数据,避免命令行长度限制
curl --location -g --request POST 'https://api.apifox.com/v1/projects/5348325/import-openapi?locale=zh-CN' \
--header 'X-Apifox-Api-Version: 2024-03-28' \
--header 'Authorization: Bearer $APIFOX_TOKEN' \
--header "Authorization: Bearer $APIFOX_TOKEN" \
--header 'Content-Type: application/json' \
--data-raw "$JSON_DATA"
--data-binary @apifox_payload.json

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 {
const hexSequence = 'A4 09 00 00 00 35';
const sequenceBytes = Buffer.from(hexSequence.replace(/ /g, ''), 'hex');

View File

@@ -530,5 +530,9 @@
"3.2.25-45758": {
"appid": 537340249,
"qua": "V1_LNX_NQ_3.2.25_45758_GW_B"
},
"9.9.27-45758": {
"appid": 537340213,
"qua": "V1_WIN_NQ_9.9.27_45758_GW_B"
}
}

View File

@@ -1,6 +1,6 @@
import fs from 'node:fs';
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 { LogWrapper } from './log';
import { getMajorPath } from '@/napcat-core/index';
@@ -107,7 +107,13 @@ export class QQBasicInfoWrapper {
if (!this.QQMainPath) {
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);
return appid;
}

View File

@@ -17,7 +17,7 @@ export const OB11UserSchema = Type.Object({
login_days: Type.Optional(Type.Number({ description: '登录天数' })),
categoryName: Type.Optional(Type.String({ description: '分组名称' })),
categoryId: Type.Optional(Type.Number({ description: '分组ID' })),
}, { description: 'OneBot 11 用户信息' });
}, { $id: 'OB11User', description: 'OneBot 11 用户信息' });
export const OB11GroupSchema = Type.Object({
group_all_shut: Type.Number({ description: '是否全员禁言' }),
@@ -26,7 +26,7 @@ export const OB11GroupSchema = Type.Object({
group_name: Type.String({ description: '群名称' }),
member_count: Type.Optional(Type.Number({ description: '成员人数' })),
max_member_count: Type.Optional(Type.Number({ description: '最大成员人数' })),
}, { description: 'OneBot 11 群信息' });
}, { $id: 'OB11Group', description: 'OneBot 11 群信息' });
export const OB11GroupMemberSchema = Type.Object({
group_id: Type.Number({ description: '群号' }),
@@ -48,7 +48,7 @@ export const OB11GroupMemberSchema = Type.Object({
shut_up_timestamp: Type.Optional(Type.Number({ description: '禁言截止时间戳' })),
is_robot: Type.Optional(Type.Boolean({ description: '是否为机器人' })),
qage: Type.Optional(Type.Number({ description: 'Q龄' })),
}, { description: 'OneBot 11 群成员信息' });
}, { $id: 'OB11GroupMember', description: 'OneBot 11 群成员信息' });
export const OB11NotifySchema = Type.Object({
request_id: Type.Number({ description: '请求ID' }),
@@ -60,7 +60,7 @@ export const OB11NotifySchema = Type.Object({
checked: Type.Boolean({ description: '是否已处理' }),
actor: Type.Number({ description: '操作者QQ' }),
requester_nick: Type.String({ description: '申请者昵称' }),
}, { description: 'OneBot 11 通知信息' });
}, { $id: 'OB11Notify', description: 'OneBot 11 通知信息' });
export const lastestMessageSchema = Type.Object({
self_id: Type.Number({ description: '发送者QQ号' }),
@@ -82,7 +82,7 @@ export const lastestMessageSchema = Type.Object({
post_type: Type.String({ description: '发布类型' }),
group_id: Type.Number({ description: '群号' }),
group_name: Type.String({ description: '群名称' }),
}, { description: '最后一条消息' });
}, { $id: 'OB11LatestMessage', description: '最后一条消息' });
export const OB11MessageSchema = Type.Intersect([
lastestMessageSchema,
@@ -95,4 +95,4 @@ export const OB11MessageSchema = Type.Intersect([
likes_cnt: Type.String({ description: '点赞数' }),
})),
}, { description: 'OneBot 11 消息信息' })
]);
], { $id: 'OB11ActionMessage', description: 'OneBot 11 消息信息' });

View File

@@ -13,6 +13,8 @@ export interface PluginPackageJson {
main?: string;
description?: string;
author?: string;
homepage?: string;
repository?: string | { type: string; url: string; };
}
// ==================== 插件配置 Schema ====================

View File

@@ -325,8 +325,11 @@ export const OB11MessageDataSchema = Type.Union([
OB11MessageDiceSchema,
OB11MessageRPSSchema,
OB11MessageContactSchema,
OB11MessageLocationSchema,
OB11MessageJsonSchema,
OB11MessageXmlSchema,
OB11MessageMarkdownSchema,
OB11MessageMiniAppSchema,
OB11MessageNodeSchema,
OB11MessageForwardSchema,
OB11MessageOnlineFileSchema,

View File

@@ -2,13 +2,16 @@ import { getAllHandlers } from '@/napcat-onebot/action/index';
import { AutoRegisterRouter } from '@/napcat-onebot/action/auto-register';
import { writeFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { TSchema } from '@sinclair/typebox';
import { fileURLToPath } from 'node:url';
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
import { TSchema } from '@sinclair/typebox';
import { OneBotAction, ActionExamples } from '@/napcat-onebot/action/OneBotAction';
import { napCatVersion } from 'napcat-common/src/version';
import * as MessageSchemas from '@/napcat-onebot/types/message';
import * as ActionSchemas from '@/napcat-onebot/action/schemas';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/* -------------------------------------------------------------------------- */
/* 基础类型 */
/* -------------------------------------------------------------------------- */
interface ActionSchemaInfo {
payload?: TSchema;
@@ -21,52 +24,524 @@ interface ActionSchemaInfo {
errorExamples?: Array<{ code: number, description: string; }>;
}
type JsonObject = { [key: string]: unknown; };
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const OPENAPI_OUTPUT_PATH = resolve(__dirname, 'openapi.json');
const MISSING_REPORT_PATH = resolve(__dirname, 'missing_props.log');
export const actionSchemas: Record<string, ActionSchemaInfo> = {};
/* -------------------------------------------------------------------------- */
/* 日志工具 */
/* -------------------------------------------------------------------------- */
/**
* 统一日志前缀,方便在构建日志中快速检索。
*/
const LOG_SCOPE = '[napcat-schema]';
function logSection (title: string) {
console.log(`\n${LOG_SCOPE} ── ${title}`);
}
function logInfo (message: string) {
console.log(`${LOG_SCOPE} ${message}`);
}
function logWarn (message: string) {
console.warn(`${LOG_SCOPE}${message}`);
}
function logSuccess (message: string) {
console.log(`${LOG_SCOPE}${message}`);
}
/* -------------------------------------------------------------------------- */
/* OpenAPI 基础组件(固定部分) */
/* -------------------------------------------------------------------------- */
const BaseResponseSchema: JsonObject = {
type: 'object',
'x-schema-id': 'BaseResponse',
properties: {
status: { type: 'string', description: '状态 (ok/failed)' },
retcode: { type: 'number', description: '返回码' },
data: { description: '业务数据(具体结构由各接口定义)' },
message: { type: 'string', description: '消息' },
wording: { type: 'string', description: '提示' },
stream: {
type: 'string',
description: '流式响应',
enum: ['stream-action', 'normal-action']
}
},
required: ['status', 'retcode']
};
const EmptyDataSchema: JsonObject = {
description: '无数据',
type: 'null'
};
const DEFAULT_SUCCESS_EXAMPLE_VALUE = {
status: 'ok',
retcode: 0,
data: {},
message: '',
wording: '',
stream: 'normal-action'
} as const;
const DEFAULT_ERROR_EXAMPLE_DEFINITIONS = ActionExamples.Common.errors;
const SUCCESS_DEFAULT_EXAMPLE_KEY = 'Success_Default';
function isObjectRecord (value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function isEmptyObject (value: unknown): value is Record<string, never> {
return isObjectRecord(value) && Object.keys(value).length === 0;
}
function isEmptyArray (value: unknown): value is [] {
return Array.isArray(value) && value.length === 0;
}
function isMeaninglessSuccessExampleData (value: unknown): boolean {
return value === null || isEmptyObject(value) || isEmptyArray(value);
}
function resolveCommonErrorExampleKey (error: { code: number, description: string; }): string | null {
const matched = DEFAULT_ERROR_EXAMPLE_DEFINITIONS.find(
item => item.code === error.code && item.description === error.description
);
return matched ? `Error_${matched.code}` : null;
}
/* -------------------------------------------------------------------------- */
/* 通用工具函数 */
/* -------------------------------------------------------------------------- */
/**
* 深拷贝 schema优先使用 structuredClone失败时回落到 JSON 序列化。
*/
function cloneSchema<T> (schema: T): T {
if (typeof globalThis.structuredClone === 'function') {
try {
return globalThis.structuredClone(schema);
} catch {
// fallback
}
}
return JSON.parse(JSON.stringify(schema)) as T;
}
/**
* 在 anyOf/oneOf 中,将“多个单值 enum 分支 + 可选 nullable 分支”压缩为单个 enum。
*
* 例:
* - anyOf: [{ type:'string', enum:['a'] }, { type:'string', enum:['b'] }]
* -> { type:'string', enum:['a','b'] }
*/
function collapseSingleValueEnumCombinator (items: unknown[]): Record<string, unknown> | null {
const enumValues: unknown[] = [];
let type: string | undefined;
let nullable = false;
for (const item of items) {
if (!item || typeof item !== 'object') {
return null;
}
const branch = item as Record<string, unknown>;
// 兼容仅有 nullable 的分支
if (branch['nullable'] === true && Object.keys(branch).length === 1) {
nullable = true;
continue;
}
const branchEnum = branch['enum'];
if (!Array.isArray(branchEnum) || branchEnum.length !== 1) {
return null;
}
enumValues.push(branchEnum[0]);
if (typeof branch['type'] === 'string') {
if (!type) {
type = branch['type'];
} else if (type !== branch['type']) {
return null;
}
}
}
if (enumValues.length === 0) {
return null;
}
const merged: Record<string, unknown> = { enum: [...new Set(enumValues)] };
if (type) {
merged['type'] = type;
}
if (nullable) {
merged['nullable'] = true;
}
return merged;
}
/**
* 将 TypeBox/JSON-Schema 映射为 OpenAPI 3.1 兼容结构。
*
* 关键规则:
* - $id -> x-schema-id保留标识用于后续 $ref 替换)
* - const -> enum:[const]
* - type:'void' / type:'undefined' -> type:'null'
* - nullable:true -> type 包含 'null'
* - anyOf/oneOf 的简单 enum 分支做压缩
*/
function sanitizeSchemaForOpenAPI<T> (schema: T): T {
const walk = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(walk);
}
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
const next: Record<string, unknown> = {};
for (const [key, child] of Object.entries(obj)) {
// 特殊处理 properties 容器:只遍历每个属性的 schema避免将容器对象误判为 schema 元对象
if (key === 'properties' && child && typeof child === 'object' && !Array.isArray(child)) {
const cleanProps: Record<string, unknown> = {};
for (const [propName, propSchema] of Object.entries(child as Record<string, unknown>)) {
cleanProps[propName] = walk(propSchema);
}
next[key] = cleanProps;
continue;
}
if (key === '$id') {
if (typeof child === 'string' && child.length > 0) {
next['x-schema-id'] = child;
}
continue;
}
if (key === 'const') {
next['enum'] = [child];
continue;
}
if (key === 'type' && typeof child === 'string') {
if (child === 'void' || child === 'undefined') {
next['type'] = 'null';
} else {
next['type'] = child;
}
continue;
}
if (key === 'type' && Array.isArray(child)) {
const types = child
.filter((t): t is string => typeof t === 'string')
.map(t => (t === 'void' || t === 'undefined') ? 'null' : t);
const normalizedTypes = [...new Set(types)];
if (normalizedTypes.length === 0) {
next['type'] = 'null';
} else if (normalizedTypes.length === 1) {
next['type'] = normalizedTypes[0];
} else {
next['type'] = normalizedTypes;
}
continue;
}
if ((key === 'anyOf' || key === 'oneOf') && Array.isArray(child)) {
const normalized = child.map(walk);
const mergedEnum = collapseSingleValueEnumCombinator(normalized);
if (mergedEnum) {
Object.assign(next, mergedEnum);
} else {
next[key] = normalized;
}
continue;
}
next[key] = walk(child);
}
// OpenAPI 3.1:将 nullable 归一到 type 包含 null
if (next['nullable'] === true) {
const currentType = next['type'];
if (typeof currentType === 'string') {
next['type'] = currentType === 'null' ? 'null' : [currentType, 'null'];
} else if (Array.isArray(currentType)) {
const normalizedTypes = [
...new Set(currentType
.filter((t): t is string => typeof t === 'string')
.map(t => (t === 'void' || t === 'undefined') ? 'null' : t)
.concat('null'))
];
next['type'] = normalizedTypes.length === 1 ? normalizedTypes[0] : normalizedTypes;
} else if (!('anyOf' in next) && !('oneOf' in next) && !('allOf' in next) && !('$ref' in next)) {
next['type'] = 'null';
}
delete next['nullable'];
}
// 兜底:仅有描述/元信息但缺少 type 时,补 object避免严格校验失败
if (
!('type' in next)
&& !('$ref' in next)
&& !('anyOf' in next)
&& !('oneOf' in next)
&& !('allOf' in next)
&& !('enum' in next)
&& !('properties' in next)
&& !('items' in next)
) {
const schemaMetaKeys = [
'description', 'title', 'default', 'examples', 'example',
'deprecated', 'readOnly', 'writeOnly', 'x-schema-id'
];
if (schemaMetaKeys.some(key => key in next)) {
next['type'] = 'object';
}
}
return next;
}
return value;
};
return walk(schema) as T;
}
/* -------------------------------------------------------------------------- */
/* Schema 注册 & 引用替换逻辑 */
/* -------------------------------------------------------------------------- */
/**
* 将模块中所有“含 $id 的导出 schema”注册到 components.schemas。
*/
function registerSchemasFromModule (
openapi: JsonObject,
source: Record<string, unknown>,
sourceName: string
) {
const components = ((openapi['components'] as JsonObject)['schemas'] as JsonObject);
let registeredCount = 0;
let duplicatedCount = 0;
for (const exportedValue of Object.values(source)) {
if (!exportedValue || typeof exportedValue !== 'object') {
continue;
}
const schema = cloneSchema(exportedValue) as JsonObject;
const schemaId = typeof schema['$id'] === 'string' && (schema['$id'] as string).length > 0
? schema['$id'] as string
: '';
if (!schemaId) {
continue;
}
if (components[schemaId]) {
duplicatedCount += 1;
logWarn(`发现重复 schema id${sourceName}${schemaId},将覆盖旧定义`);
}
components[schemaId] = sanitizeSchemaForOpenAPI(schema);
registeredCount += 1;
}
logInfo(`${sourceName} 注册完成:${registeredCount} 个 schema重复 ${duplicatedCount}`);
}
/**
* 对 components.schemas 做去内联:
* - 若子节点含 x-schema-id 且在 components.schemas 可命中
* - 则替换为 $ref
*
* 注意:组件根节点不会替换为自身,避免根级自引用。
*/
function replaceComponentInlineSchemasWithRefs (openapi: JsonObject) {
const components = openapi['components'] as JsonObject | undefined;
const schemas = components?.['schemas'] as JsonObject | undefined;
if (!schemas || typeof schemas !== 'object') {
return;
}
const availableSchemaIds = new Set(Object.keys(schemas));
let replacedCount = 0;
const walk = (value: unknown, ownerSchemaId: string): unknown => {
if (Array.isArray(value)) {
return value.map(item => walk(item, ownerSchemaId));
}
if (value && typeof value === 'object') {
const obj = value as JsonObject;
const schemaId = obj['x-schema-id'];
if (
typeof schemaId === 'string'
&& schemaId !== ownerSchemaId
&& availableSchemaIds.has(schemaId)
) {
replacedCount += 1;
return { $ref: `#/components/schemas/${schemaId}` };
}
const next: JsonObject = {};
for (const [key, child] of Object.entries(obj)) {
next[key] = walk(child, ownerSchemaId);
}
return next;
}
return value;
};
for (const [schemaId, schema] of Object.entries(schemas)) {
schemas[schemaId] = walk(schema, schemaId);
}
logInfo(`components 内联替换完成:${replacedCount}`);
}
/**
* 对 paths 做去内联:
* - 若节点含 x-schema-id 且在 components.schemas 可命中
* - 则替换为 $ref
*/
function replacePathInlineSchemasWithRefs (openapi: JsonObject) {
const paths = openapi['paths'];
const components = openapi['components'] as JsonObject | undefined;
const schemas = components?.['schemas'] as JsonObject | undefined;
if (!paths || typeof paths !== 'object' || !schemas || typeof schemas !== 'object') {
return;
}
const availableSchemaIds = new Set(Object.keys(schemas));
let replacedCount = 0;
const walk = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(walk);
}
if (value && typeof value === 'object') {
const obj = value as JsonObject;
const schemaId = obj['x-schema-id'];
if (typeof schemaId === 'string' && availableSchemaIds.has(schemaId)) {
replacedCount += 1;
return { $ref: `#/components/schemas/${schemaId}` };
}
const next: JsonObject = {};
for (const [key, child] of Object.entries(obj)) {
next[key] = walk(child);
}
return next;
}
return value;
};
openapi['paths'] = walk(paths) as JsonObject;
logInfo(`paths 内联替换完成:${replacedCount}`);
}
/* -------------------------------------------------------------------------- */
/* Action 收集逻辑 */
/* -------------------------------------------------------------------------- */
/**
* 收集全部 action schema 信息。
*/
export function initSchemas () {
const handlers = getAllHandlers(null as any, null as any);
handlers.forEach(handler => {
if (handler.actionName && (handler.actionName as string) !== 'unknown') {
const action = handler as OneBotAction<unknown, unknown>;
actionSchemas[handler.actionName] = {
payload: action.payloadSchema,
return: action.returnSchema,
summary: action.actionSummary,
description: action.actionDescription,
tags: action.actionTags,
payloadExample: action.payloadExample,
returnExample: action.returnExample,
errorExamples: action.errorExamples
};
if (!handler.actionName || (handler.actionName as string) === 'unknown') {
return;
}
const action = handler as OneBotAction<unknown, unknown>;
actionSchemas[handler.actionName] = {
payload: action.payloadSchema,
return: action.returnSchema,
summary: action.actionSummary,
description: action.actionDescription,
tags: action.actionTags,
payloadExample: action.payloadExample,
returnExample: action.returnExample,
errorExamples: action.errorExamples
};
});
AutoRegisterRouter.forEach((ActionClass) => {
AutoRegisterRouter.forEach(ActionClass => {
const handler = new ActionClass(null as any, null as any);
if (handler.actionName && (handler.actionName as string) !== 'unknown') {
const action = handler as OneBotAction<unknown, unknown>;
actionSchemas[handler.actionName] = {
payload: action.payloadSchema,
return: action.returnSchema,
summary: action.actionSummary,
description: action.actionDescription,
tags: action.actionTags,
payloadExample: action.payloadExample,
returnExample: action.returnExample,
errorExamples: action.errorExamples
};
if (!handler.actionName || (handler.actionName as string) === 'unknown') {
return;
}
const action = handler as OneBotAction<unknown, unknown>;
actionSchemas[handler.actionName] = {
payload: action.payloadSchema,
return: action.returnSchema,
summary: action.actionSummary,
description: action.actionDescription,
tags: action.actionTags,
payloadExample: action.payloadExample,
returnExample: action.returnExample,
errorExamples: action.errorExamples
};
});
}
export function generateOpenAPI () {
try {
initSchemas();
} catch (e) {
console.warn('Init schemas partial failure, proceeding with collected data...');
}
/* -------------------------------------------------------------------------- */
/* OpenAPI 构建主流程 */
/* -------------------------------------------------------------------------- */
const openapi: Record<string, unknown> = {
openapi: '3.0.1',
function createOpenAPIDocument (): Record<string, unknown> {
const componentExamples: Record<string, unknown> = {
[SUCCESS_DEFAULT_EXAMPLE_KEY]: {
summary: '成功响应',
value: DEFAULT_SUCCESS_EXAMPLE_VALUE
}
};
DEFAULT_ERROR_EXAMPLE_DEFINITIONS.forEach(error => {
componentExamples[`Error_${error.code}`] = {
summary: error.description,
value: {
status: 'failed',
retcode: error.code,
data: null,
message: error.description,
wording: error.description,
stream: 'normal-action'
}
};
});
return {
openapi: '3.1.0',
info: {
title: 'NapCat OneBot 11 HTTP API',
description: 'NapCatOneBot11 HTTP POST 接口文档',
@@ -81,39 +556,43 @@ export function generateOpenAPI () {
],
paths: {} as Record<string, unknown>,
components: {
schemas: {},
schemas: {
BaseResponse: BaseResponseSchema,
EmptyData: EmptyDataSchema
},
examples: componentExamples,
responses: {},
securitySchemes: {}
},
servers: [],
security: []
};
}
for (const [actionName, schemas] of Object.entries(actionSchemas)) {
if (!schemas.payload && !schemas.summary) continue;
const path = '/' + actionName;
const cleanPayload = schemas.payload ? JSON.parse(JSON.stringify(schemas.payload)) : { type: 'object', properties: {} };
const cleanReturn = schemas.return ? JSON.parse(JSON.stringify(schemas.return)) : { type: 'object', properties: {} };
// 构造响应示例
const responseExamples: Record<string, any> = {
'Success': {
function buildResponseExamples (schemas: ActionSchemaInfo): Record<string, unknown> {
const successData = schemas.returnExample ?? {};
const examples: Record<string, any> = {
Success: isMeaninglessSuccessExampleData(successData)
? { $ref: `#/components/examples/${SUCCESS_DEFAULT_EXAMPLE_KEY}` }
: {
summary: '成功响应',
value: {
status: 'ok',
retcode: 0,
data: schemas.returnExample || {},
data: successData,
message: '',
wording: '',
stream: 'normal-action'
}
}
};
};
if (schemas.errorExamples) {
schemas.errorExamples.forEach(error => {
responseExamples['Error_' + error.code] = {
if (schemas.errorExamples) {
schemas.errorExamples.forEach(error => {
const commonErrorKey = resolveCommonErrorExampleKey(error);
examples[`Error_${error.code}`] = commonErrorKey
? { $ref: `#/components/examples/${commonErrorKey}` }
: {
summary: error.description,
value: {
status: 'failed',
@@ -124,23 +603,33 @@ export function generateOpenAPI () {
stream: 'normal-action'
}
};
});
} else {
// 默认提供一个通用错误
responseExamples['Generic_Error'] = {
summary: '通用错误',
value: {
status: 'failed',
retcode: 1400,
data: null,
message: '请求参数错误或业务逻辑执行失败',
wording: '请求参数错误或业务逻辑执行失败',
stream: 'normal-action'
}
};
});
return examples;
}
examples['Generic_Error'] = {
$ref: '#/components/examples/Error_1400'
};
return examples;
}
function appendActionPaths (openapi: Record<string, unknown>) {
const paths = openapi['paths'] as Record<string, any>;
for (const [actionName, schemas] of Object.entries(actionSchemas)) {
if (!schemas.payload && !schemas.summary) {
continue;
}
const paths = openapi['paths'] as Record<string, any>;
const path = `/${actionName}`;
const cleanPayload = schemas.payload
? sanitizeSchemaForOpenAPI(cloneSchema(schemas.payload))
: { type: 'object', properties: {} };
const cleanReturn = schemas.return
? sanitizeSchemaForOpenAPI(cloneSchema(schemas.return))
: { $ref: '#/components/schemas/EmptyData' };
paths[path] = {
post: {
summary: schemas.summary || actionName,
@@ -154,7 +643,7 @@ export function generateOpenAPI () {
'application/json': {
schema: cleanPayload,
examples: {
'Default': {
Default: {
summary: '默认请求示例',
value: schemas.payloadExample || {}
}
@@ -168,18 +657,21 @@ export function generateOpenAPI () {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
status: { type: 'string', description: '状态 (ok/failed)' },
retcode: { type: 'number', description: '返回码' },
data: { ...cleanReturn, description: '数据' },
message: { type: 'string', description: '消息' },
wording: { type: 'string', description: '提示' },
stream: { type: 'string', description: '流式响应', enum: ['stream-action', 'normal-action'] }
},
required: ['status', 'retcode', 'data']
allOf: [
{ $ref: '#/components/schemas/BaseResponse' },
{
type: 'object',
required: ['data'],
properties: {
data: {
...(typeof cleanReturn === 'object' && cleanReturn ? cleanReturn : {}),
description: '业务数据'
}
}
}
]
},
examples: responseExamples
examples: buildResponseExamples(schemas)
}
}
}
@@ -188,16 +680,46 @@ export function generateOpenAPI () {
}
};
}
}
const outputPath = resolve(__dirname, 'openapi.json');
writeFileSync(outputPath, JSON.stringify(openapi, null, 2));
console.log('OpenAPI schema (3.0.1 Format) generated at: ' + outputPath);
export function generateOpenAPI () {
logSection('开始生成 OpenAPI 文档');
try {
initSchemas();
logInfo(`已收集 action: ${Object.keys(actionSchemas).length}`);
} catch {
logWarn('初始化 schema 过程中出现部分失败,将继续使用已收集的数据');
}
const openapi = createOpenAPIDocument();
logSection('注册组件 schema');
registerSchemasFromModule(openapi as JsonObject, MessageSchemas, 'types/message.ts');
registerSchemasFromModule(openapi as JsonObject, ActionSchemas, 'action/schemas.ts');
logSection('处理组件内联引用');
replaceComponentInlineSchemasWithRefs(openapi as JsonObject);
logSection('构建 paths');
appendActionPaths(openapi);
logSection('处理 paths 内联引用');
replacePathInlineSchemasWithRefs(openapi as JsonObject);
writeFileSync(OPENAPI_OUTPUT_PATH, JSON.stringify(openapi, null, 2));
logSuccess(`OpenAPI 生成完成:${OPENAPI_OUTPUT_PATH}`);
generateMissingReport();
}
/* -------------------------------------------------------------------------- */
/* 元数据缺失报告 */
/* -------------------------------------------------------------------------- */
function generateMissingReport () {
const missingReport: string[] = [];
for (const [actionName, schemas] of Object.entries(actionSchemas)) {
const missing: string[] = [];
if (!schemas.summary) missing.push('actionSummary');
@@ -206,18 +728,20 @@ function generateMissingReport () {
if (schemas.returnExample === undefined) missing.push('returnExample');
if (missing.length > 0) {
missingReport.push('[' + actionName + '] 缺失属性: ' + missing.join(', '));
missingReport.push(`[${actionName}] 缺失属性: ${missing.join(', ')}`);
}
}
const reportPath = resolve(__dirname, 'missing_props.log');
if (missingReport.length > 0) {
writeFileSync(reportPath, missingReport.join('\n'));
console.warn('\n检查到 ' + missingReport.length + ' 个接口存在元数据缺失,报告已保存至: ' + reportPath);
} else {
if (existsSync(reportPath)) writeFileSync(reportPath, '');
console.log('\n所有接口元数据已完整');
writeFileSync(MISSING_REPORT_PATH, missingReport.join('\n'));
logWarn(`检查到 ${missingReport.length} 个接口元数据缺失,报告已写入:${MISSING_REPORT_PATH}`);
return;
}
if (existsSync(MISSING_REPORT_PATH)) {
writeFileSync(MISSING_REPORT_PATH, '');
}
logSuccess('所有接口元数据完整');
}
generateOpenAPI();

View File

@@ -390,7 +390,7 @@ export async function NCoreInitShell () {
// 初始化 FFmpeg 服务
await FFmpegService.init(pathWrapper.binaryPath, logger);
if (!(process.env['NAPCAT_DISABLE_PIPE'] == '1' || process.env['NAPCAT_WORKER_PROCESS'] == '1')) {
if (!(process.env['NAPCAT_DISABLE_PIPE'] === '1' || process.env['NAPCAT_WORKER_PROCESS'] === '1')) {
await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
}
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });

View File

@@ -75,6 +75,8 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
status: string;
hasConfig: boolean;
hasPages: boolean;
homepage?: string;
repository?: string;
}> = new Array();
// 收集所有插件的扩展页面
@@ -111,7 +113,11 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
author: p.packageJson?.author || '',
status,
hasConfig: !!(p.runtime.module?.plugin_config_schema || p.runtime.module?.plugin_config_ui),
hasPages
hasPages,
homepage: p.packageJson?.homepage,
repository: typeof p.packageJson?.repository === 'string'
? p.packageJson.repository
: p.packageJson?.repository?.url
});
// 收集插件的扩展页面

View File

@@ -27,6 +27,22 @@ const PLUGIN_STORE_SOURCES = [
// 插件目录 - 使用 pathWrapper
const getPluginsDir = () => webUiPathWrapper.pluginPath;
/**
* 验证插件 ID防止路径注入攻击
*/
function validatePluginId (id: any): string {
if (typeof id !== 'string') {
throw new Error('Invalid plugin ID');
}
// 仅允许字母、数字、点、下划线、连字符,禁止路径遍历字符
// 通过 path.basename 进一步确保不包含路径分隔符
const safeId = path.basename(id);
if (!/^[a-zA-Z0-9._-]+$/.test(safeId) || safeId !== id) {
throw new Error('Invalid plugin ID format');
}
return safeId;
}
// 插件列表缓存
let pluginListCache: PluginStoreList | null = null;
let cacheTimestamp: number = 0;
@@ -40,7 +56,7 @@ async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginSt
// 检查缓存(如果不是强制刷新)
const now = Date.now();
if (!forceRefresh && pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
//console.log('Using cached plugin list');
// console.log('Using cached plugin list');
return pluginListCache;
}
@@ -64,7 +80,7 @@ async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginSt
}
const data = await response.json();
//console.log(`Successfully fetched plugin list from: ${url}`);
// console.log(`Successfully fetched plugin list from: ${url}`);
// 更新缓存
pluginListCache = data as PluginStoreList;
@@ -86,7 +102,13 @@ async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginSt
* 下载文件,使用镜像系统
* 自动识别 GitHub Release URL 并使用镜像加速
*/
async function downloadFile (url: string, destPath: string, customMirror?: string): Promise<void> {
async function downloadFile (
url: string,
destPath: string,
customMirror?: string,
onProgress?: (percent: number, downloaded: number, total: number, speed: number) => void,
timeout: number = 120000 // 默认120秒超时
): Promise<void> {
try {
let downloadUrl: string;
@@ -126,7 +148,7 @@ async function downloadFile (url: string, destPath: string, customMirror?: strin
headers: {
'User-Agent': 'NapCat-WebUI',
},
signal: AbortSignal.timeout(120000), // 实际下载120秒超时
signal: AbortSignal.timeout(timeout), // 使用传入的超时时间
});
if (!response.ok) {
@@ -137,9 +159,45 @@ async function downloadFile (url: string, destPath: string, customMirror?: strin
throw new Error('Response body is null');
}
const totalLength = Number(response.headers.get('content-length')) || 0;
// 初始进度通知
if (onProgress) {
onProgress(0, 0, totalLength, 0);
}
let downloaded = 0;
let lastTime = Date.now();
let lastDownloaded = 0;
// 进度监控流
// eslint-disable-next-line @stylistic/generator-star-spacing
const progressMonitor = async function* (source: any) {
for await (const chunk of source) {
downloaded += chunk.length;
const now = Date.now();
const elapsedSinceLast = now - lastTime;
// 每隔 500ms 或完成时计算一次速度并更新进度
if (elapsedSinceLast >= 500 || (totalLength && downloaded === totalLength)) {
const percent = totalLength ? Math.round((downloaded / totalLength) * 100) : 0;
const speed = (downloaded - lastDownloaded) / (elapsedSinceLast / 1000); // bytes/s
if (onProgress) {
onProgress(percent, downloaded, totalLength, speed);
}
lastTime = now;
lastDownloaded = downloaded;
}
yield chunk;
}
};
// 写入文件
const fileStream = createWriteStream(destPath);
await pipeline(response.body as any, fileStream);
await pipeline(progressMonitor(response.body), fileStream);
console.log(`Successfully downloaded to: ${destPath}`);
} catch (e: any) {
@@ -155,13 +213,15 @@ async function downloadFile (url: string, destPath: string, customMirror?: strin
* 解压插件到指定目录
*/
async function extractPlugin (zipPath: string, pluginId: string): Promise<void> {
// 验证 pluginId 确保安全
const safeId = validatePluginId(pluginId);
const PLUGINS_DIR = getPluginsDir();
const pluginDir = path.join(PLUGINS_DIR, pluginId);
const pluginDir = path.join(PLUGINS_DIR, safeId);
const dataDir = path.join(pluginDir, 'data');
const tempDataDir = path.join(PLUGINS_DIR, `${pluginId}.data.backup`);
const tempDataDir = path.join(PLUGINS_DIR, `${safeId}.data.backup`);
console.log(`[extractPlugin] PLUGINS_DIR: ${PLUGINS_DIR}`);
console.log(`[extractPlugin] pluginId: ${pluginId}`);
console.log(`[extractPlugin] pluginId: ${safeId}`);
console.log(`[extractPlugin] Target directory: ${pluginDir}`);
console.log(`[extractPlugin] Zip file: ${zipPath}`);
@@ -210,7 +270,7 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
} catch (e) {
// 解压失败时,尝试恢复 data 文件夹
if (hasDataBackup && fs.existsSync(tempDataDir)) {
console.log(`[extractPlugin] Extract failed, restoring data directory`);
console.log('[extractPlugin] Extract failed, restoring data directory');
if (!fs.existsSync(pluginDir)) {
fs.mkdirSync(pluginDir, { recursive: true });
}
@@ -224,7 +284,7 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
// 列出解压后的文件
const files = fs.readdirSync(pluginDir);
console.log(`[extractPlugin] Extracted files:`, files);
console.log('[extractPlugin] Extracted files:', files);
}
/**
@@ -246,7 +306,7 @@ export const GetPluginStoreListHandler: RequestHandler = async (req, res) => {
*/
export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
try {
const { id } = req.params;
const id = validatePluginId(req.params['id']);
const data = await fetchPluginList();
const plugin = data.plugins.find(p => p.id === id);
@@ -265,12 +325,14 @@ export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
*/
export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => {
try {
const { id, mirror } = req.body;
const { id: rawId, mirror } = req.body;
if (!id) {
if (!rawId) {
return sendError(res, 'Plugin ID is required');
}
const id = validatePluginId(rawId);
// 获取插件信息
const data = await fetchPluginList();
const plugin = data.plugins.find(p => p.id === id);
@@ -279,12 +341,21 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
return sendError(res, 'Plugin not found in store');
}
// 检查是否已安装相同版本
const pm = getPluginManager();
if (pm) {
const installedInfo = pm.getPluginInfo(id);
if (installedInfo && installedInfo.version === plugin.version) {
return sendError(res, '该插件已安装且版本相同,无需重复安装');
}
}
// 下载插件
const PLUGINS_DIR = getPluginsDir();
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
try {
await downloadFile(plugin.downloadUrl, tempZipPath, mirror);
await downloadFile(plugin.downloadUrl, tempZipPath, mirror, undefined, 300000);
// 解压插件
await extractPlugin(tempZipPath, id);
@@ -305,7 +376,7 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
return sendSuccess(res, {
message: 'Plugin installed successfully',
plugin: plugin,
plugin,
installPath: path.join(PLUGINS_DIR, id),
});
} catch (downloadError: any) {
@@ -324,21 +395,29 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
* 安装插件(从商店)- SSE 版本,实时推送进度
*/
export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) => {
const { id, mirror } = req.query;
const { id: rawId, mirror } = req.query;
if (!id || typeof id !== 'string') {
if (!rawId || typeof rawId !== 'string') {
res.status(400).json({ error: 'Plugin ID is required' });
return;
}
let id: string;
try {
id = validatePluginId(rawId);
} catch (err: any) {
res.status(400).json({ error: err.message });
return;
}
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const sendProgress = (message: string, progress?: number) => {
res.write(`data: ${JSON.stringify({ message, progress })}\n\n`);
const sendProgress = (message: string, progress?: number, detail?: any) => {
res.write(`data: ${JSON.stringify({ message, progress, ...detail })}\n\n`);
};
try {
@@ -355,6 +434,18 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
return;
}
// 检查是否已安装相同版本
const pm = getPluginManager();
if (pm) {
const installedInfo = pm.getPluginInfo(id);
if (installedInfo && installedInfo.version === plugin.version) {
sendProgress('错误: 该插件已安装且版本相同', 0);
res.write(`data: ${JSON.stringify({ error: '该插件已安装且版本相同,无需重复安装' })}\n\n`);
res.end();
return;
}
}
sendProgress(`找到插件: ${plugin.name} v${plugin.version}`, 20);
sendProgress(`下载地址: ${plugin.downloadUrl}`, 25);
@@ -368,12 +459,28 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
try {
sendProgress('正在下载插件...', 30);
await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined);
await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined, (percent, downloaded, total, speed) => {
const overallProgress = 30 + Math.round(percent * 0.5);
const downloadedMb = (downloaded / 1024 / 1024).toFixed(1);
const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?';
const speedMb = (speed / 1024 / 1024).toFixed(2);
const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1;
sendProgress('下载完成,正在解压...', 70);
sendProgress(`正在下载插件... ${percent}%`, overallProgress, {
downloaded,
total,
speed,
eta,
downloadedStr: `${downloadedMb}MB`,
totalStr: `${totalMb}MB`,
speedStr: `${speedMb}MB/s`,
});
}, 300000);
sendProgress('下载完成,正在解压...', 85);
await extractPlugin(tempZipPath, id);
sendProgress('解压完成,正在清理...', 90);
sendProgress('解压完成,正在清理...', 95);
fs.unlinkSync(tempZipPath);
// 如果 pluginManager 存在,立即注册或重载插件
@@ -393,7 +500,7 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
res.write(`data: ${JSON.stringify({
success: true,
message: 'Plugin installed successfully',
plugin: plugin,
plugin,
installPath: path.join(PLUGINS_DIR, id),
})}\n\n`);
res.end();

View File

@@ -1,13 +1,56 @@
import { Avatar } from '@heroui/avatar';
import { Button } from '@heroui/button';
import { Switch } from '@heroui/switch';
import { Card, CardBody, CardFooter } from '@heroui/card';
import { Chip } from '@heroui/chip';
import { useState } from 'react';
import { Switch } from '@heroui/switch';
import { Tooltip } from '@heroui/tooltip';
import { MdDeleteForever, MdSettings } from 'react-icons/md';
import clsx from 'clsx';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useState } from 'react';
import DisplayCardContainer from './container';
import key from '@/const/key';
import { PluginItem } from '@/controllers/plugin_manager';
/** 提取作者头像 URL */
function getAuthorAvatar (homepage?: string, repository?: string): string | undefined {
// 1. 尝试从 repository 提取 GitHub 用户名
if (repository) {
try {
// 处理 git+https://github.com/... 或 https://github.com/...
const repoUrl = repository.replace(/^git\+/, '').replace(/\.git$/, '');
const url = new URL(repoUrl);
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 {
// 如果是自定义域名,尝试获取 favicon
return `https://api.iowen.cn/favicon/${url.hostname}.png`;
}
} catch {
// 忽略解析错误
}
}
return undefined;
}
export interface PluginDisplayCardProps {
data: PluginItem;
onToggleStatus: () => Promise<void>;
@@ -23,9 +66,15 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
onConfig,
hasConfig = false,
}) => {
const { name, version, author, description, status } = data;
const { name, version, author, description, status, homepage, repository } = data;
const isEnabled = status === 'active';
const [processing, setProcessing] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
// 综合尝试提取头像,最后兜底使用 Vercel 风格头像
const avatarUrl = getAuthorAvatar(homepage, repository) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
const handleToggle = () => {
setProcessing(true);
@@ -38,88 +87,132 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
};
return (
<DisplayCardContainer
className='w-full max-w-[420px]'
action={
<div className='flex flex-col gap-2 w-full'>
<div className='flex gap-2 w-full'>
<Button
fullWidth
<Card
className={clsx(
'group w-full backdrop-blur-md rounded-2xl overflow-hidden transition-all duration-300',
'hover:shadow-xl hover:-translate-y-1',
'border border-white/50 dark:border-white/10 hover:border-primary/50 dark:hover:border-primary/50',
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/30'
)}
shadow='sm'
>
<CardBody className='p-4 flex flex-col gap-3'>
{/* Header */}
<div className='flex items-start justify-between gap-3'>
<div className='flex items-center gap-3 min-w-0'>
<Avatar
src={avatarUrl}
name={author || '?'}
className='flex-shrink-0'
size='md'
isBordered
radius='full'
size='sm'
variant='flat'
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-danger/20 hover:text-danger transition-colors'
startContent={<MdDeleteForever size={16} />}
onPress={handleUninstall}
isDisabled={processing}
>
</Button>
color='default'
/>
<div className='min-w-0'>
<h3 className='text-base font-bold text-default-900 truncate' title={name}>
{name}
</h3>
<p className='text-xs text-default-500 mt-0.5 truncate'>
by <span className='font-medium'>{author || '未知'}</span>
</p>
</div>
</div>
{hasConfig && (
<Button
fullWidth
radius='full'
size='sm'
variant='flat'
className='bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-secondary/20 hover:text-secondary transition-colors'
startContent={<MdSettings size={16} />}
onPress={onConfig}
>
</Button>
)}
<Chip
size='sm'
variant='flat'
color={status === 'active' ? 'success' : status === 'stopped' ? 'warning' : 'default'}
className='flex-shrink-0 font-medium h-6 px-1'
>
{status === 'active' ? '运行中' : status === 'stopped' ? '已停止' : '已禁用'}
</Chip>
</div>
}
enableSwitch={
{/* Description */}
<div
className='relative min-h-[2.5rem] cursor-pointer group/desc'
onClick={() => setIsExpanded(!isExpanded)}
>
<Tooltip
content={description}
isDisabled={!description || description.length < 50 || isExpanded}
placement='bottom'
className='max-w-[280px]'
delay={500}
>
<p className={clsx(
'text-sm text-default-600 dark:text-default-400 leading-relaxed transition-all duration-300',
isExpanded ? 'line-clamp-none' : 'line-clamp-2'
)}
>
{description || '暂无描述'}
</p>
</Tooltip>
</div>
{/* Version Badge */}
<div>
<Chip
size='sm'
variant='flat'
color='primary'
className='h-5 text-xs font-semibold px-0.5'
classNames={{ content: 'px-1' }}
>
v{version}
</Chip>
</div>
</CardBody>
<CardFooter className='px-4 pb-4 pt-0 gap-3'>
<Switch
isDisabled={processing}
isSelected={isEnabled}
onChange={handleToggle}
onValueChange={handleToggle}
size='sm'
color='success'
classNames={{
wrapper: 'group-data-[selected=true]:bg-primary-400',
wrapper: 'group-data-[selected=true]:bg-success',
}}
/>
}
title={name}
tag={
<Chip
className="ml-auto"
color={status === 'active' ? 'success' : status === 'stopped' ? 'warning' : 'default'}
size="sm"
variant="flat"
>
{status === 'active' ? '运行中' : status === 'stopped' ? '已停止' : '已禁用'}
</Chip>
}
>
<div className='grid grid-cols-2 gap-3'>
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
<span className='text-xs font-medium text-default-600'>
{isEnabled ? '已启用' : '已禁用'}
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{version}
</div>
</div>
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{author || '未知'}
</div>
</div>
<div className='col-span-2 flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2'>
{description || '暂无描述'}
</div>
</div>
</div>
</DisplayCardContainer>
</Switch>
<div className='flex-1' />
{hasConfig && (
<Tooltip content='插件配置'>
<Button
isIconOnly
radius='full'
size='sm'
variant='light'
color='primary'
onPress={onConfig}
>
<MdSettings size={20} />
</Button>
</Tooltip>
)}
<Tooltip content='卸载插件' color='danger'>
<Button
isIconOnly
radius='full'
size='sm'
variant='light'
color='danger'
onPress={handleUninstall}
isDisabled={processing}
>
<MdDeleteForever size={20} />
</Button>
</Tooltip>
</CardFooter>
</Card>
);
};

View File

@@ -1,17 +1,60 @@
/* eslint-disable @stylistic/indent */
import { Avatar } from '@heroui/avatar';
import { Button } from '@heroui/button';
import { Card, CardBody, CardFooter } from '@heroui/card';
import { Chip } from '@heroui/chip';
import { Tooltip } from '@heroui/tooltip';
import { IoMdCheckmarkCircle } from 'react-icons/io';
import { MdUpdate, MdOutlineGetApp } from 'react-icons/md';
import clsx from 'clsx';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useState } from 'react';
import { IoMdDownload, IoMdRefresh, IoMdCheckmarkCircle } from 'react-icons/io';
import DisplayCardContainer from './container';
import key from '@/const/key';
import { PluginStoreItem } from '@/types/plugin-store';
export type InstallStatus = 'not-installed' | 'installed' | 'update-available';
/** 提取作者头像 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 {
// 如果是自定义域名,尝试获取 favicon。使用主流的镜像服务以保证国内访问速度
return `https://api.iowen.cn/favicon/${url.hostname}.png`;
}
} catch {
// 忽略解析错误
}
}
return undefined;
}
export interface PluginStoreCardProps {
data: PluginStoreItem;
onInstall: () => Promise<void>;
onInstall: () => void;
installStatus?: InstallStatus;
installedVersion?: string;
}
@@ -20,158 +63,222 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
data,
onInstall,
installStatus = 'not-installed',
installedVersion,
}) => {
const { name, version, author, description, tags, id, homepage } = data;
const [processing, setProcessing] = useState(false);
const { name, version, author, description, tags, homepage, downloadUrl } = data;
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const [isExpanded, setIsExpanded] = useState(false);
const hasBackground = !!backgroundImage;
const handleInstall = () => {
setProcessing(true);
onInstall().finally(() => setProcessing(false));
};
// 综合尝试提取头像,最后兜底使用 Vercel 风格头像
const avatarUrl = getAuthorAvatar(homepage, downloadUrl) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
// 根据安装状态返回按钮配置
const getButtonConfig = () => {
switch (installStatus) {
case 'installed':
return {
text: '重新安装',
icon: <IoMdRefresh size={16} />,
color: 'default' as const,
};
case 'update-available':
return {
text: '更新',
icon: <IoMdDownload size={16} />,
color: 'default' as const,
};
default:
return {
text: '安装',
icon: <IoMdDownload size={16} />,
color: 'primary' as const,
};
}
};
const buttonConfig = getButtonConfig();
const titleContent = homepage ? (
<Tooltip
content="跳转到插件主页"
placement="top"
showArrow
offset={8}
delay={200}
>
<a
href={homepage}
target="_blank"
rel="noreferrer"
className="text-inherit inline-block bg-no-repeat bg-left-bottom [background-image:repeating-linear-gradient(90deg,currentColor_0_2px,transparent_2px_5px)] [background-size:0%_2px] hover:[background-size:100%_2px] transition-[background-size] duration-200 ease-out"
>
{name}
</a>
</Tooltip>
) : (
name
// 作者链接组件
const AuthorComponent = (
<span className={clsx('font-medium transition-colors', homepage ? 'hover:text-primary hover:underline cursor-pointer' : '')}>
{author || '未知作者'}
</span>
);
return (
<DisplayCardContainer
className='w-full max-w-[420px]'
title={titleContent}
tag={
<div className="ml-auto flex items-center gap-1">
{installStatus === 'installed' && (
<Chip
color="success"
size="sm"
variant="flat"
startContent={<IoMdCheckmarkCircle size={14} />}
<Card
className={clsx(
'group w-full backdrop-blur-md rounded-2xl overflow-hidden transition-all duration-300',
'hover:shadow-xl hover:-translate-y-1',
// 降低边框粗细
'border border-white/50 dark:border-white/10 hover:border-primary/50 dark:hover:border-primary/50',
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/30'
)}
shadow='sm'
>
<CardBody className='p-4 flex flex-col gap-3'>
{/* Header: Avatar + Name + Author */}
<div className='flex items-start gap-3'>
<Avatar
src={avatarUrl}
name={author || '?'}
size='md'
isBordered
color='default'
radius='full' // 圆形头像
className='flex-shrink-0 transition-transform group-hover:scale-105'
/>
<div className='flex-1 min-w-0'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-1.5 min-w-0'>
{homepage
? (
<Tooltip content='访问项目主页' placement='top' delay={500}>
<a
href={homepage}
target='_blank'
rel='noreferrer'
className='text-base font-bold text-default-900 hover:text-primary transition-colors truncate'
onClick={(e) => e.stopPropagation()}
>
{name}
</a>
</Tooltip>
)
: (
<h3 className='text-base font-bold text-default-900 truncate' title={name}>
{name}
</h3>
)}
</div>
</div>
{/* 可点击的作者名称 */}
<div className='text-xs text-default-500 mt-0.5 truncate'>
by {homepage
? (
<a
href={homepage}
target='_blank'
rel='noreferrer'
onClick={(e) => e.stopPropagation()}
>
{AuthorComponent}
</a>
)
: AuthorComponent}
</div>
</div>
</div>
{/* Description */}
<div
className='relative min-h-[2.5rem] cursor-pointer group/desc'
onClick={() => setIsExpanded(!isExpanded)}
>
<Tooltip
content={description}
isDisabled={!description || description.length < 50 || isExpanded}
placement='bottom'
className='max-w-[280px]'
delay={500}
>
<p className={clsx(
'text-sm text-default-600 dark:text-default-400 leading-relaxed transition-all duration-300',
isExpanded ? 'line-clamp-none' : 'line-clamp-2'
)}
>
</Chip>
)}
{installStatus === 'update-available' && (
<Chip
color="warning"
size="sm"
variant="flat"
>
</Chip>
)}
{description || '暂无描述'}
</p>
</Tooltip>
</div>
{/* Tags & Version */}
<div className='flex items-center gap-1.5 flex-wrap'>
<Chip
color="primary"
size="sm"
variant="flat"
size='sm'
variant='flat'
color='primary'
className='h-5 text-xs font-semibold px-0.5'
classNames={{ content: 'px-1' }}
>
v{version}
</Chip>
{/* Tags with proper truncation and hover */}
{tags?.slice(0, 2).map((tag) => (
<Chip
key={tag}
size='sm'
variant='flat'
className='h-5 text-xs px-0.5 bg-default-100 dark:bg-default-50/50 text-default-600'
classNames={{ content: 'px-1' }}
>
{tag}
</Chip>
))}
{tags && tags.length > 2 && (
<Tooltip
content={
<div className='flex flex-wrap gap-1 max-w-[200px] p-1'>
{tags.map(t => (
<span key={t} className='text-xs bg-white/10 px-1.5 py-0.5 rounded-md border border-white/10'>
{t}
</span>
))}
</div>
}
delay={0}
closeDelay={0}
>
<Chip
size='sm'
variant='flat'
className='h-5 text-xs px-0.5 cursor-pointer hover:bg-default-200 transition-colors'
classNames={{ content: 'px-1' }}
>
+{tags.length - 2}
</Chip>
</Tooltip>
)}
{installStatus === 'update-available' && installedVersion && (
<Chip
size='sm'
variant='shadow'
color='warning'
className='h-5 text-xs font-semibold px-0.5 ml-auto animate-pulse'
classNames={{ content: 'px-1' }}
>
</Chip>
)}
</div>
}
enableSwitch={undefined}
action={
<Button
fullWidth
radius='full'
size='sm'
color={buttonConfig.color}
startContent={buttonConfig.icon}
onPress={handleInstall}
isLoading={processing}
isDisabled={processing}
>
{buttonConfig.text}
</Button>
}
>
<div className='grid grid-cols-2 gap-3'>
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{author || '未知'}
</div>
</div>
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
v{version}
</div>
</div>
<div className='col-span-2 flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2 h-10 overflow-hidden'>
{description || '暂无描述'}
</div>
</div>
{id && (
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2 h-10 overflow-hidden'>
{id || '包名'}
</div>
</div>
)}
{tags && tags.length > 0 && (
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{tags.slice(0, 2).join(' · ')}
</div>
</div>
)}
</div>
</DisplayCardContainer>
</CardBody>
<CardFooter className='px-4 pb-4 pt-0'>
{installStatus === 'installed'
? (
<Button
fullWidth
radius='full'
size='sm'
color='success'
variant='flat'
startContent={<IoMdCheckmarkCircle size={18} />}
className='font-medium bg-success/20 text-success dark:bg-success/20 cursor-default'
isDisabled
>
</Button>
)
: installStatus === 'update-available'
? (
<Button
fullWidth
radius='full'
size='sm'
color='warning'
variant='shadow'
className='font-medium text-white shadow-warning/30 hover:shadow-warning/50 transition-shadow'
startContent={<MdUpdate size={18} />}
onPress={onInstall}
>
v{version}
</Button>
)
: (
<Button
fullWidth
radius='full'
size='sm'
color='primary'
variant='bordered'
className='font-medium bg-white dark:bg-zinc-900 border hover:bg-primary hover:text-white transition-all shadow-sm group/btn'
startContent={<MdOutlineGetApp size={20} className='transition-transform group-hover/btn:translate-y-0.5' />}
onPress={onInstall}
>
</Button>
)}
</CardFooter>
</Card>
);
};

View File

@@ -22,6 +22,10 @@ export interface PluginItem {
hasConfig?: boolean;
/** 是否有扩展页面 */
hasPages?: boolean;
/** 主页链接 */
homepage?: string;
/** 仓库链接 */
repository?: string;
}
/** 扩展页面信息 */

View File

@@ -18,6 +18,7 @@ interface ExtensionPage {
description?: string;
}
// eslint-disable-next-line @typescript-eslint/no-redeclare
export default function ExtensionPage () {
const [loading, setLoading] = useState(true);
const [extensionPages, setExtensionPages] = useState<ExtensionPage[]>([]);
@@ -150,28 +151,30 @@ export default function ExtensionPage () {
)}
</div>
{extensionPages.length === 0 && !loading ? (
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
<MdExtension size={64} className='mb-4 opacity-50' />
<p className='text-lg'></p>
<p className='text-sm mt-2'> WebUI </p>
</div>
) : (
<div className='flex-1 min-h-0 bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden relative'>
{iframeLoading && (
<div className='absolute inset-0 flex items-center justify-center bg-default-100/50 z-10'>
<Spinner size='lg' />
</div>
)}
<iframe
src={currentPageUrl}
className='w-full h-full border-0'
onLoad={handleIframeLoad}
title='extension-page'
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
/>
</div>
)}
{extensionPages.length === 0 && !loading
? (
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
<MdExtension size={64} className='mb-4 opacity-50' />
<p className='text-lg'></p>
<p className='text-sm mt-2'> WebUI </p>
</div>
)
: (
<div className='flex-1 min-h-0 bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden relative'>
{iframeLoading && (
<div className='absolute inset-0 flex items-center justify-center bg-default-100/50 z-10'>
<Spinner size='lg' />
</div>
)}
<iframe
src={currentPageUrl}
className='w-full h-full border-0'
onLoad={handleIframeLoad}
title='extension-page'
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
/>
</div>
)}
</div>
</>
);

View File

@@ -1,10 +1,9 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { Tab, Tabs } from '@heroui/tabs';
import { Card, CardBody } from '@heroui/card';
import { Tooltip } from '@heroui/tooltip';
import { Spinner } from '@heroui/spinner';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState, useRef } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
import clsx from 'clsx';
@@ -42,6 +41,34 @@ export default function PluginStorePage () {
const [activeTab, setActiveTab] = useState<string>('all');
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
const dialog = useDialog();
const searchInputRef = useRef<HTMLInputElement>(null);
// 快捷键支持: Ctrl+F 聚焦搜索框
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
searchInputRef.current?.focus();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// 进度条相关状态
const [installProgress, setInstallProgress] = useState<{
show: boolean;
message: string;
progress: number;
speedStr?: string;
eta?: number;
downloadedStr?: string;
totalStr?: string;
}>({
show: false,
message: '',
progress: 0,
});
// 商店列表源相关状态
const [storeSourceModalOpen, setStoreSourceModalOpen] = useState(false);
@@ -103,7 +130,8 @@ export default function PluginStorePage () {
// 获取插件的安装状态和已安装版本
const getPluginInstallInfo = (plugin: PluginStoreItem): { status: InstallStatus; installedVersion?: string; } => {
// 通过 id (包名) 或 name 匹配已安装的插件
const installed = installedPlugins.find(p => p.id === plugin.id);
// 优先匹配 ID如果 ID 匹配失败尝试匹配名称(兼容某些 ID 不一致的情况)
const installed = installedPlugins.find(p => p.id === plugin.id || p.name === plugin.name);
if (!installed) {
return { status: 'not-installed' };
@@ -167,9 +195,11 @@ export default function PluginStorePage () {
if (data.error) {
toast.error(`安装失败: ${data.error}`, { id: loadingToast });
setInstallProgress(prev => ({ ...prev, show: false }));
eventSource.close();
} else if (data.success) {
toast.success('插件安装成功!', { id: loadingToast });
setInstallProgress(prev => ({ ...prev, show: false }));
eventSource.close();
// 刷新插件列表
loadPlugins();
@@ -178,30 +208,46 @@ export default function PluginStorePage () {
dialog.confirm({
title: '插件管理器未加载',
content: (
<div className="space-y-2">
<p className="text-sm text-default-600">
<div className='space-y-2'>
<p className='text-sm text-default-600'>
</p>
<p className="text-sm text-default-600">
<p className='text-sm text-default-600'>
</p>
</div>
),
confirmText: '注册插件管理器',
cancelText: '稍后再说',
onConfirm: async () => {
try {
await PluginManager.registerPluginManager();
toast.success('插件管理器注册成功');
setPluginManagerNotFound(false);
} catch (e: any) {
toast.error('注册失败: ' + e.message);
}
onConfirm: () => {
(async () => {
try {
await PluginManager.registerPluginManager();
toast.success('插件管理器注册成功');
setPluginManagerNotFound(false);
} catch (e: any) {
toast.error('注册失败: ' + e.message);
}
})();
},
});
}
} else if (data.message) {
toast.loading(data.message, { id: loadingToast });
if (typeof data.progress === 'number' && data.progress >= 0 && data.progress <= 100) {
setInstallProgress((prev) => ({
...prev,
show: true,
message: data.message,
progress: data.progress,
// 保存下载详情,避免被后续非下载步骤的消息清空
speedStr: data.speedStr || (data.message.includes('下载') ? prev.speedStr : undefined),
eta: data.eta !== undefined ? data.eta : (data.message.includes('下载') ? prev.eta : undefined),
downloadedStr: data.downloadedStr || (data.message.includes('下载') ? prev.downloadedStr : undefined),
totalStr: data.totalStr || (data.message.includes('下载') ? prev.totalStr : undefined),
}));
} else {
toast.loading(data.message, { id: loadingToast });
}
}
} catch (e) {
console.error('Failed to parse SSE message:', e);
@@ -211,6 +257,7 @@ export default function PluginStorePage () {
eventSource.onerror = (error) => {
console.error('SSE连接出错:', error);
toast.error('连接中断,安装失败', { id: loadingToast });
setInstallProgress(prev => ({ ...prev, show: false }));
eventSource.close();
};
} catch (error: any) {
@@ -233,67 +280,72 @@ export default function PluginStorePage () {
return (
<>
<title> - NapCat WebUI</title>
<div className="p-2 md:p-4 relative">
<div className='p-2 md:p-4 relative'>
{/* 固定头部区域 */}
<div className={clsx(
'sticky top-14 z-10 backdrop-blur-sm py-4 px-4 rounded-sm mb-4 -mx-2 md:-mx-4 -mt-2 md:-mt-4 transition-colors',
'sticky top-14 z-10 backdrop-blur-md py-4 px-4 rounded-sm mb-4 -mx-2 md:-mx-4 -mt-2 md:-mt-4 transition-colors',
hasBackground
? 'bg-white/20 dark:bg-black/10'
: 'bg-transparent'
)}>
{/* 头部 */}
<div className="flex mb-4 items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold"></h1>
<Button
isIconOnly
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
radius="full"
onPress={() => loadPlugins(true)}
isLoading={loading}
>
<IoMdRefresh size={24} />
</Button>
)}
>
{/* 头部布局:标题 + 搜索 + 工具栏 */}
<div className='flex flex-col md:flex-row mb-4 items-start md:items-center justify-between gap-4'>
<div className='flex items-center gap-3 flex-shrink-0'>
<h1 className='text-2xl font-bold'></h1>
<Tooltip content='刷新列表'>
<Button
isIconOnly
size='sm'
variant='flat'
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
radius='full'
onPress={() => loadPlugins(true)}
isLoading={loading}
>
<IoMdRefresh size={20} />
</Button>
</Tooltip>
</div>
{/* 商店列表源卡片 */}
<Card className="bg-default-100/50 backdrop-blur-md shadow-sm">
<CardBody className="py-2 px-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-default-500">:</span>
<span className="text-sm font-medium">{getStoreSourceDisplayName()}</span>
</div>
<Tooltip content="切换列表源">
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => setStoreSourceModalOpen(true)}
>
<IoMdSettings size={16} />
</Button>
</Tooltip>
</div>
</CardBody>
</Card>
</div>
{/* 顶栏搜索框与列表源 */}
<div className='flex items-center gap-3 w-full md:w-auto flex-1 justify-end'>
<Input
ref={searchInputRef}
placeholder='搜索(Ctrl+F)...'
startContent={<IoMdSearch className='text-default-400' />}
value={searchQuery}
onValueChange={setSearchQuery}
className='max-w-xs w-full'
size='sm'
isClearable
classNames={{
inputWrapper: 'bg-default-100/50 dark:bg-black/20 backdrop-blur-md border-white/20 dark:border-white/10',
}}
/>
{/* 搜索框 */}
<div className="mb-4">
<Input
placeholder="搜索插件名称、描述、作者或标签..."
startContent={<IoMdSearch className="text-default-400" />}
value={searchQuery}
onValueChange={setSearchQuery}
className="max-w-md"
/>
{/* 商店列表源简易卡片 */}
<div className='hidden sm:flex items-center gap-2 bg-default-100/50 backdrop-blur-md px-3 py-1.5 rounded-full border border-white/20 dark:border-white/10'>
<span className='text-xs text-default-500 whitespace-nowrap'>: {getStoreSourceDisplayName()}</span>
<Tooltip content='切换列表源'>
<Button
isIconOnly
size='sm'
variant='light'
className='min-w-unit-6 w-6 h-6'
onPress={() => setStoreSourceModalOpen(true)}
>
<IoMdSettings size={14} />
</Button>
</Tooltip>
</div>
</div>
</div>
{/* 标签页导航 */}
<Tabs
aria-label="Plugin Store Categories"
className="max-w-full"
aria-label='Plugin Store Categories'
className='max-w-full'
selectedKey={activeTab}
onSelectionChange={(key) => setActiveTab(String(key))}
classNames={{
@@ -312,16 +364,16 @@ export default function PluginStorePage () {
</div>
{/* 插件列表区域 */}
<div className="relative">
<div className='relative'>
{/* 加载遮罩 - 只遮住插件列表区域 */}
{loading && (
<div className="absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg">
<div className='absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg'>
<Spinner size='lg' />
</div>
)}
<EmptySection isEmpty={!categorizedPlugins[activeTab]?.length} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-4'>
{categorizedPlugins[activeTab]?.map((plugin) => {
const installInfo = getPluginInstallInfo(plugin);
return (
@@ -330,7 +382,7 @@ export default function PluginStorePage () {
data={plugin}
installStatus={installInfo.status}
installedVersion={installInfo.installedVersion}
onInstall={() => handleInstall(plugin)}
onInstall={() => { handleInstall(plugin); }}
/>
);
})}
@@ -346,7 +398,7 @@ export default function PluginStorePage () {
setCurrentStoreSource(mirror);
}}
currentMirror={currentStoreSource}
type="raw"
type='raw'
/>
{/* 下载镜像选择弹窗 */}
@@ -366,8 +418,65 @@ export default function PluginStorePage () {
}
}}
currentMirror={selectedDownloadMirror}
type="file"
type='file'
/>
{/* 插件下载进度条全局居中样式 */}
{installProgress.show && (
<div className='fixed inset-0 flex items-center justify-center z-[9999] animate-in fade-in duration-300'>
{/* 毛玻璃背景层 */}
<div className='absolute inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-md' />
<div
className={clsx(
'relative w-[90%] max-w-md bg-white/80 dark:bg-black/70 backdrop-blur-2xl shadow-2xl rounded-2xl border border-white/20 dark:border-white/10 p-8',
'ring-1 ring-black/5 dark:ring-white/5 flex flex-col gap-6'
)}
>
<div className='flex flex-col gap-1'>
<h3 className='text-lg font-bold text-default-900'></h3>
<p className='text-sm text-default-500 font-medium'>{installProgress.message}</p>
</div>
<div className='flex flex-col gap-4'>
{/* 速度 & 百分比 */}
<div className='flex items-center justify-between'>
<div className='flex flex-col gap-0.5'>
{installProgress.speedStr && (
<p className='text-xs text-primary font-bold'>
{installProgress.speedStr}
</p>
)}
{installProgress.eta !== undefined && installProgress.eta !== null && (
<p className='text-xs text-default-400'>
: {
installProgress.eta > 0
? (installProgress.eta < 60 ? `${installProgress.eta}s` : `${Math.floor(installProgress.eta / 60)}m ${installProgress.eta % 60}s`)
: '计算中...'
}
</p>
)}
</div>
<span className='text-2xl font-black text-primary font-mono'>{Math.round(installProgress.progress)}%</span>
</div>
{/* 进度条 */}
<div className='w-full bg-default-200/50 dark:bg-default-100/20 rounded-full h-4 overflow-hidden border border-default-300/20 dark:border-white/5'>
<div
className='bg-primary h-full rounded-full transition-all duration-500 ease-out shadow-[0_0_15px_rgba(var(--heroui-primary),0.6)]'
style={{ width: `${installProgress.progress}%` }}
/>
</div>
{/* 详细数据 (大小) - 始终显示 */}
<div className='flex items-center justify-between text-xs text-default-400 font-bold tracking-tight'>
<span> {installProgress.downloadedStr || '0.0MB'}</span>
<span> {installProgress.totalStr || '获取中...'}</span>
</div>
</div>
</div>
</div>
)}
</>
);
}