mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 08:10:25 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beef1233fa | ||
|
|
8f69e2424a | ||
|
|
f3156b1843 | ||
|
|
61a16b44a4 | ||
|
|
7b17ef44dd | ||
|
|
2c166299a3 | ||
|
|
75d2611eda | ||
|
|
e2d2e65620 | ||
|
|
f479fccf3e | ||
|
|
45652612b4 | ||
|
|
54266f97f8 | ||
|
|
687a5f5708 | ||
|
|
3dab2b4361 | ||
|
|
74be268dbe | ||
|
|
8b3f60628e | ||
|
|
f0eb6379c1 | ||
|
|
f3e24811ad | ||
|
|
ecbac2b782 |
69
.github/workflows/release.yml
vendored
69
.github/workflows/release.yml
vendored
@@ -10,7 +10,7 @@ permissions: write-all
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
|
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
|
||||||
OPENROUTER_MODEL: "copilot/gemini-3-flash-preview"
|
OPENROUTER_MODEL: "gemini-3-flash-preview"
|
||||||
RELEASE_NAME: "NapCat"
|
RELEASE_NAME: "NapCat"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -118,61 +118,23 @@ jobs:
|
|||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install -y aria2 unzip zip p7zip-full curl jq
|
sudo apt install -y aria2 unzip zip p7zip-full curl jq
|
||||||
|
|
||||||
- name: Download QQ x64, Node.js and Assemble NapCat.Shell.Windows.Node.zip
|
- name: Download and Assemble NapCat.Shell.Windows.Node.zip
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TMPDIR=$(mktemp -d)
|
TMPDIR=$(mktemp -d)
|
||||||
cd "$TMPDIR"
|
cd "$TMPDIR"
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 1) 下载 QQ x64 (使用缓存)
|
# 1) 下载 QQ x64
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# JS_URL="https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
|
||||||
# JS_URL="https://slave.docadan488.workers.dev/proxy?url=https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
|
||||||
# NT_URL=$(curl -fsSL "$JS_URL" | grep -oP '"ntDownloadX64Url"\s*:\s*"\K[^"]+')
|
|
||||||
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/32876254/QQ9.9.27.45627_x64.exe"
|
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/32876254/QQ9.9.27.45627_x64.exe"
|
||||||
QQ_ZIP="$(basename "$NT_URL")"
|
QQ_ZIP="$(basename "$NT_URL")"
|
||||||
# 根据 URL 生成缓存键
|
echo "Downloading QQ installer: $QQ_ZIP"
|
||||||
QQ_CACHE_KEY="qq-x64-$(echo "$NT_URL" | md5sum | cut -d' ' -f1)"
|
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
|
||||||
echo "QQ_CACHE_KEY=$QQ_CACHE_KEY" >> $GITHUB_ENV
|
|
||||||
echo "QQ_ZIP=$QQ_ZIP" >> $GITHUB_ENV
|
|
||||||
echo "NT_URL=$NT_URL" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Cache QQ x64 Installer
|
|
||||||
id: cache-qq
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/qq-cache
|
|
||||||
key: ${{ env.QQ_CACHE_KEY }}
|
|
||||||
|
|
||||||
- name: Download and Extract QQ x64
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
TMPDIR=$(mktemp -d)
|
|
||||||
cd "$TMPDIR"
|
|
||||||
|
|
||||||
QQ_CACHE_DIR="$HOME/qq-cache"
|
|
||||||
mkdir -p "$QQ_CACHE_DIR"
|
|
||||||
|
|
||||||
if [ -f "$QQ_CACHE_DIR/$QQ_ZIP" ]; then
|
|
||||||
echo "Using cached QQ installer: $QQ_ZIP"
|
|
||||||
cp "$QQ_CACHE_DIR/$QQ_ZIP" "$QQ_ZIP"
|
|
||||||
else
|
|
||||||
echo "Downloading QQ installer: $QQ_ZIP"
|
|
||||||
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
|
|
||||||
cp "$QQ_ZIP" "$QQ_CACHE_DIR/$QQ_ZIP"
|
|
||||||
fi
|
|
||||||
|
|
||||||
QQ_EXTRACT="$TMPDIR/qq_extracted"
|
QQ_EXTRACT="$TMPDIR/qq_extracted"
|
||||||
mkdir -p "$QQ_EXTRACT"
|
mkdir -p "$QQ_EXTRACT"
|
||||||
7z x -y -o"$QQ_EXTRACT" "$QQ_ZIP" >/dev/null
|
7z x -y -o"$QQ_EXTRACT" "$QQ_ZIP" >/dev/null
|
||||||
echo "QQ_EXTRACT=$QQ_EXTRACT" >> $GITHUB_ENV
|
|
||||||
echo "WORK_TMPDIR=$TMPDIR" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Download Node.js and Assemble NapCat.Shell.Windows.Node.zip
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$WORK_TMPDIR"
|
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# 2) 下载 Node.js Windows x64 zip 22.11.0
|
# 2) 下载 Node.js Windows x64 zip 22.11.0
|
||||||
@@ -182,7 +144,7 @@ jobs:
|
|||||||
NODE_ZIP="node-v$NODE_VER-win-x64.zip"
|
NODE_ZIP="node-v$NODE_VER-win-x64.zip"
|
||||||
aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL"
|
aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL"
|
||||||
|
|
||||||
NODE_EXTRACT="$WORK_TMPDIR/node_extracted"
|
NODE_EXTRACT="$TMPDIR/node_extracted"
|
||||||
mkdir -p "$NODE_EXTRACT"
|
mkdir -p "$NODE_EXTRACT"
|
||||||
unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT"
|
unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT"
|
||||||
|
|
||||||
@@ -496,24 +458,23 @@ jobs:
|
|||||||
pnpm i
|
pnpm i
|
||||||
pnpm run build:openapi
|
pnpm run build:openapi
|
||||||
|
|
||||||
# 读取并压缩 openapi.json 内容
|
# 使用 jq 安全地构建大型 JSON 数据并保存到文件
|
||||||
OPENAPI_CONTENT=$(cat packages/napcat-schema/openapi.json | tr -d '\n\r\t' | sed 's/ */ /g' | sed 's/"/\\"/g')
|
jq -n --rawfile input packages/napcat-schema/dist/openapi.json \
|
||||||
|
'{
|
||||||
# 构建 JSON 数据
|
input: $input,
|
||||||
JSON_DATA=$(printf '{
|
options: {
|
||||||
"input": "%s",
|
|
||||||
"options": {
|
|
||||||
"endpointOverwriteBehavior": "OVERWRITE_EXISTING",
|
"endpointOverwriteBehavior": "OVERWRITE_EXISTING",
|
||||||
"schemaOverwriteBehavior": "OVERWRITE_EXISTING",
|
"schemaOverwriteBehavior": "OVERWRITE_EXISTING",
|
||||||
"updateFolderOfChangedEndpoint": true,
|
"updateFolderOfChangedEndpoint": true,
|
||||||
"moduleId": 1140714,
|
"moduleId": 1140714,
|
||||||
"deleteUnmatchedResources": true
|
"deleteUnmatchedResources": true
|
||||||
}
|
}
|
||||||
}' "$OPENAPI_CONTENT")
|
}' > apifox_payload.json
|
||||||
|
|
||||||
|
# 通过文件形式发送数据,避免命令行长度限制
|
||||||
curl --location -g --request POST 'https://api.apifox.com/v1/projects/5348325/import-openapi?locale=zh-CN' \
|
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 'X-Apifox-Api-Version: 2024-03-28' \
|
||||||
--header 'Authorization: Bearer $APIFOX_TOKEN' \
|
--header "Authorization: Bearer $APIFOX_TOKEN" \
|
||||||
--header 'Content-Type: application/json' \
|
--header 'Content-Type: application/json' \
|
||||||
--data-raw "$JSON_DATA"
|
--data-binary @apifox_payload.json
|
||||||
|
|
||||||
|
|||||||
4
packages/napcat-core/external/appid.json
vendored
4
packages/napcat-core/external/appid.json
vendored
@@ -526,5 +526,9 @@
|
|||||||
"6.9.88-44725": {
|
"6.9.88-44725": {
|
||||||
"appid": 537337594,
|
"appid": 537337594,
|
||||||
"qua": "V1_MAC_NQ_6.9.88_44725_GW_B"
|
"qua": "V1_MAC_NQ_6.9.88_44725_GW_B"
|
||||||
|
},
|
||||||
|
"3.2.25-45758": {
|
||||||
|
"appid": 537340249,
|
||||||
|
"qua": "V1_LNX_NQ_3.2.25_45758_GW_B"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -17,7 +17,7 @@ export const OB11UserSchema = Type.Object({
|
|||||||
login_days: Type.Optional(Type.Number({ description: '登录天数' })),
|
login_days: Type.Optional(Type.Number({ description: '登录天数' })),
|
||||||
categoryName: Type.Optional(Type.String({ description: '分组名称' })),
|
categoryName: Type.Optional(Type.String({ description: '分组名称' })),
|
||||||
categoryId: Type.Optional(Type.Number({ description: '分组ID' })),
|
categoryId: Type.Optional(Type.Number({ description: '分组ID' })),
|
||||||
}, { description: 'OneBot 11 用户信息' });
|
}, { $id: 'OB11User', description: 'OneBot 11 用户信息' });
|
||||||
|
|
||||||
export const OB11GroupSchema = Type.Object({
|
export const OB11GroupSchema = Type.Object({
|
||||||
group_all_shut: Type.Number({ description: '是否全员禁言' }),
|
group_all_shut: Type.Number({ description: '是否全员禁言' }),
|
||||||
@@ -26,7 +26,7 @@ export const OB11GroupSchema = Type.Object({
|
|||||||
group_name: Type.String({ description: '群名称' }),
|
group_name: Type.String({ description: '群名称' }),
|
||||||
member_count: Type.Optional(Type.Number({ description: '成员人数' })),
|
member_count: Type.Optional(Type.Number({ description: '成员人数' })),
|
||||||
max_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({
|
export const OB11GroupMemberSchema = Type.Object({
|
||||||
group_id: Type.Number({ description: '群号' }),
|
group_id: Type.Number({ description: '群号' }),
|
||||||
@@ -48,7 +48,7 @@ export const OB11GroupMemberSchema = Type.Object({
|
|||||||
shut_up_timestamp: Type.Optional(Type.Number({ description: '禁言截止时间戳' })),
|
shut_up_timestamp: Type.Optional(Type.Number({ description: '禁言截止时间戳' })),
|
||||||
is_robot: Type.Optional(Type.Boolean({ description: '是否为机器人' })),
|
is_robot: Type.Optional(Type.Boolean({ description: '是否为机器人' })),
|
||||||
qage: Type.Optional(Type.Number({ description: 'Q龄' })),
|
qage: Type.Optional(Type.Number({ description: 'Q龄' })),
|
||||||
}, { description: 'OneBot 11 群成员信息' });
|
}, { $id: 'OB11GroupMember', description: 'OneBot 11 群成员信息' });
|
||||||
|
|
||||||
export const OB11NotifySchema = Type.Object({
|
export const OB11NotifySchema = Type.Object({
|
||||||
request_id: Type.Number({ description: '请求ID' }),
|
request_id: Type.Number({ description: '请求ID' }),
|
||||||
@@ -60,7 +60,7 @@ export const OB11NotifySchema = Type.Object({
|
|||||||
checked: Type.Boolean({ description: '是否已处理' }),
|
checked: Type.Boolean({ description: '是否已处理' }),
|
||||||
actor: Type.Number({ description: '操作者QQ' }),
|
actor: Type.Number({ description: '操作者QQ' }),
|
||||||
requester_nick: Type.String({ description: '申请者昵称' }),
|
requester_nick: Type.String({ description: '申请者昵称' }),
|
||||||
}, { description: 'OneBot 11 通知信息' });
|
}, { $id: 'OB11Notify', description: 'OneBot 11 通知信息' });
|
||||||
|
|
||||||
export const lastestMessageSchema = Type.Object({
|
export const lastestMessageSchema = Type.Object({
|
||||||
self_id: Type.Number({ description: '发送者QQ号' }),
|
self_id: Type.Number({ description: '发送者QQ号' }),
|
||||||
@@ -82,7 +82,7 @@ export const lastestMessageSchema = Type.Object({
|
|||||||
post_type: Type.String({ description: '发布类型' }),
|
post_type: Type.String({ description: '发布类型' }),
|
||||||
group_id: Type.Number({ description: '群号' }),
|
group_id: Type.Number({ description: '群号' }),
|
||||||
group_name: Type.String({ description: '群名称' }),
|
group_name: Type.String({ description: '群名称' }),
|
||||||
}, { description: '最后一条消息' });
|
}, { $id: 'OB11LatestMessage', description: '最后一条消息' });
|
||||||
|
|
||||||
export const OB11MessageSchema = Type.Intersect([
|
export const OB11MessageSchema = Type.Intersect([
|
||||||
lastestMessageSchema,
|
lastestMessageSchema,
|
||||||
@@ -95,4 +95,4 @@ export const OB11MessageSchema = Type.Intersect([
|
|||||||
likes_cnt: Type.String({ description: '点赞数' }),
|
likes_cnt: Type.String({ description: '点赞数' }),
|
||||||
})),
|
})),
|
||||||
}, { description: 'OneBot 11 消息信息' })
|
}, { description: 'OneBot 11 消息信息' })
|
||||||
]);
|
], { $id: 'OB11ActionMessage', description: 'OneBot 11 消息信息' });
|
||||||
@@ -13,6 +13,8 @@ export interface PluginPackageJson {
|
|||||||
main?: string;
|
main?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
|
homepage?: string;
|
||||||
|
repository?: string | { type: string; url: string; };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 插件配置 Schema ====================
|
// ==================== 插件配置 Schema ====================
|
||||||
|
|||||||
@@ -325,8 +325,11 @@ export const OB11MessageDataSchema = Type.Union([
|
|||||||
OB11MessageDiceSchema,
|
OB11MessageDiceSchema,
|
||||||
OB11MessageRPSSchema,
|
OB11MessageRPSSchema,
|
||||||
OB11MessageContactSchema,
|
OB11MessageContactSchema,
|
||||||
|
OB11MessageLocationSchema,
|
||||||
OB11MessageJsonSchema,
|
OB11MessageJsonSchema,
|
||||||
|
OB11MessageXmlSchema,
|
||||||
OB11MessageMarkdownSchema,
|
OB11MessageMarkdownSchema,
|
||||||
|
OB11MessageMiniAppSchema,
|
||||||
OB11MessageNodeSchema,
|
OB11MessageNodeSchema,
|
||||||
OB11MessageForwardSchema,
|
OB11MessageForwardSchema,
|
||||||
OB11MessageOnlineFileSchema,
|
OB11MessageOnlineFileSchema,
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import { getAllHandlers } from '@/napcat-onebot/action/index';
|
|||||||
import { AutoRegisterRouter } from '@/napcat-onebot/action/auto-register';
|
import { AutoRegisterRouter } from '@/napcat-onebot/action/auto-register';
|
||||||
import { writeFileSync, existsSync } from 'node:fs';
|
import { writeFileSync, existsSync } from 'node:fs';
|
||||||
import { resolve, dirname } from 'node:path';
|
import { resolve, dirname } from 'node:path';
|
||||||
import { TSchema } from '@sinclair/typebox';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
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 { 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 {
|
interface ActionSchemaInfo {
|
||||||
payload?: TSchema;
|
payload?: TSchema;
|
||||||
@@ -21,52 +24,524 @@ interface ActionSchemaInfo {
|
|||||||
errorExamples?: Array<{ code: number, description: string; }>;
|
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> = {};
|
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 () {
|
export function initSchemas () {
|
||||||
const handlers = getAllHandlers(null as any, null as any);
|
const handlers = getAllHandlers(null as any, null as any);
|
||||||
|
|
||||||
handlers.forEach(handler => {
|
handlers.forEach(handler => {
|
||||||
if (handler.actionName && (handler.actionName as string) !== 'unknown') {
|
if (!handler.actionName || (handler.actionName as string) === 'unknown') {
|
||||||
const action = handler as OneBotAction<unknown, unknown>;
|
return;
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
const handler = new ActionClass(null as any, null as any);
|
||||||
if (handler.actionName && (handler.actionName as string) !== 'unknown') {
|
if (!handler.actionName || (handler.actionName as string) === 'unknown') {
|
||||||
const action = handler as OneBotAction<unknown, unknown>;
|
return;
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
/* OpenAPI 构建主流程 */
|
||||||
initSchemas();
|
/* -------------------------------------------------------------------------- */
|
||||||
} catch (e) {
|
|
||||||
console.warn('Init schemas partial failure, proceeding with collected data...');
|
|
||||||
}
|
|
||||||
|
|
||||||
const openapi: Record<string, unknown> = {
|
function createOpenAPIDocument (): Record<string, unknown> {
|
||||||
openapi: '3.0.1',
|
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: {
|
info: {
|
||||||
title: 'NapCat OneBot 11 HTTP API',
|
title: 'NapCat OneBot 11 HTTP API',
|
||||||
description: 'NapCatOneBot11 HTTP POST 接口文档',
|
description: 'NapCatOneBot11 HTTP POST 接口文档',
|
||||||
@@ -81,39 +556,43 @@ export function generateOpenAPI () {
|
|||||||
],
|
],
|
||||||
paths: {} as Record<string, unknown>,
|
paths: {} as Record<string, unknown>,
|
||||||
components: {
|
components: {
|
||||||
schemas: {},
|
schemas: {
|
||||||
|
BaseResponse: BaseResponseSchema,
|
||||||
|
EmptyData: EmptyDataSchema
|
||||||
|
},
|
||||||
|
examples: componentExamples,
|
||||||
responses: {},
|
responses: {},
|
||||||
securitySchemes: {}
|
securitySchemes: {}
|
||||||
},
|
},
|
||||||
servers: [],
|
servers: [],
|
||||||
security: []
|
security: []
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
for (const [actionName, schemas] of Object.entries(actionSchemas)) {
|
function buildResponseExamples (schemas: ActionSchemaInfo): Record<string, unknown> {
|
||||||
if (!schemas.payload && !schemas.summary) continue;
|
const successData = schemas.returnExample ?? {};
|
||||||
|
const examples: Record<string, any> = {
|
||||||
const path = '/' + actionName;
|
Success: isMeaninglessSuccessExampleData(successData)
|
||||||
const cleanPayload = schemas.payload ? JSON.parse(JSON.stringify(schemas.payload)) : { type: 'object', properties: {} };
|
? { $ref: `#/components/examples/${SUCCESS_DEFAULT_EXAMPLE_KEY}` }
|
||||||
const cleanReturn = schemas.return ? JSON.parse(JSON.stringify(schemas.return)) : { type: 'object', properties: {} };
|
: {
|
||||||
|
|
||||||
// 构造响应示例
|
|
||||||
const responseExamples: Record<string, any> = {
|
|
||||||
'Success': {
|
|
||||||
summary: '成功响应',
|
summary: '成功响应',
|
||||||
value: {
|
value: {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
retcode: 0,
|
retcode: 0,
|
||||||
data: schemas.returnExample || {},
|
data: successData,
|
||||||
message: '',
|
message: '',
|
||||||
wording: '',
|
wording: '',
|
||||||
stream: 'normal-action'
|
stream: 'normal-action'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (schemas.errorExamples) {
|
if (schemas.errorExamples) {
|
||||||
schemas.errorExamples.forEach(error => {
|
schemas.errorExamples.forEach(error => {
|
||||||
responseExamples['Error_' + error.code] = {
|
const commonErrorKey = resolveCommonErrorExampleKey(error);
|
||||||
|
examples[`Error_${error.code}`] = commonErrorKey
|
||||||
|
? { $ref: `#/components/examples/${commonErrorKey}` }
|
||||||
|
: {
|
||||||
summary: error.description,
|
summary: error.description,
|
||||||
value: {
|
value: {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
@@ -124,23 +603,33 @@ export function generateOpenAPI () {
|
|||||||
stream: 'normal-action'
|
stream: 'normal-action'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} else {
|
return examples;
|
||||||
// 默认提供一个通用错误
|
}
|
||||||
responseExamples['Generic_Error'] = {
|
|
||||||
summary: '通用错误',
|
examples['Generic_Error'] = {
|
||||||
value: {
|
$ref: '#/components/examples/Error_1400'
|
||||||
status: 'failed',
|
};
|
||||||
retcode: 1400,
|
|
||||||
data: null,
|
return examples;
|
||||||
message: '请求参数错误或业务逻辑执行失败',
|
}
|
||||||
wording: '请求参数错误或业务逻辑执行失败',
|
|
||||||
stream: 'normal-action'
|
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] = {
|
paths[path] = {
|
||||||
post: {
|
post: {
|
||||||
summary: schemas.summary || actionName,
|
summary: schemas.summary || actionName,
|
||||||
@@ -154,7 +643,7 @@ export function generateOpenAPI () {
|
|||||||
'application/json': {
|
'application/json': {
|
||||||
schema: cleanPayload,
|
schema: cleanPayload,
|
||||||
examples: {
|
examples: {
|
||||||
'Default': {
|
Default: {
|
||||||
summary: '默认请求示例',
|
summary: '默认请求示例',
|
||||||
value: schemas.payloadExample || {}
|
value: schemas.payloadExample || {}
|
||||||
}
|
}
|
||||||
@@ -168,18 +657,21 @@ export function generateOpenAPI () {
|
|||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
allOf: [
|
||||||
properties: {
|
{ $ref: '#/components/schemas/BaseResponse' },
|
||||||
status: { type: 'string', description: '状态 (ok/failed)' },
|
{
|
||||||
retcode: { type: 'number', description: '返回码' },
|
type: 'object',
|
||||||
data: { ...cleanReturn, description: '数据' },
|
required: ['data'],
|
||||||
message: { type: 'string', description: '消息' },
|
properties: {
|
||||||
wording: { type: 'string', description: '提示' },
|
data: {
|
||||||
stream: { type: 'string', description: '流式响应', enum: ['stream-action', 'normal-action'] }
|
...(typeof cleanReturn === 'object' && cleanReturn ? cleanReturn : {}),
|
||||||
},
|
description: '业务数据'
|
||||||
required: ['status', 'retcode', 'data']
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
examples: responseExamples
|
examples: buildResponseExamples(schemas)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,16 +680,46 @@ export function generateOpenAPI () {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const outputPath = resolve(__dirname, 'openapi.json');
|
export function generateOpenAPI () {
|
||||||
writeFileSync(outputPath, JSON.stringify(openapi, null, 2));
|
logSection('开始生成 OpenAPI 文档');
|
||||||
console.log('OpenAPI schema (3.0.1 Format) generated at: ' + outputPath);
|
|
||||||
|
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();
|
generateMissingReport();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 元数据缺失报告 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
function generateMissingReport () {
|
function generateMissingReport () {
|
||||||
const missingReport: string[] = [];
|
const missingReport: string[] = [];
|
||||||
|
|
||||||
for (const [actionName, schemas] of Object.entries(actionSchemas)) {
|
for (const [actionName, schemas] of Object.entries(actionSchemas)) {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
if (!schemas.summary) missing.push('actionSummary');
|
if (!schemas.summary) missing.push('actionSummary');
|
||||||
@@ -206,18 +728,20 @@ function generateMissingReport () {
|
|||||||
if (schemas.returnExample === undefined) missing.push('returnExample');
|
if (schemas.returnExample === undefined) missing.push('returnExample');
|
||||||
|
|
||||||
if (missing.length > 0) {
|
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) {
|
if (missingReport.length > 0) {
|
||||||
writeFileSync(reportPath, missingReport.join('\n'));
|
writeFileSync(MISSING_REPORT_PATH, missingReport.join('\n'));
|
||||||
console.warn('\n检查到 ' + missingReport.length + ' 个接口存在元数据缺失,报告已保存至: ' + reportPath);
|
logWarn(`检查到 ${missingReport.length} 个接口元数据缺失,报告已写入:${MISSING_REPORT_PATH}`);
|
||||||
} else {
|
return;
|
||||||
if (existsSync(reportPath)) writeFileSync(reportPath, '');
|
|
||||||
console.log('\n所有接口元数据已完整!');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existsSync(MISSING_REPORT_PATH)) {
|
||||||
|
writeFileSync(MISSING_REPORT_PATH, '');
|
||||||
|
}
|
||||||
|
logSuccess('所有接口元数据完整');
|
||||||
}
|
}
|
||||||
|
|
||||||
generateOpenAPI();
|
generateOpenAPI();
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ export async function NCoreInitShell () {
|
|||||||
// 初始化 FFmpeg 服务
|
// 初始化 FFmpeg 服务
|
||||||
await FFmpegService.init(pathWrapper.binaryPath, logger);
|
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));
|
await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
|
||||||
}
|
}
|
||||||
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
|||||||
status: string;
|
status: string;
|
||||||
hasConfig: boolean;
|
hasConfig: boolean;
|
||||||
hasPages: boolean;
|
hasPages: boolean;
|
||||||
|
homepage?: string;
|
||||||
|
repository?: string;
|
||||||
}> = new Array();
|
}> = new Array();
|
||||||
|
|
||||||
// 收集所有插件的扩展页面
|
// 收集所有插件的扩展页面
|
||||||
@@ -111,7 +113,11 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
|||||||
author: p.packageJson?.author || '',
|
author: p.packageJson?.author || '',
|
||||||
status,
|
status,
|
||||||
hasConfig: !!(p.runtime.module?.plugin_config_schema || p.runtime.module?.plugin_config_ui),
|
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
|
||||||
});
|
});
|
||||||
|
|
||||||
// 收集插件的扩展页面
|
// 收集插件的扩展页面
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginSt
|
|||||||
// 检查缓存(如果不是强制刷新)
|
// 检查缓存(如果不是强制刷新)
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!forceRefresh && pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
|
if (!forceRefresh && pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
|
||||||
//console.log('Using cached plugin list');
|
// console.log('Using cached plugin list');
|
||||||
return pluginListCache;
|
return pluginListCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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;
|
pluginListCache = data as PluginStoreList;
|
||||||
@@ -86,7 +86,13 @@ async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginSt
|
|||||||
* 下载文件,使用镜像系统
|
* 下载文件,使用镜像系统
|
||||||
* 自动识别 GitHub Release URL 并使用镜像加速
|
* 自动识别 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 {
|
try {
|
||||||
let downloadUrl: string;
|
let downloadUrl: string;
|
||||||
|
|
||||||
@@ -126,7 +132,7 @@ async function downloadFile (url: string, destPath: string, customMirror?: strin
|
|||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'NapCat-WebUI',
|
'User-Agent': 'NapCat-WebUI',
|
||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(120000), // 实际下载120秒超时
|
signal: AbortSignal.timeout(timeout), // 使用传入的超时时间
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -137,9 +143,45 @@ async function downloadFile (url: string, destPath: string, customMirror?: strin
|
|||||||
throw new Error('Response body is null');
|
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);
|
const fileStream = createWriteStream(destPath);
|
||||||
await pipeline(response.body as any, fileStream);
|
await pipeline(progressMonitor(response.body), fileStream);
|
||||||
|
|
||||||
console.log(`Successfully downloaded to: ${destPath}`);
|
console.log(`Successfully downloaded to: ${destPath}`);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -210,7 +252,7 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 解压失败时,尝试恢复 data 文件夹
|
// 解压失败时,尝试恢复 data 文件夹
|
||||||
if (hasDataBackup && fs.existsSync(tempDataDir)) {
|
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)) {
|
if (!fs.existsSync(pluginDir)) {
|
||||||
fs.mkdirSync(pluginDir, { recursive: true });
|
fs.mkdirSync(pluginDir, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -224,7 +266,7 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
|
|||||||
|
|
||||||
// 列出解压后的文件
|
// 列出解压后的文件
|
||||||
const files = fs.readdirSync(pluginDir);
|
const files = fs.readdirSync(pluginDir);
|
||||||
console.log(`[extractPlugin] Extracted files:`, files);
|
console.log('[extractPlugin] Extracted files:', files);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -279,12 +321,21 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
|
|||||||
return sendError(res, 'Plugin not found in store');
|
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 PLUGINS_DIR = getPluginsDir();
|
||||||
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
|
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadFile(plugin.downloadUrl, tempZipPath, mirror);
|
await downloadFile(plugin.downloadUrl, tempZipPath, mirror, undefined, 300000);
|
||||||
|
|
||||||
// 解压插件
|
// 解压插件
|
||||||
await extractPlugin(tempZipPath, id);
|
await extractPlugin(tempZipPath, id);
|
||||||
@@ -305,7 +356,7 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
|
|||||||
|
|
||||||
return sendSuccess(res, {
|
return sendSuccess(res, {
|
||||||
message: 'Plugin installed successfully',
|
message: 'Plugin installed successfully',
|
||||||
plugin: plugin,
|
plugin,
|
||||||
installPath: path.join(PLUGINS_DIR, id),
|
installPath: path.join(PLUGINS_DIR, id),
|
||||||
});
|
});
|
||||||
} catch (downloadError: any) {
|
} catch (downloadError: any) {
|
||||||
@@ -337,8 +388,8 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
|||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.flushHeaders();
|
res.flushHeaders();
|
||||||
|
|
||||||
const sendProgress = (message: string, progress?: number) => {
|
const sendProgress = (message: string, progress?: number, detail?: any) => {
|
||||||
res.write(`data: ${JSON.stringify({ message, progress })}\n\n`);
|
res.write(`data: ${JSON.stringify({ message, progress, ...detail })}\n\n`);
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -355,6 +406,18 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
|||||||
return;
|
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.name} v${plugin.version}`, 20);
|
||||||
sendProgress(`下载地址: ${plugin.downloadUrl}`, 25);
|
sendProgress(`下载地址: ${plugin.downloadUrl}`, 25);
|
||||||
|
|
||||||
@@ -368,12 +431,28 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
sendProgress('正在下载插件...', 30);
|
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);
|
await extractPlugin(tempZipPath, id);
|
||||||
|
|
||||||
sendProgress('解压完成,正在清理...', 90);
|
sendProgress('解压完成,正在清理...', 95);
|
||||||
fs.unlinkSync(tempZipPath);
|
fs.unlinkSync(tempZipPath);
|
||||||
|
|
||||||
// 如果 pluginManager 存在,立即注册或重载插件
|
// 如果 pluginManager 存在,立即注册或重载插件
|
||||||
@@ -393,7 +472,7 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
|||||||
res.write(`data: ${JSON.stringify({
|
res.write(`data: ${JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Plugin installed successfully',
|
message: 'Plugin installed successfully',
|
||||||
plugin: plugin,
|
plugin,
|
||||||
installPath: path.join(PLUGINS_DIR, id),
|
installPath: path.join(PLUGINS_DIR, id),
|
||||||
})}\n\n`);
|
})}\n\n`);
|
||||||
res.end();
|
res.end();
|
||||||
|
|||||||
@@ -1,13 +1,56 @@
|
|||||||
|
import { Avatar } from '@heroui/avatar';
|
||||||
import { Button } from '@heroui/button';
|
import { Button } from '@heroui/button';
|
||||||
import { Switch } from '@heroui/switch';
|
import { Card, CardBody, CardFooter } from '@heroui/card';
|
||||||
import { Chip } from '@heroui/chip';
|
import { Chip } from '@heroui/chip';
|
||||||
|
import { Switch } from '@heroui/switch';
|
||||||
import { useState } from 'react';
|
import { Tooltip } from '@heroui/tooltip';
|
||||||
import { MdDeleteForever, MdSettings } from 'react-icons/md';
|
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';
|
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 {
|
export interface PluginDisplayCardProps {
|
||||||
data: PluginItem;
|
data: PluginItem;
|
||||||
onToggleStatus: () => Promise<void>;
|
onToggleStatus: () => Promise<void>;
|
||||||
@@ -23,9 +66,15 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
|
|||||||
onConfig,
|
onConfig,
|
||||||
hasConfig = false,
|
hasConfig = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { name, version, author, description, status } = data;
|
const { name, version, author, description, status, homepage, repository } = data;
|
||||||
const isEnabled = status === 'active';
|
const isEnabled = status === 'active';
|
||||||
const [processing, setProcessing] = useState(false);
|
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 = () => {
|
const handleToggle = () => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
@@ -38,88 +87,132 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisplayCardContainer
|
<Card
|
||||||
className='w-full max-w-[420px]'
|
className={clsx(
|
||||||
action={
|
'group w-full backdrop-blur-md rounded-2xl overflow-hidden transition-all duration-300',
|
||||||
<div className='flex flex-col gap-2 w-full'>
|
'hover:shadow-xl hover:-translate-y-1',
|
||||||
<div className='flex gap-2 w-full'>
|
'border border-white/50 dark:border-white/10 hover:border-primary/50 dark:hover:border-primary/50',
|
||||||
<Button
|
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/30'
|
||||||
fullWidth
|
)}
|
||||||
|
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'
|
radius='full'
|
||||||
size='sm'
|
color='default'
|
||||||
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'
|
<div className='min-w-0'>
|
||||||
startContent={<MdDeleteForever size={16} />}
|
<h3 className='text-base font-bold text-default-900 truncate' title={name}>
|
||||||
onPress={handleUninstall}
|
{name}
|
||||||
isDisabled={processing}
|
</h3>
|
||||||
>
|
<p className='text-xs text-default-500 mt-0.5 truncate'>
|
||||||
卸载
|
by <span className='font-medium'>{author || '未知'}</span>
|
||||||
</Button>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasConfig && (
|
|
||||||
<Button
|
<Chip
|
||||||
fullWidth
|
size='sm'
|
||||||
radius='full'
|
variant='flat'
|
||||||
size='sm'
|
color={status === 'active' ? 'success' : status === 'stopped' ? 'warning' : 'default'}
|
||||||
variant='flat'
|
className='flex-shrink-0 font-medium h-6 px-1'
|
||||||
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} />}
|
{status === 'active' ? '运行中' : status === 'stopped' ? '已停止' : '已禁用'}
|
||||||
onPress={onConfig}
|
</Chip>
|
||||||
>
|
|
||||||
配置
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</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
|
<Switch
|
||||||
isDisabled={processing}
|
isDisabled={processing}
|
||||||
isSelected={isEnabled}
|
isSelected={isEnabled}
|
||||||
onChange={handleToggle}
|
onValueChange={handleToggle}
|
||||||
|
size='sm'
|
||||||
|
color='success'
|
||||||
classNames={{
|
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' ? '已停止' : '已禁用'}
|
<span className='text-xs font-medium text-default-600'>
|
||||||
</Chip>
|
{isEnabled ? '已启用' : '已禁用'}
|
||||||
}
|
|
||||||
>
|
|
||||||
<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>
|
</span>
|
||||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
</Switch>
|
||||||
{version}
|
|
||||||
</div>
|
<div className='flex-1' />
|
||||||
</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'>
|
{hasConfig && (
|
||||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
<Tooltip content='插件配置'>
|
||||||
作者
|
<Button
|
||||||
</span>
|
isIconOnly
|
||||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
radius='full'
|
||||||
{author || '未知'}
|
size='sm'
|
||||||
</div>
|
variant='light'
|
||||||
</div>
|
color='primary'
|
||||||
<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'>
|
onPress={onConfig}
|
||||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
>
|
||||||
描述
|
<MdSettings size={20} />
|
||||||
</span>
|
</Button>
|
||||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2'>
|
</Tooltip>
|
||||||
{description || '暂无描述'}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
<Tooltip content='卸载插件' color='danger'>
|
||||||
</div>
|
<Button
|
||||||
</DisplayCardContainer>
|
isIconOnly
|
||||||
|
radius='full'
|
||||||
|
size='sm'
|
||||||
|
variant='light'
|
||||||
|
color='danger'
|
||||||
|
onPress={handleUninstall}
|
||||||
|
isDisabled={processing}
|
||||||
|
>
|
||||||
|
<MdDeleteForever size={20} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,60 @@
|
|||||||
|
/* eslint-disable @stylistic/indent */
|
||||||
|
import { Avatar } from '@heroui/avatar';
|
||||||
import { Button } from '@heroui/button';
|
import { Button } from '@heroui/button';
|
||||||
|
import { Card, CardBody, CardFooter } from '@heroui/card';
|
||||||
import { Chip } from '@heroui/chip';
|
import { Chip } from '@heroui/chip';
|
||||||
import { Tooltip } from '@heroui/tooltip';
|
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 { 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';
|
import { PluginStoreItem } from '@/types/plugin-store';
|
||||||
|
|
||||||
export type InstallStatus = 'not-installed' | 'installed' | 'update-available';
|
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 {
|
export interface PluginStoreCardProps {
|
||||||
data: PluginStoreItem;
|
data: PluginStoreItem;
|
||||||
onInstall: () => Promise<void>;
|
onInstall: () => void;
|
||||||
installStatus?: InstallStatus;
|
installStatus?: InstallStatus;
|
||||||
installedVersion?: string;
|
installedVersion?: string;
|
||||||
}
|
}
|
||||||
@@ -20,158 +63,222 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
|||||||
data,
|
data,
|
||||||
onInstall,
|
onInstall,
|
||||||
installStatus = 'not-installed',
|
installStatus = 'not-installed',
|
||||||
|
installedVersion,
|
||||||
}) => {
|
}) => {
|
||||||
const { name, version, author, description, tags, id, homepage } = data;
|
const { name, version, author, description, tags, homepage, downloadUrl } = data;
|
||||||
const [processing, setProcessing] = useState(false);
|
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const hasBackground = !!backgroundImage;
|
||||||
|
|
||||||
const handleInstall = () => {
|
// 综合尝试提取头像,最后兜底使用 Vercel 风格头像
|
||||||
setProcessing(true);
|
const avatarUrl = getAuthorAvatar(homepage, downloadUrl) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
|
||||||
onInstall().finally(() => setProcessing(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 根据安装状态返回按钮配置
|
// 作者链接组件
|
||||||
const getButtonConfig = () => {
|
const AuthorComponent = (
|
||||||
switch (installStatus) {
|
<span className={clsx('font-medium transition-colors', homepage ? 'hover:text-primary hover:underline cursor-pointer' : '')}>
|
||||||
case 'installed':
|
{author || '未知作者'}
|
||||||
return {
|
</span>
|
||||||
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
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisplayCardContainer
|
<Card
|
||||||
className='w-full max-w-[420px]'
|
className={clsx(
|
||||||
title={titleContent}
|
'group w-full backdrop-blur-md rounded-2xl overflow-hidden transition-all duration-300',
|
||||||
tag={
|
'hover:shadow-xl hover:-translate-y-1',
|
||||||
<div className="ml-auto flex items-center gap-1">
|
// 降低边框粗细
|
||||||
{installStatus === 'installed' && (
|
'border border-white/50 dark:border-white/10 hover:border-primary/50 dark:hover:border-primary/50',
|
||||||
<Chip
|
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/30'
|
||||||
color="success"
|
)}
|
||||||
size="sm"
|
shadow='sm'
|
||||||
variant="flat"
|
>
|
||||||
startContent={<IoMdCheckmarkCircle size={14} />}
|
<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'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
已安装
|
{description || '暂无描述'}
|
||||||
</Chip>
|
</p>
|
||||||
)}
|
</Tooltip>
|
||||||
{installStatus === 'update-available' && (
|
</div>
|
||||||
<Chip
|
|
||||||
color="warning"
|
{/* Tags & Version */}
|
||||||
size="sm"
|
<div className='flex items-center gap-1.5 flex-wrap'>
|
||||||
variant="flat"
|
|
||||||
>
|
|
||||||
可更新
|
|
||||||
</Chip>
|
|
||||||
)}
|
|
||||||
<Chip
|
<Chip
|
||||||
color="primary"
|
size='sm'
|
||||||
size="sm"
|
variant='flat'
|
||||||
variant="flat"
|
color='primary'
|
||||||
|
className='h-5 text-xs font-semibold px-0.5'
|
||||||
|
classNames={{ content: 'px-1' }}
|
||||||
>
|
>
|
||||||
v{version}
|
v{version}
|
||||||
</Chip>
|
</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>
|
</div>
|
||||||
}
|
</CardBody>
|
||||||
enableSwitch={undefined}
|
|
||||||
action={
|
<CardFooter className='px-4 pb-4 pt-0'>
|
||||||
<Button
|
{installStatus === 'installed'
|
||||||
fullWidth
|
? (
|
||||||
radius='full'
|
<Button
|
||||||
size='sm'
|
fullWidth
|
||||||
color={buttonConfig.color}
|
radius='full'
|
||||||
startContent={buttonConfig.icon}
|
size='sm'
|
||||||
onPress={handleInstall}
|
color='success'
|
||||||
isLoading={processing}
|
variant='flat'
|
||||||
isDisabled={processing}
|
startContent={<IoMdCheckmarkCircle size={18} />}
|
||||||
>
|
className='font-medium bg-success/20 text-success dark:bg-success/20 cursor-default'
|
||||||
{buttonConfig.text}
|
isDisabled
|
||||||
</Button>
|
>
|
||||||
}
|
已安装
|
||||||
>
|
</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'>
|
: installStatus === 'update-available'
|
||||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
? (
|
||||||
作者
|
<Button
|
||||||
</span>
|
fullWidth
|
||||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
radius='full'
|
||||||
{author || '未知'}
|
size='sm'
|
||||||
</div>
|
color='warning'
|
||||||
</div>
|
variant='shadow'
|
||||||
<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'>
|
className='font-medium text-white shadow-warning/30 hover:shadow-warning/50 transition-shadow'
|
||||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
startContent={<MdUpdate size={18} />}
|
||||||
版本
|
onPress={onInstall}
|
||||||
</span>
|
>
|
||||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
更新到 v{version}
|
||||||
v{version}
|
</Button>
|
||||||
</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'>
|
<Button
|
||||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
fullWidth
|
||||||
描述
|
radius='full'
|
||||||
</span>
|
size='sm'
|
||||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2 h-10 overflow-hidden'>
|
color='primary'
|
||||||
{description || '暂无描述'}
|
variant='bordered'
|
||||||
</div>
|
className='font-medium bg-white dark:bg-zinc-900 border hover:bg-primary hover:text-white transition-all shadow-sm group/btn'
|
||||||
</div>
|
startContent={<MdOutlineGetApp size={20} className='transition-transform group-hover/btn:translate-y-0.5' />}
|
||||||
{id && (
|
onPress={onInstall}
|
||||||
<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'>
|
立即安装
|
||||||
包名
|
</Button>
|
||||||
</span>
|
)}
|
||||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2 h-10 overflow-hidden'>
|
</CardFooter>
|
||||||
{id || '包名'}
|
</Card>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const Toaster = () => {
|
|||||||
borderRadius: '20px',
|
borderRadius: '20px',
|
||||||
background: isDark ? '#333' : '#fff',
|
background: isDark ? '#333' : '#fff',
|
||||||
color: isDark ? '#fff' : '#333',
|
color: isDark ? '#fff' : '#333',
|
||||||
|
maxWidth: '400px',
|
||||||
|
wordBreak: 'break-word',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export interface PluginItem {
|
|||||||
hasConfig?: boolean;
|
hasConfig?: boolean;
|
||||||
/** 是否有扩展页面 */
|
/** 是否有扩展页面 */
|
||||||
hasPages?: boolean;
|
hasPages?: boolean;
|
||||||
|
/** 主页链接 */
|
||||||
|
homepage?: string;
|
||||||
|
/** 仓库链接 */
|
||||||
|
repository?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 扩展页面信息 */
|
/** 扩展页面信息 */
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface ExtensionPage {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||||
export default function ExtensionPage () {
|
export default function ExtensionPage () {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [extensionPages, setExtensionPages] = useState<ExtensionPage[]>([]);
|
const [extensionPages, setExtensionPages] = useState<ExtensionPage[]>([]);
|
||||||
@@ -150,28 +151,30 @@ export default function ExtensionPage () {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{extensionPages.length === 0 && !loading ? (
|
{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' />
|
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
|
||||||
<p className='text-lg'>暂无插件扩展页面</p>
|
<MdExtension size={64} className='mb-4 opacity-50' />
|
||||||
<p className='text-sm mt-2'>插件可以通过注册页面来扩展 WebUI 功能</p>
|
<p className='text-lg'>暂无插件扩展页面</p>
|
||||||
</div>
|
<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'>
|
<div className='flex-1 min-h-0 bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden relative'>
|
||||||
<Spinner size='lg' />
|
{iframeLoading && (
|
||||||
</div>
|
<div className='absolute inset-0 flex items-center justify-center bg-default-100/50 z-10'>
|
||||||
)}
|
<Spinner size='lg' />
|
||||||
<iframe
|
</div>
|
||||||
src={currentPageUrl}
|
)}
|
||||||
className='w-full h-full border-0'
|
<iframe
|
||||||
onLoad={handleIframeLoad}
|
src={currentPageUrl}
|
||||||
title='extension-page'
|
className='w-full h-full border-0'
|
||||||
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
|
onLoad={handleIframeLoad}
|
||||||
/>
|
title='extension-page'
|
||||||
</div>
|
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
|
||||||
)}
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Input } from '@heroui/input';
|
|||||||
import { Select, SelectItem } from '@heroui/select';
|
import { Select, SelectItem } from '@heroui/select';
|
||||||
import { Switch } from '@heroui/switch';
|
import { Switch } from '@heroui/switch';
|
||||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from '@/utils/toast';
|
||||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||||
import PluginManager, { PluginConfigSchemaItem } from '@/controllers/plugin_manager';
|
import PluginManager, { PluginConfigSchemaItem } from '@/controllers/plugin_manager';
|
||||||
import key from '@/const/key';
|
import key from '@/const/key';
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Button } from '@heroui/button';
|
import { Button } from '@heroui/button';
|
||||||
import { Input } from '@heroui/input';
|
import { Input } from '@heroui/input';
|
||||||
import { Tab, Tabs } from '@heroui/tabs';
|
import { Tab, Tabs } from '@heroui/tabs';
|
||||||
import { Card, CardBody } from '@heroui/card';
|
|
||||||
import { Tooltip } from '@heroui/tooltip';
|
import { Tooltip } from '@heroui/tooltip';
|
||||||
import { Spinner } from '@heroui/spinner';
|
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 toast from 'react-hot-toast';
|
||||||
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -42,6 +41,34 @@ export default function PluginStorePage () {
|
|||||||
const [activeTab, setActiveTab] = useState<string>('all');
|
const [activeTab, setActiveTab] = useState<string>('all');
|
||||||
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
|
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
|
||||||
const dialog = useDialog();
|
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);
|
const [storeSourceModalOpen, setStoreSourceModalOpen] = useState(false);
|
||||||
@@ -103,7 +130,8 @@ export default function PluginStorePage () {
|
|||||||
// 获取插件的安装状态和已安装版本
|
// 获取插件的安装状态和已安装版本
|
||||||
const getPluginInstallInfo = (plugin: PluginStoreItem): { status: InstallStatus; installedVersion?: string; } => {
|
const getPluginInstallInfo = (plugin: PluginStoreItem): { status: InstallStatus; installedVersion?: string; } => {
|
||||||
// 通过 id (包名) 或 name 匹配已安装的插件
|
// 通过 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) {
|
if (!installed) {
|
||||||
return { status: 'not-installed' };
|
return { status: 'not-installed' };
|
||||||
@@ -167,9 +195,11 @@ export default function PluginStorePage () {
|
|||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
toast.error(`安装失败: ${data.error}`, { id: loadingToast });
|
toast.error(`安装失败: ${data.error}`, { id: loadingToast });
|
||||||
|
setInstallProgress(prev => ({ ...prev, show: false }));
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
} else if (data.success) {
|
} else if (data.success) {
|
||||||
toast.success('插件安装成功!', { id: loadingToast });
|
toast.success('插件安装成功!', { id: loadingToast });
|
||||||
|
setInstallProgress(prev => ({ ...prev, show: false }));
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
// 刷新插件列表
|
// 刷新插件列表
|
||||||
loadPlugins();
|
loadPlugins();
|
||||||
@@ -178,30 +208,46 @@ export default function PluginStorePage () {
|
|||||||
dialog.confirm({
|
dialog.confirm({
|
||||||
title: '插件管理器未加载',
|
title: '插件管理器未加载',
|
||||||
content: (
|
content: (
|
||||||
<div className="space-y-2">
|
<div className='space-y-2'>
|
||||||
<p className="text-sm text-default-600">
|
<p className='text-sm text-default-600'>
|
||||||
插件已安装成功,但插件管理器尚未加载。
|
插件已安装成功,但插件管理器尚未加载。
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-default-600">
|
<p className='text-sm text-default-600'>
|
||||||
是否立即注册插件管理器?注册后插件才能正常运行。
|
是否立即注册插件管理器?注册后插件才能正常运行。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
confirmText: '注册插件管理器',
|
confirmText: '注册插件管理器',
|
||||||
cancelText: '稍后再说',
|
cancelText: '稍后再说',
|
||||||
onConfirm: async () => {
|
onConfirm: () => {
|
||||||
try {
|
(async () => {
|
||||||
await PluginManager.registerPluginManager();
|
try {
|
||||||
toast.success('插件管理器注册成功');
|
await PluginManager.registerPluginManager();
|
||||||
setPluginManagerNotFound(false);
|
toast.success('插件管理器注册成功');
|
||||||
} catch (e: any) {
|
setPluginManagerNotFound(false);
|
||||||
toast.error('注册失败: ' + e.message);
|
} catch (e: any) {
|
||||||
}
|
toast.error('注册失败: ' + e.message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (data.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) {
|
} catch (e) {
|
||||||
console.error('Failed to parse SSE message:', e);
|
console.error('Failed to parse SSE message:', e);
|
||||||
@@ -211,6 +257,7 @@ export default function PluginStorePage () {
|
|||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
console.error('SSE连接出错:', error);
|
console.error('SSE连接出错:', error);
|
||||||
toast.error('连接中断,安装失败', { id: loadingToast });
|
toast.error('连接中断,安装失败', { id: loadingToast });
|
||||||
|
setInstallProgress(prev => ({ ...prev, show: false }));
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -233,67 +280,72 @@ export default function PluginStorePage () {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>插件商店 - NapCat WebUI</title>
|
<title>插件商店 - NapCat WebUI</title>
|
||||||
<div className="p-2 md:p-4 relative">
|
<div className='p-2 md:p-4 relative'>
|
||||||
{/* 固定头部区域 */}
|
{/* 固定头部区域 */}
|
||||||
<div className={clsx(
|
<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
|
hasBackground
|
||||||
? 'bg-white/20 dark:bg-black/10'
|
? 'bg-white/20 dark:bg-black/10'
|
||||||
: 'bg-transparent'
|
: 'bg-transparent'
|
||||||
)}>
|
)}
|
||||||
{/* 头部 */}
|
>
|
||||||
<div className="flex mb-4 items-center justify-between flex-wrap gap-4">
|
{/* 头部布局:标题 + 搜索 + 工具栏 */}
|
||||||
<div className="flex items-center gap-4">
|
<div className='flex flex-col md:flex-row mb-4 items-start md:items-center justify-between gap-4'>
|
||||||
<h1 className="text-2xl font-bold">插件商店</h1>
|
<div className='flex items-center gap-3 flex-shrink-0'>
|
||||||
<Button
|
<h1 className='text-2xl font-bold'>插件商店</h1>
|
||||||
isIconOnly
|
<Tooltip content='刷新列表'>
|
||||||
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
<Button
|
||||||
radius="full"
|
isIconOnly
|
||||||
onPress={() => loadPlugins(true)}
|
size='sm'
|
||||||
isLoading={loading}
|
variant='flat'
|
||||||
>
|
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
|
||||||
<IoMdRefresh size={24} />
|
radius='full'
|
||||||
</Button>
|
onPress={() => loadPlugins(true)}
|
||||||
|
isLoading={loading}
|
||||||
|
>
|
||||||
|
<IoMdRefresh size={20} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 商店列表源卡片 */}
|
{/* 顶栏搜索框与列表源 */}
|
||||||
<Card className="bg-default-100/50 backdrop-blur-md shadow-sm">
|
<div className='flex items-center gap-3 w-full md:w-auto flex-1 justify-end'>
|
||||||
<CardBody className="py-2 px-3">
|
<Input
|
||||||
<div className="flex items-center gap-3">
|
ref={searchInputRef}
|
||||||
<div className="flex items-center gap-2">
|
placeholder='搜索(Ctrl+F)...'
|
||||||
<span className="text-xs text-default-500">列表源:</span>
|
startContent={<IoMdSearch className='text-default-400' />}
|
||||||
<span className="text-sm font-medium">{getStoreSourceDisplayName()}</span>
|
value={searchQuery}
|
||||||
</div>
|
onValueChange={setSearchQuery}
|
||||||
<Tooltip content="切换列表源">
|
className='max-w-xs w-full'
|
||||||
<Button
|
size='sm'
|
||||||
isIconOnly
|
isClearable
|
||||||
size="sm"
|
classNames={{
|
||||||
variant="light"
|
inputWrapper: 'bg-default-100/50 dark:bg-black/20 backdrop-blur-md border-white/20 dark:border-white/10',
|
||||||
onPress={() => setStoreSourceModalOpen(true)}
|
}}
|
||||||
>
|
/>
|
||||||
<IoMdSettings size={16} />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 搜索框 */}
|
{/* 商店列表源简易卡片 */}
|
||||||
<div className="mb-4">
|
<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'>
|
||||||
<Input
|
<span className='text-xs text-default-500 whitespace-nowrap'>源: {getStoreSourceDisplayName()}</span>
|
||||||
placeholder="搜索插件名称、描述、作者或标签..."
|
<Tooltip content='切换列表源'>
|
||||||
startContent={<IoMdSearch className="text-default-400" />}
|
<Button
|
||||||
value={searchQuery}
|
isIconOnly
|
||||||
onValueChange={setSearchQuery}
|
size='sm'
|
||||||
className="max-w-md"
|
variant='light'
|
||||||
/>
|
className='min-w-unit-6 w-6 h-6'
|
||||||
|
onPress={() => setStoreSourceModalOpen(true)}
|
||||||
|
>
|
||||||
|
<IoMdSettings size={14} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 标签页导航 */}
|
{/* 标签页导航 */}
|
||||||
<Tabs
|
<Tabs
|
||||||
aria-label="Plugin Store Categories"
|
aria-label='Plugin Store Categories'
|
||||||
className="max-w-full"
|
className='max-w-full'
|
||||||
selectedKey={activeTab}
|
selectedKey={activeTab}
|
||||||
onSelectionChange={(key) => setActiveTab(String(key))}
|
onSelectionChange={(key) => setActiveTab(String(key))}
|
||||||
classNames={{
|
classNames={{
|
||||||
@@ -312,16 +364,16 @@ export default function PluginStorePage () {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 插件列表区域 */}
|
{/* 插件列表区域 */}
|
||||||
<div className="relative">
|
<div className='relative'>
|
||||||
{/* 加载遮罩 - 只遮住插件列表区域 */}
|
{/* 加载遮罩 - 只遮住插件列表区域 */}
|
||||||
{loading && (
|
{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' />
|
<Spinner size='lg' />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<EmptySection isEmpty={!categorizedPlugins[activeTab]?.length} />
|
<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) => {
|
{categorizedPlugins[activeTab]?.map((plugin) => {
|
||||||
const installInfo = getPluginInstallInfo(plugin);
|
const installInfo = getPluginInstallInfo(plugin);
|
||||||
return (
|
return (
|
||||||
@@ -330,7 +382,7 @@ export default function PluginStorePage () {
|
|||||||
data={plugin}
|
data={plugin}
|
||||||
installStatus={installInfo.status}
|
installStatus={installInfo.status}
|
||||||
installedVersion={installInfo.installedVersion}
|
installedVersion={installInfo.installedVersion}
|
||||||
onInstall={() => handleInstall(plugin)}
|
onInstall={() => { handleInstall(plugin); }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -346,7 +398,7 @@ export default function PluginStorePage () {
|
|||||||
setCurrentStoreSource(mirror);
|
setCurrentStoreSource(mirror);
|
||||||
}}
|
}}
|
||||||
currentMirror={currentStoreSource}
|
currentMirror={currentStoreSource}
|
||||||
type="raw"
|
type='raw'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 下载镜像选择弹窗 */}
|
{/* 下载镜像选择弹窗 */}
|
||||||
@@ -366,8 +418,65 @@ export default function PluginStorePage () {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
currentMirror={selectedDownloadMirror}
|
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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
60
packages/napcat-webui-frontend/src/utils/toast.ts
Normal file
60
packages/napcat-webui-frontend/src/utils/toast.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Toast 工具模块
|
||||||
|
* 包装 react-hot-toast,自动截断长路径避免溢出
|
||||||
|
*/
|
||||||
|
import hotToast, { ToastOptions, Renderable, ValueOrFunction, Toast } from 'react-hot-toast';
|
||||||
|
import { truncateErrorMessage } from './truncate';
|
||||||
|
|
||||||
|
type Message = ValueOrFunction<Renderable, Toast>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 包装后的 toast 对象
|
||||||
|
* 对 error 类型的 toast 自动应用路径截断
|
||||||
|
*/
|
||||||
|
const toast = {
|
||||||
|
/**
|
||||||
|
* 显示错误 toast,自动截断长路径
|
||||||
|
*/
|
||||||
|
error: (message: Message, options?: ToastOptions) => {
|
||||||
|
const truncatedMessage = typeof message === 'string'
|
||||||
|
? truncateErrorMessage(message)
|
||||||
|
: message;
|
||||||
|
return hotToast.error(truncatedMessage, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示成功 toast
|
||||||
|
*/
|
||||||
|
success: (message: Message, options?: ToastOptions) => {
|
||||||
|
return hotToast.success(message, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示加载中 toast
|
||||||
|
*/
|
||||||
|
loading: (message: Message, options?: ToastOptions) => {
|
||||||
|
return hotToast.loading(message, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示普通 toast
|
||||||
|
*/
|
||||||
|
custom: hotToast.custom,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭 toast
|
||||||
|
*/
|
||||||
|
dismiss: hotToast.dismiss,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除 toast
|
||||||
|
*/
|
||||||
|
remove: hotToast.remove,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promise toast
|
||||||
|
*/
|
||||||
|
promise: hotToast.promise,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default toast;
|
||||||
141
packages/napcat-webui-frontend/src/utils/truncate.ts
Normal file
141
packages/napcat-webui-frontend/src/utils/truncate.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* 路径截断工具函数
|
||||||
|
*
|
||||||
|
* 用于解决前端提示框中长路径导致内容溢出的问题。
|
||||||
|
* 当错误消息包含过长的文件路径时,会导致提示框显示异常。
|
||||||
|
*
|
||||||
|
* 使用场景:
|
||||||
|
* - Toast 消息中包含文件路径
|
||||||
|
* - 错误提示中包含配置文件路径
|
||||||
|
* - 任何可能因路径过长导致 UI 溢出的场景
|
||||||
|
*
|
||||||
|
* 兼容性:
|
||||||
|
* - Windows 路径:D:\folder\subfolder\file (使用 \ 作为分隔符)
|
||||||
|
* - Linux/Unix 路径:/home/user/folder/file (使用 / 作为分隔符)
|
||||||
|
*
|
||||||
|
* 示例:
|
||||||
|
* - Windows: D:\NapCat.Shell-1\NapCat.Shell-2\...\data → D:\NapCat.Shell-1\...\napcat-plugin-builtin\data
|
||||||
|
* - Linux: /home/user/projects/napcat/plugins/data → /home/user/...\plugins/data
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 截断长路径,保留开头和结尾部分
|
||||||
|
*
|
||||||
|
* @param path - 需要截断的路径(支持 Windows 和 Linux 路径格式)
|
||||||
|
* @param maxLength - 最大允许长度,默认 60 字符
|
||||||
|
* @returns 截断后的路径,中间用 ... 替代
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Windows 路径
|
||||||
|
* truncatePath('D:\\folder1\\folder2\\folder3\\file.txt', 30)
|
||||||
|
* // 返回: 'D:\\...\\folder3\\file.txt'
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Linux 路径
|
||||||
|
* truncatePath('/home/user/projects/deep/nested/file.txt', 30)
|
||||||
|
* // 返回: '/home/user/.../nested/file.txt'
|
||||||
|
*/
|
||||||
|
export function truncatePath (path: string, maxLength: number = 60): string {
|
||||||
|
if (path.length <= maxLength) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动检测路径分隔符,兼容 Windows (\) 和 Linux/Unix (/)
|
||||||
|
const separator = path.includes('\\') ? '\\' : '/';
|
||||||
|
const parts = path.split(separator);
|
||||||
|
|
||||||
|
if (parts.length <= 3) {
|
||||||
|
// 如果路径段太少(如 D:\folder\file),直接尾部截断
|
||||||
|
return path.substring(0, maxLength - 3) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留第一段(Windows 驱动器号如 D: 或 Linux 根目录)和最后两段(父目录+文件名)
|
||||||
|
const firstPart = parts[0];
|
||||||
|
const lastParts = parts.slice(-2).join(separator);
|
||||||
|
|
||||||
|
const truncated = `${firstPart}${separator}...${separator}${lastParts}`;
|
||||||
|
|
||||||
|
// 如果截断后仍然超长,回退到简单的尾部截断
|
||||||
|
if (truncated.length > maxLength) {
|
||||||
|
return path.substring(0, maxLength - 3) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return truncated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能截断消息文本,特别处理包含路径的错误消息
|
||||||
|
*
|
||||||
|
* 此函数会自动检测消息中的文件路径(Windows 和 Linux 格式)并截断过长的路径,
|
||||||
|
* 以防止 UI 组件(如 Toast、Alert)因内容过长而溢出。
|
||||||
|
*
|
||||||
|
* @param message - 需要处理的消息文本
|
||||||
|
* @param maxLength - 最终消息的最大长度,默认 100 字符
|
||||||
|
* @returns 处理后的消息,路径被截断,整体长度受限
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 处理包含 Windows 路径的错误消息
|
||||||
|
* truncateErrorMessage("Save failed: Error updating config: EPERM: operation not permitted, open 'D:\\very\\long\\path\\config.json'")
|
||||||
|
* // 返回: "Save failed: Error updating config: EPERM: operation not permitted, open 'D:\\...\\path\\config.json'"
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 处理包含 Linux 路径的错误消息
|
||||||
|
* truncateErrorMessage("Failed to read /home/user/projects/napcat/very/deep/nested/config.json")
|
||||||
|
* // 返回: "Failed to read /home/user/.../nested/config.json"
|
||||||
|
*/
|
||||||
|
export function truncateErrorMessage (message: string, maxLength: number = 100): string {
|
||||||
|
if (message.length <= maxLength) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows 路径正则:匹配 盘符:\路径 格式,如 D:\folder\file.txt
|
||||||
|
// 排除空白字符和引号,避免匹配到路径外的内容
|
||||||
|
const windowsPathRegex = /[A-Za-z]:\\[^\s'"]+/g;
|
||||||
|
|
||||||
|
// Linux/Unix 路径正则:匹配 /开头的多级路径,如 /home/user/file
|
||||||
|
// 要求至少有两级目录,避免匹配单独的 /
|
||||||
|
const unixPathRegex = /\/[^\s'"]+(?:\/[^\s'"]+)+/g;
|
||||||
|
|
||||||
|
let result = message;
|
||||||
|
|
||||||
|
// 处理 Windows 路径
|
||||||
|
const windowsPaths = message.match(windowsPathRegex);
|
||||||
|
if (windowsPaths) {
|
||||||
|
for (const path of windowsPaths) {
|
||||||
|
if (path.length > 40) {
|
||||||
|
result = result.replace(path, truncatePath(path, 40));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 Unix 路径
|
||||||
|
const unixPaths = message.match(unixPathRegex);
|
||||||
|
if (unixPaths) {
|
||||||
|
for (const path of unixPaths) {
|
||||||
|
if (path.length > 40) {
|
||||||
|
result = result.replace(path, truncatePath(path, 40));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果处理路径后消息仍然超长,直接尾部截断
|
||||||
|
if (result.length > maxLength) {
|
||||||
|
return result.substring(0, maxLength - 3) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 截断普通文本(简单截断,不做路径检测)
|
||||||
|
*
|
||||||
|
* @param text - 需要截断的文本
|
||||||
|
* @param maxLength - 最大长度,默认 50 字符
|
||||||
|
* @returns 截断后的文本,超长部分用 ... 替代
|
||||||
|
*/
|
||||||
|
export function truncateText (text: string, maxLength: number = 50): string {
|
||||||
|
if (text.length <= maxLength) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return text.substring(0, maxLength - 3) + '...';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user