mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-12-24 09:00:06 +08:00
Compare commits
No commits in common. "main" and "v4.9.78" have entirely different histories.
@ -43,7 +43,7 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
|
||||
**首次使用**请务必查看如下文档看使用教程
|
||||
|
||||
> 项目非盈利,涉及 对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
||||
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
||||
|
||||
## Link
|
||||
|
||||
|
||||
22
packages/napcat-core/external/appid.json
vendored
22
packages/napcat-core/external/appid.json
vendored
@ -471,32 +471,12 @@
|
||||
"appid": 537320212,
|
||||
"qua": "V1_WIN_NQ_9.9.23_42430_GW_B"
|
||||
},
|
||||
"9.9.25-42744": {
|
||||
"9.9.23-42744": {
|
||||
"appid": 537328470,
|
||||
"qua": "V1_WIN_NQ_9.9.23_42744_GW_B"
|
||||
},
|
||||
"6.9.86-42744": {
|
||||
"appid": 537328495,
|
||||
"qua": "V1_MAC_NQ_6.9.85_42744_GW_B"
|
||||
},
|
||||
"9.9.25-42905": {
|
||||
"appid": 537328521,
|
||||
"qua": "V1_WIN_NQ_9.9.25_42905_GW_B"
|
||||
},
|
||||
"6.9.86-42905": {
|
||||
"appid": 537328546,
|
||||
"qua": "V1_MAC_NQ_6.9.86_42905_GW_B"
|
||||
},
|
||||
"3.2.22-42941": {
|
||||
"appid": 537328659,
|
||||
"qua": "V1_LNX_NQ_3.2.22_42941_GW_B"
|
||||
},
|
||||
"9.9.25-42941": {
|
||||
"appid": 537328623,
|
||||
"qua": "V1_WIN_NQ_9.9.25_42941_GW_B"
|
||||
},
|
||||
"6.9.86-42941": {
|
||||
"appid": 537328648,
|
||||
"qua": "V1_MAC_NQ_6.9.86_42941_GW_B"
|
||||
}
|
||||
}
|
||||
26
packages/napcat-core/external/napi2native.json
vendored
26
packages/napcat-core/external/napi2native.json
vendored
@ -95,36 +95,12 @@
|
||||
"send": "0A01A34",
|
||||
"recv": "1D1CFF9"
|
||||
},
|
||||
"9.9.25-42744-x64": {
|
||||
"9.9.23-42744-x64": {
|
||||
"send": "0A0D104",
|
||||
"recv": "1D3E7F9"
|
||||
},
|
||||
"6.9.85-42744-arm64": {
|
||||
"send": "23DFEF0",
|
||||
"recv": "095FD80"
|
||||
},
|
||||
"9.9.25-42905-x64": {
|
||||
"send": "0A12E74",
|
||||
"recv": "1D450FD"
|
||||
},
|
||||
"6.9.86-42905-arm64": {
|
||||
"send": "2342408",
|
||||
"recv": "09639B8"
|
||||
},
|
||||
"3.2.22-42941-x64": {
|
||||
"send": "5BC1630",
|
||||
"recv": "3011E00"
|
||||
},
|
||||
"3.2.22-42941-arm64": {
|
||||
"send": "3DC90AC",
|
||||
"recv": "1497A70"
|
||||
},
|
||||
"9.9.25-42941-x64": {
|
||||
"send": "0A131D4",
|
||||
"recv": "1D4547D"
|
||||
},
|
||||
"6.9.86-42941-arm64": {
|
||||
"send": "2346108",
|
||||
"recv": "09675F0"
|
||||
}
|
||||
}
|
||||
26
packages/napcat-core/external/packet.json
vendored
26
packages/napcat-core/external/packet.json
vendored
@ -607,36 +607,12 @@
|
||||
"send": "2C9A4A0",
|
||||
"recv": "2C9DA20"
|
||||
},
|
||||
"9.9.25-42744-x64": {
|
||||
"9.9.23-42744-x64": {
|
||||
"send": "2CD8E40",
|
||||
"recv": "2CDC3C0"
|
||||
},
|
||||
"6.9.86-42744-arm64": {
|
||||
"send": "3DCC840",
|
||||
"recv": "3DCF150"
|
||||
},
|
||||
"9.9.25-42905-x64": {
|
||||
"send": "2CE46A0",
|
||||
"recv": "2CE7C20"
|
||||
},
|
||||
"6.9.86-42905-arm64": {
|
||||
"send": "3DD6098",
|
||||
"recv": "3DD89A8"
|
||||
},
|
||||
"3.2.22-42941-x64": {
|
||||
"send": "A8AD8A0",
|
||||
"recv": "A8B1320"
|
||||
},
|
||||
"9.9.25-42941-x64": {
|
||||
"send": "2CE4DA0",
|
||||
"recv": "2CE8320"
|
||||
},
|
||||
"3.2.22-42941-arm64": {
|
||||
"send": "6BC95E8",
|
||||
"recv": "6BCCF78"
|
||||
},
|
||||
"6.9.86-42941-arm64": {
|
||||
"send": "3DDDAD0",
|
||||
"recv": "3DE03E0"
|
||||
}
|
||||
}
|
||||
@ -79,10 +79,7 @@ export async function NCoreInitFramework (
|
||||
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework);
|
||||
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
||||
// 初始化LLNC的Onebot实现
|
||||
const oneBotAdapter = new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper);
|
||||
// 注册到 WebUiDataRuntime,供调试功能使用
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
await oneBotAdapter.InitOneBot();
|
||||
await new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper).InitOneBot();
|
||||
}
|
||||
|
||||
export class NapCatFramework {
|
||||
|
||||
@ -174,6 +174,7 @@ export class OneBotGroupApi {
|
||||
|
||||
async registerParseGroupReactEventByCore () {
|
||||
this.core.event.on('event:emoji_like', async (data) => {
|
||||
console.log('Received emoji_like event from core:', data);
|
||||
const event = await this.createGroupEmojiLikeEvent(
|
||||
data.groupId,
|
||||
data.senderUin,
|
||||
|
||||
@ -455,11 +455,7 @@ export class NapCatShell {
|
||||
|
||||
async InitNapCat () {
|
||||
await this.core.initCore();
|
||||
const oneBotAdapter = new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper);
|
||||
// 注册到 WebUiDataRuntime,供调试功能使用
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
oneBotAdapter.InitOneBot()
|
||||
new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper).InitOneBot()
|
||||
.catch(e => this.context.logger.logError('初始化OneBot失败', e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -22,7 +22,6 @@ import { existsSync, readFileSync } from 'node:fs'; // 引入multer用于错误
|
||||
import { ILogWrapper } from 'napcat-common/src/log-interface';
|
||||
import { ISubscription } from 'napcat-common/src/subscription-interface';
|
||||
import { IStatusHelperSubscription } from '@/napcat-common/src/status-interface';
|
||||
import { handleDebugWebSocket } from '@/napcat-webui-backend/src/api/Debug';
|
||||
// 实例化Express
|
||||
const app = express();
|
||||
/**
|
||||
@ -188,15 +187,7 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
const isHttps = !!sslCerts;
|
||||
const server = isHttps && sslCerts ? createHttpsServer(sslCerts, app) : createServer(app);
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
|
||||
// 检查是否是调试 WebSocket 连接
|
||||
if (url.pathname.startsWith('/api/Debug/ws')) {
|
||||
handleDebugWebSocket(request, socket, head);
|
||||
} else {
|
||||
// 默认为终端 WebSocket
|
||||
terminalManager.initialize(request, socket, head, logger);
|
||||
}
|
||||
terminalManager.initialize(request, socket, head, logger);
|
||||
});
|
||||
// 挂载API接口
|
||||
app.use('/api', ALLRouter);
|
||||
|
||||
@ -1,406 +0,0 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { OB11Response } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { OB11LifeCycleEvent, LifeCycleSubType } from '@/napcat-onebot/event/meta/OB11LifeCycleEvent';
|
||||
|
||||
const router = Router();
|
||||
const DEFAULT_ADAPTER_NAME = 'debug-primary';
|
||||
|
||||
/**
|
||||
* 统一的调试适配器
|
||||
* 用于注入到 OneBot NetworkManager,接收所有事件并转发给 WebSocket 客户端
|
||||
*/
|
||||
class DebugAdapter {
|
||||
name: string;
|
||||
isEnable: boolean = true;
|
||||
// 安全令牌
|
||||
readonly token: string;
|
||||
|
||||
// 添加 config 属性,模拟 PluginConfig 结构
|
||||
config: {
|
||||
enable: boolean;
|
||||
name: string;
|
||||
messagePostFormat?: string;
|
||||
reportSelfMessage?: boolean;
|
||||
debug?: boolean;
|
||||
token?: string;
|
||||
heartInterval?: number;
|
||||
};
|
||||
wsClients: Set<WebSocket> = new Set();
|
||||
lastActivityTime: number = Date.now();
|
||||
inactivityTimer: NodeJS.Timeout | null = null;
|
||||
readonly INACTIVITY_TIMEOUT = 5 * 60 * 1000; // 5分钟不活跃
|
||||
|
||||
constructor (sessionId: string) {
|
||||
this.name = `debug-${sessionId}`;
|
||||
// 生成简单的随机 token
|
||||
this.token = Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2);
|
||||
|
||||
this.config = {
|
||||
enable: true,
|
||||
name: this.name,
|
||||
messagePostFormat: 'array',
|
||||
reportSelfMessage: true,
|
||||
debug: true,
|
||||
token: this.token,
|
||||
heartInterval: 30000
|
||||
};
|
||||
this.startInactivityCheck();
|
||||
}
|
||||
|
||||
// 实现 IOB11NetworkAdapter 接口所需的抽象方法
|
||||
async open (): Promise<void> { }
|
||||
async close (): Promise<void> { this.cleanup(); }
|
||||
async reload (_config: any): Promise<any> { return 0; }
|
||||
|
||||
/**
|
||||
* OneBot 事件回调 - 转发给所有 WebSocket 客户端 (原始流)
|
||||
*/
|
||||
async onEvent (event: any) {
|
||||
this.updateActivity();
|
||||
|
||||
const payload = JSON.stringify(event);
|
||||
|
||||
if (this.wsClients.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.wsClients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
client.send(payload);
|
||||
} catch (error) {
|
||||
console.error('[Debug] 发送事件到 WebSocket 失败:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 OneBot API (HTTP 接口使用)
|
||||
*/
|
||||
async callApi (actionName: string, params: any): Promise<any> {
|
||||
this.updateActivity();
|
||||
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (!oneBotContext) {
|
||||
throw new Error('OneBot 未初始化');
|
||||
}
|
||||
|
||||
const action = oneBotContext.actions.get(actionName);
|
||||
if (!action) {
|
||||
throw new Error(`不支持的 API: ${actionName}`);
|
||||
}
|
||||
|
||||
return await action.handle(params, this.name, {
|
||||
name: this.name,
|
||||
enable: true,
|
||||
messagePostFormat: 'array',
|
||||
reportSelfMessage: true,
|
||||
debug: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WebSocket 消息 (OneBot 标准)
|
||||
*/
|
||||
async handleWsMessage (ws: WebSocket, message: string | Buffer) {
|
||||
this.updateActivity();
|
||||
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any; } = { action: ActionName.Unknown, params: {} };
|
||||
let echo;
|
||||
|
||||
try {
|
||||
receiveData = JSON.parse(message.toString());
|
||||
echo = receiveData.echo;
|
||||
} catch {
|
||||
this.sendWsResponse(ws, OB11Response.error('json解析失败,请检查数据格式', 1400, echo));
|
||||
return;
|
||||
}
|
||||
|
||||
receiveData.params = (receiveData?.params) ? receiveData.params : {};
|
||||
|
||||
// 兼容 WebUI 之前可能的一些非标准格式 (如果用户是旧前端)
|
||||
// 但既然用户说要"原始流",我们优先支持标准格式
|
||||
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (!oneBotContext) {
|
||||
this.sendWsResponse(ws, OB11Response.error('OneBot 未初始化', 1404, echo));
|
||||
return;
|
||||
}
|
||||
|
||||
const action = oneBotContext.actions.get(receiveData.action as any);
|
||||
if (!action) {
|
||||
this.sendWsResponse(ws, OB11Response.error('不支持的API ' + receiveData.action, 1404, echo));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
|
||||
send: async (data: object) => {
|
||||
this.sendWsResponse(ws, OB11Response.ok(data, echo ?? '', true));
|
||||
},
|
||||
});
|
||||
this.sendWsResponse(ws, retdata);
|
||||
} catch (e: any) {
|
||||
this.sendWsResponse(ws, OB11Response.error(e.message || '内部错误', 1200, echo));
|
||||
}
|
||||
}
|
||||
|
||||
sendWsResponse (ws: WebSocket, data: any) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 WebSocket 客户端
|
||||
*/
|
||||
addWsClient (ws: WebSocket) {
|
||||
this.wsClients.add(ws);
|
||||
this.updateActivity();
|
||||
|
||||
// 发送生命周期事件 (Connect)
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (oneBotContext && oneBotContext.core) {
|
||||
try {
|
||||
const event = new OB11LifeCycleEvent(oneBotContext.core, LifeCycleSubType.CONNECT);
|
||||
ws.send(JSON.stringify(event));
|
||||
} catch (e) {
|
||||
console.error('[Debug] 发送生命周期事件失败', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 WebSocket 客户端
|
||||
*/
|
||||
removeWsClient (ws: WebSocket) {
|
||||
this.wsClients.delete(ws);
|
||||
}
|
||||
|
||||
updateActivity () {
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
startInactivityCheck () {
|
||||
this.inactivityTimer = setInterval(() => {
|
||||
const inactive = Date.now() - this.lastActivityTime;
|
||||
// 如果没有 WebSocket 连接且超时,则自动清理
|
||||
if (inactive > this.INACTIVITY_TIMEOUT && this.wsClients.size === 0) {
|
||||
console.log(`[Debug] Adapter ${this.name} 不活跃,自动关闭`);
|
||||
this.cleanup();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
cleanup () {
|
||||
if (this.inactivityTimer) {
|
||||
clearInterval(this.inactivityTimer);
|
||||
this.inactivityTimer = null;
|
||||
}
|
||||
|
||||
// 关闭所有 WebSocket 连接
|
||||
this.wsClients.forEach((client) => {
|
||||
try {
|
||||
client.close();
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
this.wsClients.clear();
|
||||
|
||||
// 从 OneBot NetworkManager 移除
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (oneBotContext) {
|
||||
oneBotContext.networkManager.adapters.delete(this.name);
|
||||
}
|
||||
|
||||
// 从管理器中移除
|
||||
debugAdapterManager.removeAdapter(this.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Token
|
||||
*/
|
||||
validateToken (inputToken: string): boolean {
|
||||
return this.token === inputToken;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试适配器管理器(单例管理)
|
||||
*/
|
||||
class DebugAdapterManager {
|
||||
private currentAdapter: DebugAdapter | null = null;
|
||||
|
||||
getOrCreateAdapter (): DebugAdapter {
|
||||
// 如果已存在且活跃,直接返回
|
||||
if (this.currentAdapter) {
|
||||
this.currentAdapter.updateActivity();
|
||||
return this.currentAdapter;
|
||||
}
|
||||
|
||||
// 创建新实例
|
||||
const adapter = new DebugAdapter('primary');
|
||||
this.currentAdapter = adapter;
|
||||
|
||||
// 注册到 OneBot NetworkManager
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (oneBotContext) {
|
||||
oneBotContext.networkManager.adapters.set(adapter.name, adapter as any);
|
||||
} else {
|
||||
console.warn('[Debug] OneBot 未初始化,无法注册适配器');
|
||||
}
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
getAdapter (name: string): DebugAdapter | undefined {
|
||||
if (this.currentAdapter && this.currentAdapter.name === name) {
|
||||
return this.currentAdapter;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
removeAdapter (name: string) {
|
||||
if (this.currentAdapter && this.currentAdapter.name === name) {
|
||||
this.currentAdapter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const debugAdapterManager = new DebugAdapterManager();
|
||||
|
||||
/**
|
||||
* 获取或创建调试会话
|
||||
*/
|
||||
router.post('/create', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const adapter = debugAdapterManager.getOrCreateAdapter();
|
||||
sendSuccess(res, {
|
||||
adapterName: adapter.name,
|
||||
token: adapter.token,
|
||||
message: '调试适配器已就绪',
|
||||
});
|
||||
} catch (error: any) {
|
||||
sendError(res, error.message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* HTTP 调用 OneBot API (支持默认 adapter)
|
||||
*/
|
||||
const handleCallApi = async (req: Request, res: Response) => {
|
||||
try {
|
||||
let adapterName = req.params['adapterName'] || req.body.adapterName || DEFAULT_ADAPTER_NAME;
|
||||
|
||||
let adapter = debugAdapterManager.getAdapter(adapterName);
|
||||
|
||||
// 如果是默认 adapter 且不存在,尝试创建
|
||||
if (!adapter && adapterName === DEFAULT_ADAPTER_NAME) {
|
||||
adapter = debugAdapterManager.getOrCreateAdapter();
|
||||
}
|
||||
|
||||
if (!adapter) {
|
||||
return sendError(res, '调试适配器不存在');
|
||||
}
|
||||
|
||||
const { action, params } = req.body;
|
||||
const result = await adapter.callApi(action, params || {});
|
||||
sendSuccess(res, result);
|
||||
} catch (error: any) {
|
||||
sendError(res, error.message);
|
||||
}
|
||||
};
|
||||
|
||||
router.post('/call/:adapterName', handleCallApi);
|
||||
router.post('/call', handleCallApi);
|
||||
|
||||
/**
|
||||
* 关闭调试适配器
|
||||
*/
|
||||
router.post('/close/:adapterName', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { adapterName } = req.params;
|
||||
if (!adapterName) {
|
||||
return sendError(res, '缺少 adapterName 参数');
|
||||
}
|
||||
debugAdapterManager.removeAdapter(adapterName);
|
||||
sendSuccess(res, { message: '调试适配器已关闭' });
|
||||
} catch (error: any) {
|
||||
sendError(res, error.message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* WebSocket 连接处理
|
||||
* 路径: /api/Debug/ws?adapterName=xxx&token=xxx
|
||||
*/
|
||||
export function handleDebugWebSocket (request: IncomingMessage, socket: any, head: any) {
|
||||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
let adapterName = url.searchParams.get('adapterName');
|
||||
const token = url.searchParams.get('token') || url.searchParams.get('access_token');
|
||||
|
||||
// 默认 adapterName
|
||||
if (!adapterName) {
|
||||
adapterName = DEFAULT_ADAPTER_NAME;
|
||||
}
|
||||
|
||||
// Debug session should provide token
|
||||
if (!token) {
|
||||
console.log('[Debug] WebSocket 连接被拒绝: 缺少 Token');
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
let adapter = debugAdapterManager.getAdapter(adapterName);
|
||||
|
||||
// 如果是默认 adapter 且不存在,尝试创建
|
||||
if (!adapter && adapterName === DEFAULT_ADAPTER_NAME) {
|
||||
adapter = debugAdapterManager.getOrCreateAdapter();
|
||||
}
|
||||
|
||||
if (!adapter) {
|
||||
console.log('[Debug] WebSocket 连接被拒绝: 适配器不存在');
|
||||
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!adapter.validateToken(token)) {
|
||||
console.log('[Debug] WebSocket 连接被拒绝: Token 无效');
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建 WebSocket 服务器
|
||||
const wsServer = new WebSocketServer({ noServer: true });
|
||||
|
||||
wsServer.handleUpgrade(request, socket, head, (ws) => {
|
||||
adapter.addWsClient(ws);
|
||||
|
||||
ws.on('message', async (data) => {
|
||||
try {
|
||||
await adapter.handleWsMessage(ws, data as any);
|
||||
} catch (error: any) {
|
||||
console.error('[Debug] handleWsMessage error', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
adapter.removeWsClient(ws);
|
||||
});
|
||||
|
||||
ws.on('error', () => {
|
||||
adapter.removeWsClient(ws);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default router;
|
||||
@ -15,7 +15,6 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
nick: '',
|
||||
},
|
||||
QQVersion: 'unknown',
|
||||
OneBotContext: null,
|
||||
onQQLoginStatusChange: async (status: boolean) => {
|
||||
LoginRuntime.QQLoginStatus = status;
|
||||
},
|
||||
@ -155,12 +154,4 @@ export const WebUiDataRuntime = {
|
||||
runWebUiConfigQuickFunction: async function () {
|
||||
await LoginRuntime.WebUiConfigQuickFunction();
|
||||
},
|
||||
|
||||
setOneBotContext (context: any): void {
|
||||
LoginRuntime.OneBotContext = context;
|
||||
},
|
||||
|
||||
getOneBotContext (): any | null {
|
||||
return LoginRuntime.OneBotContext;
|
||||
},
|
||||
};
|
||||
|
||||
@ -15,7 +15,6 @@ import { BaseRouter } from '@/napcat-webui-backend/src/router/Base';
|
||||
import { FileRouter } from './File';
|
||||
import { WebUIConfigRouter } from './WebUIConfig';
|
||||
import { UpdateNapCatRouter } from './UpdateNapCat';
|
||||
import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -42,7 +41,5 @@ router.use('/File', FileRouter);
|
||||
router.use('/WebUIConfig', WebUIConfigRouter);
|
||||
// router:更新NapCat相关路由
|
||||
router.use('/UpdateNapCat', UpdateNapCatRouter);
|
||||
// router:调试相关路由
|
||||
router.use('/Debug', DebugRouter);
|
||||
|
||||
export { router as ALLRouter };
|
||||
|
||||
@ -47,7 +47,6 @@ export interface LoginRuntimeType {
|
||||
onQQLoginStatusChange: (status: boolean) => Promise<void>;
|
||||
onWebUiTokenChange: (token: string) => Promise<void>;
|
||||
WebUiConfigQuickFunction: () => Promise<void>;
|
||||
OneBotContext: any | null; // OneBot 上下文,用于调试功能
|
||||
NapCatHelper: {
|
||||
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
|
||||
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
||||
|
||||
2
packages/napcat-webui-frontend/.gitignore
vendored
2
packages/napcat-webui-frontend/.gitignore
vendored
@ -26,5 +26,7 @@ dist-ssr
|
||||
# NPM LOCK files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
|
||||
dist.zip
|
||||
@ -22,7 +22,6 @@
|
||||
"@heroui/checkbox": "2.3.9",
|
||||
"@heroui/chip": "2.2.7",
|
||||
"@heroui/code": "2.2.7",
|
||||
"@heroui/divider": "^2.2.21",
|
||||
"@heroui/dropdown": "2.3.10",
|
||||
"@heroui/form": "2.1.9",
|
||||
"@heroui/image": "2.2.6",
|
||||
|
||||
@ -7,6 +7,7 @@ import PageLoading from '@/components/page_loading';
|
||||
import Toaster from '@/components/toaster';
|
||||
|
||||
import DialogProvider from '@/contexts/dialog';
|
||||
import AudioProvider from '@/contexts/songs';
|
||||
|
||||
import useAuth from '@/hooks/auth';
|
||||
|
||||
@ -32,11 +33,13 @@ function App () {
|
||||
<Provider store={store}>
|
||||
<PageBackground />
|
||||
<Toaster />
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<AuthChecker>
|
||||
<AppRoutes />
|
||||
</AuthChecker>
|
||||
</Suspense>
|
||||
<AudioProvider>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<AuthChecker>
|
||||
<AppRoutes />
|
||||
</AuthChecker>
|
||||
</Suspense>
|
||||
</AudioProvider>
|
||||
</Provider>
|
||||
</DialogProvider>
|
||||
);
|
||||
|
||||
425
packages/napcat-webui-frontend/src/components/audio_player.tsx
Normal file
425
packages/napcat-webui-frontend/src/components/audio_player.tsx
Normal file
@ -0,0 +1,425 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Image } from '@heroui/image';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Slider } from '@heroui/slider';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BiSolidSkipNextCircle,
|
||||
BiSolidSkipPreviousCircle,
|
||||
} from 'react-icons/bi';
|
||||
import {
|
||||
FaPause,
|
||||
FaPlay,
|
||||
FaRegHandPointRight,
|
||||
FaRepeat,
|
||||
FaShuffle,
|
||||
} from 'react-icons/fa6';
|
||||
import { TbRepeatOnce } from 'react-icons/tb';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import { PlayMode } from '@/const/enum';
|
||||
import key from '@/const/key';
|
||||
|
||||
import { VolumeHighIcon, VolumeLowIcon } from './icons';
|
||||
|
||||
export interface AudioPlayerProps
|
||||
extends React.AudioHTMLAttributes<HTMLAudioElement> {
|
||||
src: string
|
||||
title?: string
|
||||
artist?: string
|
||||
cover?: string
|
||||
pressNext?: () => void
|
||||
pressPrevious?: () => void
|
||||
onPlayEnd?: () => void
|
||||
onChangeMode?: (mode: PlayMode) => void
|
||||
mode?: PlayMode
|
||||
}
|
||||
|
||||
export default function AudioPlayer (props: AudioPlayerProps) {
|
||||
const {
|
||||
src,
|
||||
pressNext,
|
||||
pressPrevious,
|
||||
cover = 'https://nextui.org/images/album-cover.png',
|
||||
title = '未知',
|
||||
artist = '未知',
|
||||
onTimeUpdate,
|
||||
onLoadedData,
|
||||
onPlay,
|
||||
onPause,
|
||||
onPlayEnd,
|
||||
onChangeMode,
|
||||
autoPlay,
|
||||
mode = PlayMode.Loop,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(100);
|
||||
const [isCollapsed, setIsCollapsed] = useLocalStorage(
|
||||
key.isCollapsedMusicPlayer,
|
||||
false
|
||||
);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const startY = useRef(0);
|
||||
const startX = useRef(0);
|
||||
const [translateY, setTranslateY] = useState(0);
|
||||
const [translateX, setTranslateX] = useState(0);
|
||||
const isSmallScreen = useMediaQuery({ maxWidth: 767 });
|
||||
const isMediumUp = useMediaQuery({ minWidth: 768 });
|
||||
const shouldAdd = useRef(false);
|
||||
const currentProgress = (currentTime / duration) * 100;
|
||||
const [storageAutoPlay, setStorageAutoPlay] = useLocalStorage(
|
||||
key.autoPlay,
|
||||
true
|
||||
);
|
||||
|
||||
const handleTimeUpdate = (event: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
const audio = event.target as HTMLAudioElement;
|
||||
setCurrentTime(audio.currentTime);
|
||||
onTimeUpdate?.(event);
|
||||
};
|
||||
|
||||
const handleLoadedData = (event: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
const audio = event.target as HTMLAudioElement;
|
||||
setDuration(audio.duration);
|
||||
onLoadedData?.(event);
|
||||
};
|
||||
|
||||
const handlePlay = (e: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
setIsPlaying(true);
|
||||
setStorageAutoPlay(true);
|
||||
onPlay?.(e);
|
||||
};
|
||||
|
||||
const handlePause = (e: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||
setIsPlaying(false);
|
||||
onPause?.(e);
|
||||
};
|
||||
|
||||
const changeMode = () => {
|
||||
const modes = [PlayMode.Loop, PlayMode.Random, PlayMode.Single];
|
||||
const currentIndex = modes.findIndex((_mode) => _mode === mode);
|
||||
const nextIndex = currentIndex + 1;
|
||||
const nextMode = modes[nextIndex] || modes[0];
|
||||
onChangeMode?.(nextMode);
|
||||
};
|
||||
|
||||
const volumeChange = (value: number) => {
|
||||
setVolume(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.volume = volume / 100;
|
||||
}
|
||||
}, [volume]);
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
startY.current = e.touches[0].clientY;
|
||||
startX.current = e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
const deltaY = e.touches[0].clientY - startY.current;
|
||||
const deltaX = e.touches[0].clientX - startX.current;
|
||||
const container = cardRef.current;
|
||||
const header = cardRef.current?.querySelector('[data-header]');
|
||||
const headerHeight = header?.clientHeight || 20;
|
||||
const addHeight = (container?.clientHeight || headerHeight) - headerHeight;
|
||||
const _shouldAdd = isCollapsed && deltaY < 0;
|
||||
if (isSmallScreen) {
|
||||
shouldAdd.current = _shouldAdd;
|
||||
setTranslateY(_shouldAdd ? deltaY + addHeight : deltaY);
|
||||
} else {
|
||||
setTranslateX(deltaX);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (isSmallScreen) {
|
||||
const container = cardRef.current;
|
||||
const header = cardRef.current?.querySelector('[data-header]');
|
||||
const headerHeight = header?.clientHeight || 20;
|
||||
const addHeight = (container?.clientHeight || headerHeight) - headerHeight;
|
||||
const _translateY = translateY - (shouldAdd.current ? addHeight : 0);
|
||||
if (_translateY > 100) {
|
||||
setIsCollapsed(true);
|
||||
} else if (_translateY < -100) {
|
||||
setIsCollapsed(false);
|
||||
}
|
||||
setTranslateY(0);
|
||||
} else {
|
||||
if (translateX > 100) {
|
||||
setIsCollapsed(true);
|
||||
} else if (translateX < -100) {
|
||||
setIsCollapsed(false);
|
||||
}
|
||||
setTranslateX(0);
|
||||
}
|
||||
};
|
||||
|
||||
const dragTranslate = isSmallScreen
|
||||
? translateY
|
||||
? `translateY(${translateY}px)`
|
||||
: ''
|
||||
: translateX
|
||||
? `translateX(${translateX}px)`
|
||||
: '';
|
||||
const collapsedTranslate = isCollapsed
|
||||
? isSmallScreen
|
||||
? 'translateY(90%)'
|
||||
: 'translateX(96%)'
|
||||
: '';
|
||||
|
||||
const translateStyle = dragTranslate || collapsedTranslate;
|
||||
|
||||
if (!src) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed right-0 bottom-0 z-[52] w-full md:w-96',
|
||||
!translateX && !translateY && 'transition-transform',
|
||||
isCollapsed && 'md:hover:!translate-x-80'
|
||||
)}
|
||||
style={{
|
||||
transform: translateStyle,
|
||||
}}
|
||||
>
|
||||
<audio
|
||||
src={src}
|
||||
onLoadedData={handleLoadedData}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onEnded={onPlayEnd}
|
||||
autoPlay={autoPlay ?? storageAutoPlay}
|
||||
{...rest}
|
||||
controls={false}
|
||||
hidden
|
||||
ref={audioRef}
|
||||
/>
|
||||
|
||||
<Card
|
||||
ref={cardRef}
|
||||
className={clsx(
|
||||
'border-none bg-background/60 dark:bg-default-300/50 w-full max-w-full transform transition-transform backdrop-blur-md duration-300 overflow-visible',
|
||||
isSmallScreen ? 'rounded-t-3xl' : 'md:rounded-l-xl'
|
||||
)}
|
||||
classNames={{
|
||||
body: 'p-0',
|
||||
}}
|
||||
shadow='sm'
|
||||
radius='none'
|
||||
>
|
||||
{isMediumUp && (
|
||||
<Button
|
||||
isIconOnly
|
||||
className={clsx(
|
||||
'absolute data-[hover]:bg-foreground/10 text-lg z-50',
|
||||
isCollapsed
|
||||
? 'top-0 left-0 w-full h-full rounded-xl bg-opacity-0 hover:bg-opacity-30'
|
||||
: 'top-3 -left-8 rounded-l-full bg-opacity-50 backdrop-blur-md'
|
||||
)}
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='sm'
|
||||
onPress={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<FaRegHandPointRight />
|
||||
</Button>
|
||||
)}
|
||||
{isSmallScreen && (
|
||||
<CardHeader
|
||||
data-header
|
||||
className='flex-row justify-center pt-4'
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<div className='w-24 h-2 rounded-full bg-content2-foreground shadow-sm' />
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardBody>
|
||||
<div className='grid grid-cols-6 md:grid-cols-12 gap-6 md:gap-4 items-center justify-center overflow-hidden p-6 md:p-2 m-0'>
|
||||
<div className='relative col-span-6 md:col-span-4 flex justify-center'>
|
||||
<Image
|
||||
alt='Album cover'
|
||||
className='object-cover'
|
||||
classNames={{
|
||||
wrapper: 'w-36 aspect-square md:w-24 flex',
|
||||
img: 'block w-full h-full',
|
||||
}}
|
||||
shadow='md'
|
||||
src={cover}
|
||||
width='100%'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col col-span-6 md:col-span-8'>
|
||||
<div className='flex flex-col gap-0'>
|
||||
<h1 className='font-medium truncate'>{title}</h1>
|
||||
<p className='text-xs text-foreground/80 truncate'>{artist}</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col'>
|
||||
<Slider
|
||||
aria-label='Music progress'
|
||||
classNames={{
|
||||
track: 'bg-default-500/30 border-none',
|
||||
thumb: 'w-2 h-2 after:w-1.5 after:h-1.5',
|
||||
filler: 'rounded-full',
|
||||
}}
|
||||
color='foreground'
|
||||
value={currentProgress || 0}
|
||||
defaultValue={0}
|
||||
size='sm'
|
||||
onChange={(value) => {
|
||||
value = Array.isArray(value) ? value[0] : value;
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.currentTime = (value / 100) * duration;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className='flex justify-between h-3'>
|
||||
<p className='text-xs'>
|
||||
{Math.floor(currentTime / 60)}:
|
||||
{Math.floor(currentTime % 60)
|
||||
.toString()
|
||||
.padStart(2, '0')}
|
||||
</p>
|
||||
<p className='text-xs text-foreground/50'>
|
||||
{Math.floor(duration / 60)}:
|
||||
{Math.floor(duration % 60)
|
||||
.toString()
|
||||
.padStart(2, '0')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex w-full items-center justify-center'>
|
||||
<Tooltip
|
||||
content={
|
||||
mode === PlayMode.Loop
|
||||
? '列表循环'
|
||||
: mode === PlayMode.Random
|
||||
? '随机播放'
|
||||
: '单曲循环'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='data-[hover]:bg-foreground/10 text-lg md:text-medium'
|
||||
radius='full'
|
||||
variant='light'
|
||||
size='md'
|
||||
onPress={changeMode}
|
||||
>
|
||||
{mode === PlayMode.Loop && (
|
||||
<FaRepeat className='text-foreground/80' />
|
||||
)}
|
||||
{mode === PlayMode.Random && (
|
||||
<FaShuffle className='text-foreground/80' />
|
||||
)}
|
||||
{mode === PlayMode.Single && (
|
||||
<TbRepeatOnce className='text-foreground/80 text-xl' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content='上一首'>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
|
||||
radius='full'
|
||||
variant='light'
|
||||
size='md'
|
||||
onPress={pressPrevious}
|
||||
>
|
||||
<BiSolidSkipPreviousCircle />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={isPlaying ? '暂停' : '播放'}>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='data-[hover]:bg-foreground/10 text-3xl md:text-3xl'
|
||||
radius='full'
|
||||
variant='light'
|
||||
size='lg'
|
||||
onPress={() => {
|
||||
if (isPlaying) {
|
||||
audioRef.current?.pause();
|
||||
setStorageAutoPlay(false);
|
||||
} else {
|
||||
audioRef.current?.play();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPlaying ? <FaPause /> : <FaPlay className='ml-1' />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content='下一首'>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
|
||||
radius='full'
|
||||
variant='light'
|
||||
size='md'
|
||||
onPress={pressNext}
|
||||
>
|
||||
<BiSolidSkipNextCircle />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
placement='top'
|
||||
classNames={{
|
||||
content: 'bg-opacity-30 backdrop-blur-md',
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='data-[hover]:bg-foreground/10 text-xl md:text-xl'
|
||||
radius='full'
|
||||
variant='light'
|
||||
size='md'
|
||||
>
|
||||
<VolumeHighIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Slider
|
||||
orientation='vertical'
|
||||
showTooltip
|
||||
aria-label='Volume'
|
||||
className='h-40'
|
||||
color='primary'
|
||||
defaultValue={volume}
|
||||
onChange={(value) => {
|
||||
value = Array.isArray(value) ? value[0] : value;
|
||||
volumeChange(value);
|
||||
}}
|
||||
startContent={<VolumeHighIcon className='text-2xl' />}
|
||||
size='sm'
|
||||
endContent={<VolumeLowIcon className='text-2xl' />}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -18,7 +18,7 @@ import {
|
||||
} from '../icons';
|
||||
|
||||
export interface AddButtonProps {
|
||||
onOpen: (key: keyof OneBotConfig['network']) => void;
|
||||
onOpen: (key: keyof OneBotConfig['network']) => void
|
||||
}
|
||||
|
||||
const AddButton: React.FC<AddButtonProps> = (props) => {
|
||||
@ -33,7 +33,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
||||
color='primary'
|
||||
startContent={<IoAddCircleOutline className='text-2xl' />}
|
||||
>
|
||||
新建
|
||||
@ -41,7 +41,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
aria-label='Create Network Config'
|
||||
color='default'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onAction={(key) => {
|
||||
onOpen(key as keyof OneBotConfig['network']);
|
||||
|
||||
@ -4,11 +4,11 @@ import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh } from 'react-icons/io';
|
||||
|
||||
export interface SaveButtonsProps {
|
||||
onSubmit: () => void;
|
||||
reset: () => void;
|
||||
refresh?: () => void;
|
||||
isSubmitting: boolean;
|
||||
className?: string;
|
||||
onSubmit: () => void
|
||||
reset: () => void
|
||||
refresh?: () => void
|
||||
isSubmitting: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const SaveButtons: React.FC<SaveButtonsProps> = ({
|
||||
@ -20,15 +20,13 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
|
||||
}) => (
|
||||
<div
|
||||
className={clsx(
|
||||
'w-full flex flex-col justify-center gap-3',
|
||||
'max-w-full mx-3 w-96 flex flex-col justify-center gap-3',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center justify-center gap-2 mt-5'>
|
||||
<Button
|
||||
radius="full"
|
||||
variant="flat"
|
||||
className="font-medium bg-default-100 text-default-600 dark:bg-default-50/50"
|
||||
color='default'
|
||||
onPress={() => {
|
||||
reset();
|
||||
toast.success('重置成功');
|
||||
@ -38,8 +36,6 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
radius="full"
|
||||
className="font-medium shadow-md shadow-primary/20"
|
||||
isLoading={isSubmitting}
|
||||
onPress={() => onSubmit()}
|
||||
>
|
||||
@ -48,12 +44,12 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
|
||||
{refresh && (
|
||||
<Button
|
||||
isIconOnly
|
||||
color='secondary'
|
||||
radius='full'
|
||||
variant='flat'
|
||||
className="text-default-500 bg-default-100 dark:bg-default-50/50"
|
||||
onPress={() => refresh()}
|
||||
>
|
||||
<IoMdRefresh size={20} />
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -10,27 +10,14 @@ import {
|
||||
|
||||
import ChatInput from '.';
|
||||
|
||||
interface ChatInputModalProps {
|
||||
children?: (onOpen: () => void) => React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ChatInputModal ({ children }: ChatInputModalProps) {
|
||||
export default function ChatInputModal () {
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
|
||||
return (
|
||||
<>
|
||||
{children ? children(onOpen) : (
|
||||
<Button
|
||||
onPress={onOpen}
|
||||
color='primary'
|
||||
radius='full'
|
||||
variant='flat'
|
||||
size='sm'
|
||||
className="bg-primary/10 text-primary"
|
||||
>
|
||||
构造消息
|
||||
</Button>
|
||||
)}
|
||||
<Button onPress={onOpen} color='primary' radius='full' variant='flat'>
|
||||
构造聊天消息
|
||||
</Button>
|
||||
<Modal
|
||||
size='4xl'
|
||||
scrollBehavior='inside'
|
||||
|
||||
@ -8,10 +8,19 @@ import monaco from '@/monaco';
|
||||
|
||||
loader.config({
|
||||
monaco,
|
||||
paths: {
|
||||
vs: '/webui/monaco-editor/min/vs',
|
||||
},
|
||||
});
|
||||
|
||||
loader.config({
|
||||
'vs/nls': {
|
||||
availableLanguages: { '*': 'zh-cn' },
|
||||
},
|
||||
});
|
||||
|
||||
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
|
||||
test?: string;
|
||||
test?: string
|
||||
}
|
||||
|
||||
export type CodeEditorRef = monaco.editor.IStandaloneCodeEditor;
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Button, ButtonGroup } from '@heroui/button';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
import { CgDebug } from 'react-icons/cg';
|
||||
import { FiEdit3 } from 'react-icons/fi';
|
||||
@ -11,26 +10,27 @@ import DisplayCardContainer from './container';
|
||||
type NetworkType = OneBotConfig['network'];
|
||||
|
||||
export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{
|
||||
label: string;
|
||||
value: NetworkType[T][0][keyof NetworkType[T][0]];
|
||||
label: string
|
||||
value: NetworkType[T][0][keyof NetworkType[T][0]]
|
||||
render?: (
|
||||
value: NetworkType[T][0][keyof NetworkType[T][0]]
|
||||
) => React.ReactNode;
|
||||
) => React.ReactNode
|
||||
}>;
|
||||
|
||||
export interface NetworkDisplayCardProps<T extends keyof NetworkType> {
|
||||
data: NetworkType[T][0];
|
||||
typeLabel: string;
|
||||
fields: NetworkDisplayCardFields<T>;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
showType?: boolean;
|
||||
data: NetworkType[T][0]
|
||||
showType?: boolean
|
||||
typeLabel: string
|
||||
fields: NetworkDisplayCardFields<T>
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
const NetworkDisplayCard = <T extends keyof NetworkType>({
|
||||
data,
|
||||
showType,
|
||||
typeLabel,
|
||||
fields,
|
||||
onEdit,
|
||||
@ -56,146 +56,79 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
onEnableDebug().finally(() => setEditing(false));
|
||||
};
|
||||
|
||||
const isFullWidthField = (label: string) => ['URL', 'Token', 'AccessToken'].includes(label);
|
||||
|
||||
return (
|
||||
<DisplayCardContainer
|
||||
className="w-full max-w-[420px]"
|
||||
action={
|
||||
<div className="flex gap-2 w-full">
|
||||
<ButtonGroup
|
||||
fullWidth
|
||||
isDisabled={editing}
|
||||
radius='sm'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
>
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className="flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-warning/20 hover:text-warning transition-colors"
|
||||
color='warning'
|
||||
startContent={<FiEdit3 size={16} />}
|
||||
onPress={onEdit}
|
||||
isDisabled={editing}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
color={debug ? 'secondary' : 'success'}
|
||||
variant='flat'
|
||||
className={clsx(
|
||||
"flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors",
|
||||
debug
|
||||
? "hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary"
|
||||
: "hover:bg-success/20 hover:text-success data-[hover=true]:text-success"
|
||||
)}
|
||||
startContent={<CgDebug size={16} />}
|
||||
startContent={
|
||||
<CgDebug
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
minWidth: '16px',
|
||||
minHeight: '16px',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onPress={handleEnableDebug}
|
||||
isDisabled={editing}
|
||||
>
|
||||
{debug ? '关闭调试' : '开启调试'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
className='bg-danger/20 text-danger hover:bg-danger/30 transition-colors'
|
||||
variant='flat'
|
||||
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-danger/20 hover:text-danger transition-colors'
|
||||
startContent={<MdDeleteForever size={16} />}
|
||||
onPress={handleDelete}
|
||||
isDisabled={editing}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</ButtonGroup>
|
||||
}
|
||||
enableSwitch={
|
||||
<Switch
|
||||
isDisabled={editing}
|
||||
isSelected={enable}
|
||||
onChange={handleEnable}
|
||||
classNames={{
|
||||
wrapper: "group-data-[selected=true]:bg-primary-400",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={typeLabel}
|
||||
tag={showType && typeLabel}
|
||||
title={name}
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
{(() => {
|
||||
const targetFullField = fields.find(f => isFullWidthField(f.label));
|
||||
|
||||
if (targetFullField) {
|
||||
// 模式1:存在全宽字段(如URL),布局为:
|
||||
// Row 1: 名称 (全宽)
|
||||
// Row 2: 全宽字段 (全宽)
|
||||
return (
|
||||
<>
|
||||
<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 col-span-2'
|
||||
>
|
||||
<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">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
|
||||
>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{targetFullField.label}</span>
|
||||
<div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
|
||||
{targetFullField.render
|
||||
? targetFullField.render(targetFullField.value)
|
||||
: (
|
||||
<span className={clsx(
|
||||
typeof targetFullField.value === 'string' && (targetFullField.value.startsWith('http') || targetFullField.value.includes('.') || targetFullField.value.includes(':')) ? 'font-mono' : ''
|
||||
)}>
|
||||
{String(targetFullField.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
// 模式2:无全宽字段,布局为 4 个小块 (2行 x 2列)
|
||||
// Row 1: 名称 | Field 0
|
||||
// Row 2: Field 1 | Field 2
|
||||
const displayFields = fields.slice(0, 3);
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
{displayFields.map((field, index) => (
|
||||
<div
|
||||
key={index}
|
||||
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'>{field.label}</span>
|
||||
<div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
|
||||
{field.render
|
||||
? (
|
||||
field.render(field.value)
|
||||
)
|
||||
: (
|
||||
<span className={clsx(
|
||||
typeof field.value === 'string' && (field.value.startsWith('http') || field.value.includes('.') || field.value.includes(':')) ? 'font-mono' : ''
|
||||
)}>
|
||||
{String(field.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* 如果字段不足3个,可以补充空白块占位吗?或者是让它空着?用户说要高度一致。只要是grid,通常高度会被撑开。目前这样应该能保证最多2行。 */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
<div className='grid grid-cols-2 gap-1'>
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center gap-2 ${
|
||||
field.label === 'URL' ? 'col-span-2' : ''
|
||||
}`}
|
||||
>
|
||||
<span className='text-default-400'>{field.label}</span>
|
||||
{field.render
|
||||
? (
|
||||
field.render(field.value)
|
||||
)
|
||||
: (
|
||||
<span>{field.value}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DisplayCardContainer>
|
||||
);
|
||||
|
||||
@ -1,24 +1,22 @@
|
||||
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import key from '@/const/key';
|
||||
|
||||
import { title } from '../primitives';
|
||||
|
||||
export interface ContainerProps {
|
||||
title: string;
|
||||
tag?: React.ReactNode;
|
||||
action: React.ReactNode;
|
||||
enableSwitch: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string; // Add className prop
|
||||
title: string
|
||||
tag?: React.ReactNode
|
||||
action: React.ReactNode
|
||||
enableSwitch: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export interface DisplayCardProps {
|
||||
showType?: boolean;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const DisplayCardContainer: React.FC<ContainerProps> = ({
|
||||
@ -27,35 +25,31 @@ const DisplayCardContainer: React.FC<ContainerProps> = ({
|
||||
tag,
|
||||
enableSwitch,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
return (
|
||||
<Card className={clsx(
|
||||
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm rounded-2xl overflow-hidden transition-all',
|
||||
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CardHeader className='p-4 pb-2 flex items-center justify-between gap-3'>
|
||||
<Card className='bg-opacity-50 backdrop-blur-sm'>
|
||||
<CardHeader className='pb-0 flex items-center'>
|
||||
{tag && (
|
||||
<div className='text-center text-default-500 font-medium mb-1 absolute top-0 left-1/2 -translate-x-1/2 text-xs pointer-events-none bg-default-200/50 dark:bg-default-100/50 backdrop-blur-sm px-3 py-0.5 rounded-b-lg shadow-sm z-10'>
|
||||
<div className='text-center text-default-400 mb-1 absolute top-0 left-1/2 -translate-x-1/2 text-sm pointer-events-none bg-warning-100 dark:bg-warning-50 px-2 rounded-b'>
|
||||
{tag}
|
||||
</div>
|
||||
)}
|
||||
<div className='flex-1 min-w-0 mr-2'>
|
||||
<div className='inline-flex items-center px-3 py-1 rounded-lg bg-default-100/50 dark:bg-white/10 border border-transparent dark:border-white/5'>
|
||||
<span className='font-bold text-default-600 dark:text-white/90 text-sm truncate select-text'>
|
||||
{_title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>{enableSwitch}</div>
|
||||
<h2
|
||||
className={clsx(
|
||||
title({
|
||||
color: 'foreground',
|
||||
size: 'xs',
|
||||
shadow: true,
|
||||
}),
|
||||
'truncate'
|
||||
)}
|
||||
>
|
||||
{_title}
|
||||
</h2>
|
||||
<div className='ml-auto'>{enableSwitch}</div>
|
||||
</CardHeader>
|
||||
<CardBody className='px-4 py-2 text-sm text-default-600'>{children}</CardBody>
|
||||
<CardFooter className='px-4 pb-4 pt-2'>{action}</CardFooter>
|
||||
<CardBody className='text-sm'>{children}</CardBody>
|
||||
<CardFooter>{action}</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
|
||||
import type { NetworkDisplayCardFields } from './common_card';
|
||||
|
||||
interface HTTPClientDisplayCardProps {
|
||||
data: OneBotConfig['network']['httpClients'][0];
|
||||
showType?: boolean;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
data: OneBotConfig['network']['httpClients'][0]
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const HTTPClientDisplayCard: React.FC<HTTPClientDisplayCardProps> = (props) => {
|
||||
|
||||
@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
|
||||
import type { NetworkDisplayCardFields } from './common_card';
|
||||
|
||||
interface HTTPServerDisplayCardProps {
|
||||
data: OneBotConfig['network']['httpServers'][0];
|
||||
showType?: boolean;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
data: OneBotConfig['network']['httpServers'][0]
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const HTTPServerDisplayCard: React.FC<HTTPServerDisplayCardProps> = (props) => {
|
||||
|
||||
@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
|
||||
import type { NetworkDisplayCardFields } from './common_card';
|
||||
|
||||
interface HTTPSSEServerDisplayCardProps {
|
||||
data: OneBotConfig['network']['httpSseServers'][0];
|
||||
showType?: boolean;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
data: OneBotConfig['network']['httpSseServers'][0]
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const HTTPSSEServerDisplayCard: React.FC<HTTPSSEServerDisplayCardProps> = (
|
||||
|
||||
@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
|
||||
import type { NetworkDisplayCardFields } from './common_card';
|
||||
|
||||
interface WebsocketClientDisplayCardProps {
|
||||
data: OneBotConfig['network']['websocketClients'][0];
|
||||
showType?: boolean;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
data: OneBotConfig['network']['websocketClients'][0]
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = (
|
||||
|
||||
@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
|
||||
import type { NetworkDisplayCardFields } from './common_card';
|
||||
|
||||
interface WebsocketServerDisplayCardProps {
|
||||
data: OneBotConfig['network']['websocketServers'][0];
|
||||
showType?: boolean;
|
||||
onEdit: () => void;
|
||||
onEnable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
data: OneBotConfig['network']['websocketServers'][0]
|
||||
showType?: boolean
|
||||
onEdit: () => void
|
||||
onEnable: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onEnableDebug: () => Promise<void>
|
||||
}
|
||||
|
||||
const WebsocketServerDisplayCard: React.FC<WebsocketServerDisplayCardProps> = (
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import key from '@/const/key';
|
||||
|
||||
|
||||
import { title } from '@/components/primitives';
|
||||
|
||||
export interface NetworkItemDisplayProps {
|
||||
count: number;
|
||||
label: string;
|
||||
size?: 'sm' | 'md';
|
||||
count: number
|
||||
label: string
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
|
||||
@ -16,37 +14,38 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
|
||||
label,
|
||||
size = 'md',
|
||||
}) => {
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={clsx(
|
||||
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all',
|
||||
hasBackground
|
||||
? 'bg-white/10 dark:bg-black/10 hover:bg-white/20 dark:hover:bg-black/20'
|
||||
: 'bg-white/60 dark:bg-black/40 hover:bg-white/70 dark:hover:bg-black/30',
|
||||
'bg-opacity-60 shadow-sm md:rounded-3xl',
|
||||
size === 'md'
|
||||
? 'col-span-8 md:col-span-2'
|
||||
: 'col-span-2 md:col-span-1'
|
||||
? 'col-span-8 md:col-span-2 bg-primary-50 shadow-primary-100'
|
||||
: 'col-span-2 md:col-span-1 bg-warning-100 shadow-warning-200'
|
||||
)}
|
||||
shadow='none'
|
||||
shadow='sm'
|
||||
>
|
||||
<CardBody className='items-center md:gap-1 p-1 md:p-2'>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex-1 font-mono font-bold',
|
||||
size === 'md' ? 'text-4xl md:text-5xl' : 'text-2xl md:text-3xl',
|
||||
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
|
||||
'flex-1',
|
||||
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
|
||||
title({
|
||||
color: size === 'md' ? 'pink' : 'yellow',
|
||||
size,
|
||||
})
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'whitespace-nowrap text-nowrap flex-shrink-0 font-medium',
|
||||
size === 'md' ? 'text-sm' : 'text-xs',
|
||||
hasBackground ? 'text-white/80' : 'text-default-500'
|
||||
'whitespace-nowrap text-nowrap flex-shrink-0',
|
||||
size === 'md' ? 'text-sm md:text-base' : 'text-xs md:text-sm',
|
||||
title({
|
||||
color: size === 'md' ? 'pink' : 'yellow',
|
||||
shadow: true,
|
||||
size: 'xxs',
|
||||
})
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
|
||||
@ -94,7 +94,7 @@ const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
|
||||
ref={lightRef}
|
||||
className={clsx(
|
||||
isShowLight ? 'opacity-100' : 'opacity-0',
|
||||
'absolute rounded-full blur-[100px] filter transition-opacity duration-300 bg-gradient-to-r from-primary-400 to-secondary-400 w-[150px] h-[150px]',
|
||||
'absolute rounded-full blur-[150px] filter transition-opacity duration-300 dark:bg-[#2850ff] bg-[#ff4132] w-[100px] h-[100px]',
|
||||
lightClassName
|
||||
)}
|
||||
style={{
|
||||
|
||||
@ -25,21 +25,21 @@ import { supportedPreviewExts } from './file_preview_modal';
|
||||
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button';
|
||||
|
||||
export interface FileTableProps {
|
||||
files: FileInfo[];
|
||||
currentPath: string;
|
||||
loading: boolean;
|
||||
sortDescriptor: SortDescriptor;
|
||||
onSortChange: (descriptor: SortDescriptor) => void;
|
||||
selectedFiles: Selection;
|
||||
onSelectionChange: (selected: Selection) => void;
|
||||
onDirectoryClick: (dirPath: string) => void;
|
||||
onEdit: (filePath: string) => void;
|
||||
onPreview: (filePath: string) => void;
|
||||
onRenameRequest: (name: string) => void;
|
||||
onMoveRequest: (name: string) => void;
|
||||
onCopyPath: (fileName: string) => void;
|
||||
onDelete: (filePath: string) => void;
|
||||
onDownload: (filePath: string) => void;
|
||||
files: FileInfo[]
|
||||
currentPath: string
|
||||
loading: boolean
|
||||
sortDescriptor: SortDescriptor
|
||||
onSortChange: (descriptor: SortDescriptor) => void
|
||||
selectedFiles: Selection
|
||||
onSelectionChange: (selected: Selection) => void
|
||||
onDirectoryClick: (dirPath: string) => void
|
||||
onEdit: (filePath: string) => void
|
||||
onPreview: (filePath: string) => void
|
||||
onRenameRequest: (name: string) => void
|
||||
onMoveRequest: (name: string) => void
|
||||
onCopyPath: (fileName: string) => void
|
||||
onDelete: (filePath: string) => void
|
||||
onDownload: (filePath: string) => void
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@ -112,7 +112,7 @@ export default function FileTable ({
|
||||
selectedKeys={selectedFiles}
|
||||
selectionMode='multiple'
|
||||
bottomContent={
|
||||
<div className='flex w-full justify-center p-2 border-t border-white/10'>
|
||||
<div className='flex w-full justify-center'>
|
||||
<Pagination
|
||||
isCompact
|
||||
showControls
|
||||
@ -121,29 +121,21 @@ export default function FileTable ({
|
||||
page={page}
|
||||
total={pages}
|
||||
onChange={(page) => setPage(page)}
|
||||
classNames={{
|
||||
cursor: 'bg-primary shadow-lg',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
classNames={{
|
||||
wrapper: 'bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm p-0',
|
||||
th: 'bg-white/40 dark:bg-white/5 backdrop-blur-md text-default-600',
|
||||
td: 'group-data-[first=true]:first:before:rounded-none group-data-[first=true]:last:before:rounded-none',
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn key='name' allowsSorting>
|
||||
名称
|
||||
</TableColumn>
|
||||
<TableColumn key='type' allowsSorting className='hidden md:table-cell'>
|
||||
<TableColumn key='type' allowsSorting>
|
||||
类型
|
||||
</TableColumn>
|
||||
<TableColumn key='size' allowsSorting className='hidden md:table-cell'>
|
||||
<TableColumn key='size' allowsSorting>
|
||||
大小
|
||||
</TableColumn>
|
||||
<TableColumn key='mtime' allowsSorting className='hidden md:table-cell'>
|
||||
<TableColumn key='mtime' allowsSorting>
|
||||
修改时间
|
||||
</TableColumn>
|
||||
<TableColumn key='actions'>操作</TableColumn>
|
||||
@ -188,57 +180,57 @@ export default function FileTable ({
|
||||
name={file.name}
|
||||
isDirectory={file.isDirectory}
|
||||
/>
|
||||
}
|
||||
}
|
||||
>
|
||||
{file.name}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>{file.isDirectory ? '目录' : '文件'}</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>
|
||||
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
|
||||
<TableCell>
|
||||
{isNaN(file.size) || file.isDirectory
|
||||
? '-'
|
||||
: `${file.size} 字节`}
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>{new Date(file.mtime).toLocaleString()}</TableCell>
|
||||
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<ButtonGroup size='sm' variant='light'>
|
||||
<ButtonGroup size='sm'>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='default'
|
||||
className='text-default-500 hover:text-primary'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onPress={() => onRenameRequest(file.name)}
|
||||
>
|
||||
<BiRename />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='default'
|
||||
className='text-default-500 hover:text-primary'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onPress={() => onMoveRequest(file.name)}
|
||||
>
|
||||
<FiMove />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='default'
|
||||
className='text-default-500 hover:text-primary'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onPress={() => onCopyPath(file.name)}
|
||||
>
|
||||
<FiCopy />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='default'
|
||||
className='text-default-500 hover:text-primary'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onPress={() => onDownload(filePath)}
|
||||
>
|
||||
<FiDownload />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='danger'
|
||||
className='text-danger hover:bg-danger/10'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onPress={() => onDelete(filePath)}
|
||||
>
|
||||
<FiTrash2 />
|
||||
|
||||
@ -1,13 +1,9 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useRequest } from 'ahooks';
|
||||
import clsx from 'clsx';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdQuote } from 'react-icons/io';
|
||||
import { IoCopy, IoRefresh } from 'react-icons/io5';
|
||||
|
||||
import key from '@/const/key';
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
import PageLoading from './page_loading';
|
||||
@ -22,15 +18,7 @@ export default function Hitokoto () {
|
||||
pollingInterval: 10000,
|
||||
throttleWait: 1000,
|
||||
});
|
||||
const backupData = {
|
||||
hitokoto: '凡是过往,皆为序章。',
|
||||
from: '暴风雨',
|
||||
from_who: '莎士比亚',
|
||||
};
|
||||
const data = dataOri?.data || (error ? backupData : undefined);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const data = dataOri?.data;
|
||||
const onCopy = () => {
|
||||
try {
|
||||
const text = `${data?.hitokoto} —— ${data?.from} ${data?.from_who}`;
|
||||
@ -42,61 +30,44 @@ export default function Hitokoto () {
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className='relative flex flex-col items-center justify-center p-6 min-h-[120px]'>
|
||||
{loading && !data && <PageLoading />}
|
||||
{data && (
|
||||
<>
|
||||
<IoMdQuote className={clsx(
|
||||
"text-4xl mb-4",
|
||||
hasBackground ? "text-white/30" : "text-primary/20"
|
||||
)} />
|
||||
<div className={clsx(
|
||||
"text-xl font-medium tracking-wide leading-relaxed italic",
|
||||
hasBackground ? "text-white drop-shadow-sm" : "text-default-700 dark:text-gray-200"
|
||||
)}>
|
||||
" {data?.hitokoto} "
|
||||
</div>
|
||||
<div className='mt-4 flex flex-col items-center text-sm'>
|
||||
<span className={clsx(
|
||||
'font-bold',
|
||||
hasBackground ? 'text-white/90' : 'text-primary-500/80'
|
||||
)}>—— {data?.from}</span>
|
||||
{data?.from_who && <span className={clsx(
|
||||
"text-xs mt-1",
|
||||
hasBackground ? "text-white/70" : "text-default-400"
|
||||
)}>{data?.from_who}</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className='relative'>
|
||||
{loading && <PageLoading />}
|
||||
{error
|
||||
? (
|
||||
<div className='text-primary-400'>一言加载失败:{error.message}</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div>{data?.hitokoto}</div>
|
||||
<div className='text-right'>
|
||||
—— <span className='text-default-400'>{data?.from}</span>{' '}
|
||||
{data?.from_who}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Tooltip content='刷新' placement='top'>
|
||||
<Button
|
||||
className={clsx(
|
||||
"transition-colors",
|
||||
hasBackground ? "text-white/60 hover:text-white" : "text-default-400 hover:text-primary"
|
||||
)}
|
||||
onPress={run}
|
||||
size='sm'
|
||||
isLoading={loading}
|
||||
isIconOnly
|
||||
radius='full'
|
||||
variant='light'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
>
|
||||
<IoRefresh />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content='复制' placement='top'>
|
||||
<Button
|
||||
className={clsx(
|
||||
"transition-colors",
|
||||
hasBackground ? "text-white/60 hover:text-white" : "text-default-400 hover:text-success"
|
||||
)}
|
||||
onPress={onCopy}
|
||||
size='sm'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
variant='light'
|
||||
color='success'
|
||||
variant='flat'
|
||||
>
|
||||
<IoCopy />
|
||||
</Button>
|
||||
|
||||
@ -7,7 +7,6 @@ export interface FileInputProps {
|
||||
onDelete?: () => Promise<void> | void;
|
||||
label?: string;
|
||||
accept?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const FileInput: React.FC<FileInputProps> = ({
|
||||
@ -15,7 +14,6 @@ const FileInput: React.FC<FileInputProps> = ({
|
||||
onDelete,
|
||||
label,
|
||||
accept,
|
||||
placeholder,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@ -27,13 +25,8 @@ const FileInput: React.FC<FileInputProps> = ({
|
||||
ref={inputRef}
|
||||
label={label}
|
||||
type='file'
|
||||
placeholder={placeholder || '选择文件'}
|
||||
placeholder='选择文件'
|
||||
accept={accept}
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
|
||||
input: 'bg-transparent text-default-700 placeholder:text-default-400',
|
||||
}}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
@ -4,9 +4,9 @@ import { Input } from '@heroui/input';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export interface ImageInputProps {
|
||||
onChange: (base64: string) => void;
|
||||
value: string;
|
||||
label?: string;
|
||||
onChange: (base64: string) => void
|
||||
value: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
|
||||
@ -26,11 +26,6 @@ const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
|
||||
type='file'
|
||||
placeholder='选择图片'
|
||||
accept='image/*'
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
|
||||
input: 'bg-transparent text-default-700 placeholder:text-default-400',
|
||||
}}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
|
||||
@ -2,11 +2,8 @@ import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import type { Selection } from '@react-types/shared';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import key from '@/const/key';
|
||||
import { colorizeLogLevel } from '@/utils/terminal';
|
||||
|
||||
import PageLoading from '../page_loading';
|
||||
@ -15,15 +12,15 @@ import type { XTermRef } from '../xterm';
|
||||
import LogLevelSelect from './log_level_select';
|
||||
|
||||
export interface HistoryLogsProps {
|
||||
list: string[];
|
||||
onSelect: (name: string) => void;
|
||||
selectedLog?: string;
|
||||
refreshList: () => void;
|
||||
refreshLog: () => void;
|
||||
listLoading?: boolean;
|
||||
logLoading?: boolean;
|
||||
listError?: Error;
|
||||
logContent?: string;
|
||||
list: string[]
|
||||
onSelect: (name: string) => void
|
||||
selectedLog?: string
|
||||
refreshList: () => void
|
||||
refreshLog: () => void
|
||||
listLoading?: boolean
|
||||
logLoading?: boolean
|
||||
listError?: Error
|
||||
logContent?: string
|
||||
}
|
||||
const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
|
||||
const {
|
||||
@ -42,8 +39,6 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
|
||||
const [logLevel, setLogLevel] = useState<Selection>(
|
||||
new Set(['info', 'warn', 'error'])
|
||||
);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const logToColored = (log: string) => {
|
||||
const logs = log
|
||||
@ -88,10 +83,7 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<title>历史日志 - NapCat WebUI</title>
|
||||
<Card className={clsx(
|
||||
'max-w-full h-full backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm',
|
||||
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
|
||||
)}>
|
||||
<Card className='max-w-full h-full bg-opacity-50 backdrop-blur-sm'>
|
||||
<CardHeader className='flex-row justify-start gap-3'>
|
||||
<Select
|
||||
label='选择日志'
|
||||
@ -100,7 +92,7 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
|
||||
errorMessage={listError?.message}
|
||||
classNames={{
|
||||
trigger:
|
||||
'bg-default-100/50 backdrop-blur-sm hover:!bg-default-200/50',
|
||||
'hover:!bg-content3 bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60',
|
||||
}}
|
||||
placeholder='选择日志'
|
||||
onChange={(e) => {
|
||||
@ -126,13 +118,11 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
|
||||
selectedKeys={logLevel}
|
||||
onSelectionChange={setLogLevel}
|
||||
/>
|
||||
<div className='flex gap-2 ml-auto'>
|
||||
<Button className='flex-shrink-0' onPress={onDownloadLog} size='sm' variant='flat' color='primary'>
|
||||
下载日志
|
||||
</Button>
|
||||
<Button onPress={refreshList} size='sm' variant='flat'>刷新列表</Button>
|
||||
<Button onPress={refreshLog} size='sm' variant='flat'>刷新日志</Button>
|
||||
</div>
|
||||
<Button className='flex-shrink-0' onPress={onDownloadLog}>
|
||||
下载日志
|
||||
</Button>
|
||||
<Button onPress={refreshList}>刷新列表</Button>
|
||||
<Button onPress={refreshLog}>刷新日志</Button>
|
||||
</CardHeader>
|
||||
<CardBody className='relative'>
|
||||
<PageLoading loading={logLoading} />
|
||||
|
||||
@ -6,17 +6,17 @@ import type { Selection } from '@react-types/shared';
|
||||
import { LogLevel } from '@/const/enum';
|
||||
|
||||
export interface LogLevelSelectProps {
|
||||
selectedKeys: Selection;
|
||||
onSelectionChange: (keys: SharedSelection) => void;
|
||||
selectedKeys: Selection
|
||||
onSelectionChange: (keys: SharedSelection) => void
|
||||
}
|
||||
const logLevelColor: {
|
||||
[key in LogLevel]:
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'primary'
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'primary'
|
||||
} = {
|
||||
[LogLevel.DEBUG]: 'default',
|
||||
[LogLevel.INFO]: 'primary',
|
||||
@ -40,7 +40,7 @@ const LogLevelSelect = (props: LogLevelSelectProps) => {
|
||||
aria-label='Log Level'
|
||||
classNames={{
|
||||
label: 'mb-2',
|
||||
trigger: 'bg-default-100/50 backdrop-blur-sm hover:!bg-default-200/50',
|
||||
trigger: 'bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60',
|
||||
popoverContent: 'bg-opacity-50 backdrop-blur-sm',
|
||||
}}
|
||||
size='sm'
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import type { Selection } from '@react-types/shared';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoDownloadOutline } from 'react-icons/io5';
|
||||
|
||||
import key from '@/const/key';
|
||||
import { colorizeLogLevelWithTag } from '@/utils/terminal';
|
||||
|
||||
import WebUIManager, { Log } from '@/controllers/webui_manager';
|
||||
@ -21,8 +18,6 @@ const RealTimeLogs = () => {
|
||||
new Set(['info', 'warn', 'error'])
|
||||
);
|
||||
const [dataArr, setDataArr] = useState<Log[]>([]);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const onDownloadLog = () => {
|
||||
const logContent = dataArr
|
||||
@ -96,10 +91,7 @@ const RealTimeLogs = () => {
|
||||
return (
|
||||
<>
|
||||
<title>实时日志 - NapCat WebUI</title>
|
||||
<div className={clsx(
|
||||
'flex items-center gap-2 p-2 rounded-2xl border backdrop-blur-sm transition-all shadow-sm mb-4',
|
||||
hasBackground ? 'bg-white/20 dark:bg-black/10 border-white/40 dark:border-white/10' : 'bg-white/60 dark:bg-black/40 border-white/40 dark:border-white/10'
|
||||
)}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<LogLevelSelect
|
||||
selectedKeys={logLevel}
|
||||
onSelectionChange={setLogLevel}
|
||||
@ -108,8 +100,6 @@ const RealTimeLogs = () => {
|
||||
className='flex-shrink-0'
|
||||
onPress={onDownloadLog}
|
||||
startContent={<IoDownloadOutline className='text-lg' />}
|
||||
color='primary'
|
||||
variant='flat'
|
||||
>
|
||||
下载日志
|
||||
</Button>
|
||||
|
||||
@ -109,11 +109,6 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
|
||||
isDisabled={field.isDisabled}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
|
||||
input: 'bg-transparent text-default-700 placeholder:text-default-400',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'select':
|
||||
@ -126,10 +121,6 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
|
||||
placeholder={field.placeholder}
|
||||
selectedKeys={[controllerField.value as string]}
|
||||
value={controllerField.value.toString()}
|
||||
classNames={{
|
||||
trigger: 'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
|
||||
value: 'text-default-700',
|
||||
}}
|
||||
>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option.key} value={option.value}>
|
||||
|
||||
@ -1,15 +1,13 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Snippet } from '@heroui/snippet';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoChevronDown, IoSend, IoSettingsSharp, IoCopy } from 'react-icons/io5';
|
||||
import { TbCode, TbMessageCode } from 'react-icons/tb';
|
||||
import { IoLink, IoSend } from 'react-icons/io5';
|
||||
import { PiCatDuotone } from 'react-icons/pi';
|
||||
|
||||
import key from '@/const/key';
|
||||
import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api';
|
||||
@ -19,7 +17,7 @@ import CodeEditor from '@/components/code_editor';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
import { parseAxiosResponse } from '@/utils/url';
|
||||
import { generateDefaultJson, parse } from '@/utils/zod';
|
||||
|
||||
import DisplayStruct from './display_struct';
|
||||
@ -27,11 +25,10 @@ import DisplayStruct from './display_struct';
|
||||
export interface OneBotApiDebugProps {
|
||||
path: OneBotHttpApiPath;
|
||||
data: OneBotHttpApiContent;
|
||||
adapterName?: string;
|
||||
}
|
||||
|
||||
const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
const { path, data, adapterName } = props;
|
||||
const { path, data } = props;
|
||||
const currentURL = new URL(window.location.origin);
|
||||
currentURL.port = '3000';
|
||||
const defaultHttpUrl = currentURL.href;
|
||||
@ -39,61 +36,21 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
url: defaultHttpUrl,
|
||||
token: '',
|
||||
});
|
||||
|
||||
const [requestBody, setRequestBody] = useState('{}');
|
||||
const [responseContent, setResponseContent] = useState('');
|
||||
const [isCodeEditorOpen, setIsCodeEditorOpen] = useState(false);
|
||||
const [isResponseOpen, setIsResponseOpen] = useState(false);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<any>('request');
|
||||
const [responseExpanded, setResponseExpanded] = useState(true);
|
||||
const [responseStatus, setResponseStatus] = useState<{ code: number; text: string; } | null>(null);
|
||||
const [responseHeight, setResponseHeight] = useLocalStorage('napcat_debug_response_height', 240); // 默认高度
|
||||
|
||||
const responseRef = useRef<HTMLDivElement>(null);
|
||||
const parsedRequest = parse(data.request);
|
||||
const parsedResponse = parse(data.response);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const sendRequest = async () => {
|
||||
if (isFetching) return;
|
||||
setIsFetching(true);
|
||||
setResponseStatus(null);
|
||||
const r = toast.loading('正在发送请求...');
|
||||
|
||||
try {
|
||||
const parsedRequestBody = JSON.parse(requestBody);
|
||||
|
||||
// 如果有 adapterName,走后端转发
|
||||
if (adapterName) {
|
||||
request.post(`/api/Debug/call/${adapterName}`, {
|
||||
action: path.replace(/^\//, ''), // 去掉开头的 /
|
||||
params: parsedRequestBody
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
}).then((res) => {
|
||||
if (res.data.code === 0) {
|
||||
setResponseContent(JSON.stringify(res.data.data, null, 2));
|
||||
setResponseStatus({ code: 200, text: 'OK' });
|
||||
} else {
|
||||
setResponseContent(JSON.stringify(res.data, null, 2));
|
||||
setResponseStatus({ code: 500, text: res.data.message });
|
||||
}
|
||||
setResponseExpanded(true);
|
||||
toast.success('请求成功');
|
||||
}).catch((err) => {
|
||||
toast.error('请求失败:' + err.message);
|
||||
setResponseContent(JSON.stringify({ error: err.message }, null, 2));
|
||||
setResponseStatus({ code: 500, text: 'Error' });
|
||||
setResponseExpanded(true);
|
||||
}).finally(() => {
|
||||
setIsFetching(false);
|
||||
toast.dismiss(r);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 回退到旧逻辑 (直接请求)
|
||||
const requestURL = new URL(httpConfig.url);
|
||||
requestURL.pathname = path;
|
||||
request
|
||||
@ -101,23 +58,23 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
headers: {
|
||||
Authorization: `Bearer ${httpConfig.token}`,
|
||||
},
|
||||
}) // 移除 responseType: 'text',以便 axios 自动解析 JSON
|
||||
responseType: 'text',
|
||||
})
|
||||
.then((res) => {
|
||||
setResponseContent(JSON.stringify(res.data, null, 2));
|
||||
setResponseStatus({ code: res.status, text: res.statusText });
|
||||
setResponseExpanded(true);
|
||||
toast.success('请求成功');
|
||||
setResponseContent(parseAxiosResponse(res));
|
||||
toast.success('请求发送完成,请查看响应');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('请求失败:' + err.message);
|
||||
setResponseContent(JSON.stringify(err.response?.data || { error: err.message }, null, 2));
|
||||
if (err.response) {
|
||||
setResponseStatus({ code: err.response.status, text: err.response.statusText });
|
||||
}
|
||||
setResponseExpanded(true);
|
||||
toast.error('请求发送失败:' + err.message);
|
||||
setResponseContent(parseAxiosResponse(err.response));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetching(false);
|
||||
setIsResponseOpen(true);
|
||||
responseRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
toast.dismiss(r);
|
||||
});
|
||||
} catch (_error) {
|
||||
@ -130,248 +87,150 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
useEffect(() => {
|
||||
setRequestBody(generateDefaultJson(data.request));
|
||||
setResponseContent('');
|
||||
setResponseStatus(null);
|
||||
}, [path]);
|
||||
|
||||
// Height Resizing Logic
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startHeight = responseHeight;
|
||||
|
||||
const handleMouseMove = (mv: MouseEvent) => {
|
||||
const delta = startY - mv.clientY;
|
||||
// 向上拖动 -> 增加高度
|
||||
setResponseHeight(Math.max(100, Math.min(window.innerHeight - 200, startHeight + delta)));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [responseHeight, setResponseHeight]);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
// 阻止默认滚动行为可能需要谨慎,这里尽量只阻止 handle 上的
|
||||
// e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
const startY = touch.clientY;
|
||||
const startHeight = responseHeight;
|
||||
|
||||
const handleTouchMove = (mv: TouchEvent) => {
|
||||
const mvTouch = mv.touches[0];
|
||||
const delta = startY - mvTouch.clientY;
|
||||
setResponseHeight(Math.max(100, Math.min(window.innerHeight - 200, startHeight + delta)));
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', handleTouchMove);
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
}, [responseHeight, setResponseHeight]);
|
||||
|
||||
|
||||
return (
|
||||
<section className='h-full flex flex-col overflow-hidden bg-transparent'>
|
||||
{/* URL Bar */}
|
||||
<div className='flex flex-wrap md:flex-nowrap items-center gap-2 p-2 md:p-4 pb-2 flex-shrink-0'>
|
||||
<div className={clsx(
|
||||
'flex-grow flex items-center gap-2 px-3 md:px-4 h-10 rounded-xl transition-all w-full md:w-auto',
|
||||
hasBackground ? 'bg-white/5' : 'bg-black/5 dark:bg-white/5'
|
||||
)}>
|
||||
<Chip size="sm" variant="shadow" color="primary" className="font-bold text-[10px] h-5 min-w-[40px]">POST</Chip>
|
||||
<span className={clsx(
|
||||
'text-xs font-mono truncate select-all flex-1 opacity-50',
|
||||
hasBackground ? 'text-white' : 'text-default-600'
|
||||
)}>{path}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2 flex-shrink-0 ml-auto'>
|
||||
<Popover placement='bottom-end' backdrop='blur'>
|
||||
<PopoverTrigger>
|
||||
<Button size='sm' variant='light' radius='full' isIconOnly className='h-10 w-10 opacity-40 hover:opacity-100'>
|
||||
<IoSettingsSharp className="text-lg" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[260px] p-3 rounded-xl border border-white/10 shadow-2xl bg-white/80 dark:bg-black/80 backdrop-blur-xl'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='text-[10px] font-bold opacity-30 uppercase tracking-widest'>Debug Setup</p>
|
||||
<Input label='Base URL' value={httpConfig.url} onChange={(e) => setHttpConfig({ ...httpConfig, url: e.target.value })} size='sm' variant='flat' />
|
||||
<Input label='Token' value={httpConfig.token} onChange={(e) => setHttpConfig({ ...httpConfig, token: e.target.value })} size='sm' variant='flat' />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
onPress={sendRequest}
|
||||
color='primary'
|
||||
radius='full'
|
||||
size='sm'
|
||||
className='h-10 px-6 font-bold shadow-md shadow-primary/20 hover:scale-[1.02] active:scale-[0.98]'
|
||||
isLoading={isFetching}
|
||||
startContent={!isFetching && <IoSend className="text-xs" />}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
<section className='p-4 pt-14 rounded-lg shadow-md'>
|
||||
<h1 className='text-2xl font-bold mb-4 flex items-center gap-1 text-primary-400'>
|
||||
<PiCatDuotone />
|
||||
{data.description}
|
||||
</h1>
|
||||
<h1 className='text-lg font-bold mb-4'>
|
||||
<Snippet
|
||||
className='bg-default-50 bg-opacity-50 backdrop-blur-md'
|
||||
symbol={<IoLink size={18} className='inline-block mr-1' />}
|
||||
tooltipProps={{
|
||||
content: '点击复制地址',
|
||||
}}
|
||||
>
|
||||
{path}
|
||||
</Snippet>
|
||||
</h1>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<Input
|
||||
label='HTTP URL'
|
||||
placeholder='输入 HTTP URL'
|
||||
value={httpConfig.url}
|
||||
onChange={(e) =>
|
||||
setHttpConfig({ ...httpConfig, url: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label='Token'
|
||||
placeholder='输入 Token'
|
||||
value={httpConfig.token}
|
||||
onChange={(e) =>
|
||||
setHttpConfig({ ...httpConfig, token: e.target.value })}
|
||||
/>
|
||||
<Button
|
||||
onPress={sendRequest}
|
||||
color='primary'
|
||||
size='lg'
|
||||
radius='full'
|
||||
isIconOnly
|
||||
isDisabled={isFetching}
|
||||
>
|
||||
<IoSend />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 flex flex-col min-h-0 bg-transparent'>
|
||||
<div className='px-4 flex flex-wrap items-center justify-between flex-shrink-0 min-h-[36px] gap-2 py-1'>
|
||||
<Tabs
|
||||
size="sm"
|
||||
variant="underlined"
|
||||
selectedKey={activeTab}
|
||||
onSelectionChange={setActiveTab}
|
||||
classNames={{
|
||||
cursor: 'bg-primary h-0.5',
|
||||
tab: 'px-0 mr-5 h-8',
|
||||
tabList: 'p-0 border-none',
|
||||
tabContent: 'text-[11px] font-bold opacity-30 group-data-[selected=true]:opacity-80 transition-opacity'
|
||||
<Card
|
||||
shadow='sm'
|
||||
className='my-4 bg-opacity-50 backdrop-blur-md overflow-visible'
|
||||
>
|
||||
<CardHeader className='font-bold text-lg gap-1 pb-0'>
|
||||
<span className='mr-2'>请求体</span>
|
||||
<Button
|
||||
color='warning'
|
||||
variant='flat'
|
||||
onPress={() => setIsCodeEditorOpen(!isCodeEditorOpen)}
|
||||
size='sm'
|
||||
radius='full'
|
||||
>
|
||||
{isCodeEditorOpen ? '收起' : '展开'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<motion.div
|
||||
ref={responseRef}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{
|
||||
opacity: isCodeEditorOpen ? 1 : 0,
|
||||
height: isCodeEditorOpen ? 'auto' : 0,
|
||||
}}
|
||||
>
|
||||
<Tab key="request" title="请求参数" />
|
||||
<Tab key="docs" title="接口定义" />
|
||||
</Tabs>
|
||||
<div className='flex items-center gap-1 ml-auto'>
|
||||
<ChatInputModal>
|
||||
{(onOpen) => (
|
||||
<Tooltip content="构造消息 (CQ码)" closeDelay={0}>
|
||||
<Button
|
||||
isIconOnly
|
||||
size='sm'
|
||||
variant='light'
|
||||
radius='full'
|
||||
className='h-7 w-7 text-primary/80 bg-primary/10 hover:bg-primary/20'
|
||||
onPress={onOpen}
|
||||
>
|
||||
<TbMessageCode size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ChatInputModal>
|
||||
<CodeEditor
|
||||
value={requestBody}
|
||||
onChange={(value) => setRequestBody(value ?? '')}
|
||||
language='json'
|
||||
height='400px'
|
||||
/>
|
||||
|
||||
<Tooltip content="生成示例参数" closeDelay={0}>
|
||||
<div className='flex justify-end gap-1'>
|
||||
<ChatInputModal />
|
||||
<Button
|
||||
isIconOnly
|
||||
size='sm'
|
||||
variant='light'
|
||||
radius='full'
|
||||
className='h-7 w-7 text-default-400 hover:text-primary hover:bg-default-100/50'
|
||||
onPress={() => setRequestBody(generateDefaultJson(data.request))}
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onPress={() =>
|
||||
setRequestBody(generateDefaultJson(data.request))}
|
||||
>
|
||||
<TbCode size={16} />
|
||||
填充示例请求体
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 min-h-0 relative px-3 pb-2 mt-1'>
|
||||
<div className={clsx(
|
||||
'h-full rounded-xl overflow-y-auto no-scrollbar transition-all',
|
||||
hasBackground ? 'bg-transparent' : 'bg-white/10 dark:bg-black/10'
|
||||
)}>
|
||||
{activeTab === 'request' ? (
|
||||
<CodeEditor
|
||||
value={requestBody}
|
||||
onChange={(value) => setRequestBody(value ?? '')}
|
||||
language='json'
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
padding: { top: 12 },
|
||||
lineNumbersMinChars: 3
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className='p-6 space-y-10'>
|
||||
<section>
|
||||
<h3 className='text-[10px] font-bold opacity-20 uppercase tracking-[0.2em] mb-4'>Request - 请求数据结构</h3>
|
||||
<DisplayStruct schema={parsedRequest} />
|
||||
</section>
|
||||
<div className='h-px bg-white/5 w-full' />
|
||||
<section>
|
||||
<h3 className='text-[10px] font-bold opacity-20 uppercase tracking-[0.2em] mb-4'>Response - 返回数据结构</h3>
|
||||
<DisplayStruct schema={parsedResponse} />
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Area */}
|
||||
<div className='flex-shrink-0 px-3 pb-3'>
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-xl transition-all overflow-hidden border border-white/5 flex flex-col',
|
||||
hasBackground ? 'bg-white/5' : 'bg-white/5 dark:bg-black/5'
|
||||
)}
|
||||
>
|
||||
{/* Header & Resize Handle */}
|
||||
<div
|
||||
className='flex items-center justify-between px-4 py-2 cursor-pointer hover:bg-white/5 transition-all select-none relative group'
|
||||
onClick={() => setResponseExpanded(!responseExpanded)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card
|
||||
shadow='sm'
|
||||
className='my-4 relative bg-opacity-50 backdrop-blur-md'
|
||||
>
|
||||
<PageLoading loading={isFetching} />
|
||||
<CardHeader className='font-bold text-lg gap-1 pb-0'>
|
||||
<span className='mr-2'>响应</span>
|
||||
<Button
|
||||
color='warning'
|
||||
variant='flat'
|
||||
onPress={() => setIsResponseOpen(!isResponseOpen)}
|
||||
size='sm'
|
||||
radius='full'
|
||||
>
|
||||
{/* Invisble Resize Area that becomes visible/active */}
|
||||
{responseExpanded && (
|
||||
<div
|
||||
className="absolute -top-1 left-0 w-full h-3 cursor-ns-resize z-50 flex items-center justify-center opacity-0 hover:opacity-100 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e); }}
|
||||
onTouchStart={(e) => { e.stopPropagation(); handleTouchStart(e); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-12 h-1 bg-white/20 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<IoChevronDown className={clsx('text-[10px] transition-transform duration-300 opacity-20', !responseExpanded && '-rotate-90')} />
|
||||
<span className='text-[10px] font-semibold tracking-wide opacity-30 uppercase'>Response</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
{responseStatus && (
|
||||
<Chip size="sm" variant="flat" color={responseStatus.code >= 200 && responseStatus.code < 300 ? 'success' : 'danger'} className="h-4 text-[9px] font-mono px-1.5 opacity-50">
|
||||
{responseStatus.code}
|
||||
</Chip>
|
||||
)}
|
||||
<Button size='sm' variant='light' isIconOnly radius='full' className='h-6 w-6 opacity-20 hover:opacity-80 transition-opacity' onClick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(responseContent); toast.success('已复制'); }}>
|
||||
<IoCopy size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Content - Code Editor */}
|
||||
{responseExpanded && (
|
||||
<div style={{ height: responseHeight }} className="relative bg-black/5 dark:bg-black/20">
|
||||
<PageLoading loading={isFetching} />
|
||||
<CodeEditor
|
||||
value={responseContent || '// Waiting for response...'}
|
||||
language='json'
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 11,
|
||||
lineNumbers: 'off',
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
readOnly: true,
|
||||
folding: true,
|
||||
padding: { top: 8, bottom: 8 },
|
||||
renderLineHighlight: 'none',
|
||||
automaticLayout: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isResponseOpen ? '收起' : '展开'}
|
||||
</Button>
|
||||
<Button
|
||||
color='success'
|
||||
variant='flat'
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(responseContent);
|
||||
toast.success('响应内容已复制到剪贴板');
|
||||
}}
|
||||
size='sm'
|
||||
radius='full'
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<motion.div
|
||||
className='overflow-y-auto text-sm'
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{
|
||||
opacity: isResponseOpen ? 1 : 0,
|
||||
height: isResponseOpen ? 300 : 0,
|
||||
}}
|
||||
>
|
||||
<pre>
|
||||
<code>
|
||||
{responseContent || (
|
||||
<div className='text-gray-400'>暂无响应</div>
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
</motion.div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<div className='p-2 md:p-4 border border-default-50 dark:border-default-200 rounded-lg backdrop-blur-sm'>
|
||||
<h2 className='text-xl font-semibold mb-2'>请求体结构</h2>
|
||||
<DisplayStruct schema={parsedRequest} />
|
||||
<h2 className='text-xl font-semibold mt-4 mb-2'>响应体结构</h2>
|
||||
<DisplayStruct schema={parsedResponse} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -8,15 +8,15 @@ import { TbSquareRoundedChevronRightFilled } from 'react-icons/tb';
|
||||
import type { LiteralValue, ParsedSchema } from '@/utils/zod';
|
||||
|
||||
interface DisplayStructProps {
|
||||
schema: ParsedSchema | ParsedSchema[];
|
||||
schema: ParsedSchema | ParsedSchema[]
|
||||
}
|
||||
|
||||
const SchemaType = ({
|
||||
type,
|
||||
value,
|
||||
}: {
|
||||
type: string;
|
||||
value?: LiteralValue;
|
||||
type: string
|
||||
value?: LiteralValue
|
||||
}) => {
|
||||
let name = type;
|
||||
switch (type) {
|
||||
@ -57,7 +57,7 @@ const SchemaType = ({
|
||||
};
|
||||
|
||||
const SchemaLabel: React.FC<{
|
||||
schema: ParsedSchema;
|
||||
schema: ParsedSchema
|
||||
}> = ({ schema }) => (
|
||||
<>
|
||||
{Array.isArray(schema.type)
|
||||
@ -81,8 +81,8 @@ const SchemaLabel: React.FC<{
|
||||
);
|
||||
|
||||
const SchemaContainer: React.FC<{
|
||||
schema: ParsedSchema;
|
||||
children: React.ReactNode;
|
||||
schema: ParsedSchema
|
||||
children: React.ReactNode
|
||||
}> = ({ schema, children }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
@ -126,7 +126,7 @@ const SchemaContainer: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const RenderSchema: React.FC<{ schema: ParsedSchema; }> = ({ schema }) => {
|
||||
const RenderSchema: React.FC<{ schema: ParsedSchema }> = ({ schema }) => {
|
||||
if (schema.type === 'object') {
|
||||
return (
|
||||
<SchemaContainer schema={schema}>
|
||||
@ -193,7 +193,7 @@ const RenderSchema: React.FC<{ schema: ParsedSchema; }> = ({ schema }) => {
|
||||
|
||||
const DisplayStruct: React.FC<DisplayStructProps> = ({ schema }) => {
|
||||
return (
|
||||
<div className=''>
|
||||
<div className='p-4 bg-content2 rounded-lg bg-opacity-50'>
|
||||
{Array.isArray(schema)
|
||||
? (
|
||||
schema.map((s, i) => <RenderSchema key={s.name || i} schema={s} />)
|
||||
|
||||
@ -1,173 +1,85 @@
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Input } from '@heroui/input';
|
||||
import clsx from 'clsx';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { TbChevronRight, TbFolder, TbSearch } from 'react-icons/tb';
|
||||
import { motion } from 'motion/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import oneBotHttpApiGroup from '@/const/ob_api/group';
|
||||
import oneBotHttpApiMessage from '@/const/ob_api/message';
|
||||
import oneBotHttpApiSystem from '@/const/ob_api/system';
|
||||
import oneBotHttpApiUser from '@/const/ob_api/user';
|
||||
import type { OneBotHttpApi, OneBotHttpApiPath } from '@/const/ob_api';
|
||||
|
||||
export interface OneBotApiNavListProps {
|
||||
data: OneBotHttpApi;
|
||||
selectedApi: OneBotHttpApiPath;
|
||||
onSelect: (apiName: OneBotHttpApiPath) => void;
|
||||
openSideBar: boolean;
|
||||
onToggle?: (isOpen: boolean) => void;
|
||||
data: OneBotHttpApi
|
||||
selectedApi: OneBotHttpApiPath
|
||||
onSelect: (apiName: OneBotHttpApiPath) => void
|
||||
openSideBar: boolean
|
||||
}
|
||||
|
||||
const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
||||
const { data, selectedApi, onSelect, openSideBar, onToggle } = props;
|
||||
const { data, selectedApi, onSelect, openSideBar } = props;
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const rawGroups = [
|
||||
{ id: 'user', label: '账号相关', keys: Object.keys(oneBotHttpApiUser) },
|
||||
{ id: 'message', label: '消息相关', keys: Object.keys(oneBotHttpApiMessage) },
|
||||
{ id: 'group', label: '群聊相关', keys: Object.keys(oneBotHttpApiGroup) },
|
||||
{ id: 'system', label: '系统操作', keys: Object.keys(oneBotHttpApiSystem) },
|
||||
];
|
||||
|
||||
return rawGroups.map(g => {
|
||||
const apis = g.keys
|
||||
.filter(k => k in data)
|
||||
.map(k => ({ path: k as OneBotHttpApiPath, ...data[k as OneBotHttpApiPath] }))
|
||||
.filter(api =>
|
||||
api.path.toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||
api.description?.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
return { ...g, apis };
|
||||
}).filter(g => g.apis.length > 0);
|
||||
}, [data, searchValue]);
|
||||
|
||||
const toggleGroup = (id: string) => {
|
||||
setExpandedGroups(prev =>
|
||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile backdrop overlay - below header (z-40) */}
|
||||
<AnimatePresence>
|
||||
{openSideBar && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-[2px] z-30 md:hidden"
|
||||
onClick={() => onToggle?.(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.div
|
||||
className={clsx(
|
||||
'h-full z-40 flex-shrink-0 border-r border-white/10 dark:border-white/5 overflow-hidden transition-all',
|
||||
// Mobile: absolute position, drawer style
|
||||
// Desktop: relative position, pushing content
|
||||
'absolute md:relative left-0 top-0',
|
||||
'bg-white/80 dark:bg-black/80 md:bg-transparent backdrop-blur-2xl md:backdrop-blur-none'
|
||||
)}
|
||||
initial={false}
|
||||
animate={{
|
||||
width: openSideBar ? 260 : 0,
|
||||
opacity: openSideBar ? 1 : 0,
|
||||
x: (window.innerWidth < 768 && !openSideBar) ? -260 : 0 // Optional: slide out completely on mobile
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
>
|
||||
<div className='w-[260px] h-full flex flex-col'>
|
||||
<div className='p-3'>
|
||||
<Input
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-white/5 dark:bg-white/5 border border-white/10 hover:bg-white/10 transition-all shadow-none',
|
||||
input: 'bg-transparent text-xs placeholder:opacity-30',
|
||||
}}
|
||||
isClearable
|
||||
radius='lg'
|
||||
placeholder='搜索接口...'
|
||||
startContent={<TbSearch size={14} className="opacity-30" />}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onClear={() => setSearchValue('')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 px-2 pb-4 flex flex-col gap-1 overflow-y-auto no-scrollbar'>
|
||||
{groups.map((group) => {
|
||||
const isOpen = expandedGroups.includes(group.id) || searchValue.length > 0;
|
||||
return (
|
||||
<div key={group.id} className="flex flex-col">
|
||||
{/* Group Header */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 py-2 rounded-lg cursor-pointer hover:bg-white/5 transition-all group/header"
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
>
|
||||
<TbChevronRight
|
||||
size={12}
|
||||
className={clsx(
|
||||
'transition-transform duration-200 opacity-20 group-hover/header:opacity-50',
|
||||
isOpen && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<TbFolder className="text-primary/60" size={16} />
|
||||
<span className="text-[13px] font-medium opacity-70 flex-1">{group.label}</span>
|
||||
<span className="text-[11px] opacity-20 font-mono tracking-tighter">({group.apis.length})</span>
|
||||
</div>
|
||||
|
||||
{/* Group Content */}
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden flex flex-col gap-1 ml-4 border-l border-white/5 pl-2 my-1"
|
||||
>
|
||||
{group.apis.map((api) => {
|
||||
const isSelected = api.path === selectedApi;
|
||||
return (
|
||||
<div
|
||||
key={api.path}
|
||||
onClick={() => onSelect(api.path)}
|
||||
className={clsx(
|
||||
'flex flex-col gap-0.5 px-3 py-2 rounded-lg cursor-pointer transition-all border border-transparent select-none',
|
||||
isSelected
|
||||
? 'bg-primary/20 border-primary/20 shadow-sm'
|
||||
: 'hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<span className={clsx(
|
||||
'text-[12px] font-medium transition-colors truncate',
|
||||
isSelected ? 'text-primary' : 'opacity-60'
|
||||
)}>
|
||||
{api.description}
|
||||
</span>
|
||||
<span className={clsx(
|
||||
'text-[10px] font-mono truncate transition-all',
|
||||
isSelected ? 'text-primary/60' : 'opacity-20'
|
||||
)}>
|
||||
{api.path}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
<motion.div
|
||||
className={clsx(
|
||||
'h-[calc(100vh-3.5rem)] left-0 !overflow-hidden md:w-auto z-20 top-[3.3rem] md:top-[3rem] absolute md:sticky md:float-start',
|
||||
openSideBar && 'bg-background bg-opacity-20 backdrop-blur-md'
|
||||
)}
|
||||
initial={{ width: 0 }}
|
||||
transition={{
|
||||
type: openSideBar ? 'spring' : 'tween',
|
||||
stiffness: 150,
|
||||
damping: 15,
|
||||
}}
|
||||
animate={{ width: openSideBar ? '16rem' : '0rem' }}
|
||||
style={{ overflowY: openSideBar ? 'auto' : 'hidden' }}
|
||||
>
|
||||
<div className='w-64 h-full overflow-y-auto px-2 pt-2 pb-10 md:pb-0'>
|
||||
<Input
|
||||
className='sticky top-0 z-10 text-primary-600'
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-opacity-30 bg-primary-50 backdrop-blur-sm border border-primary-300 mb-2',
|
||||
input: 'bg-transparent !text-primary-400 !placeholder-primary-400',
|
||||
}}
|
||||
radius='full'
|
||||
placeholder='搜索 API'
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
isClearable
|
||||
onClear={() => setSearchValue('')}
|
||||
/>
|
||||
{Object.entries(data).map(([apiName, api]) => (
|
||||
<Card
|
||||
key={apiName}
|
||||
shadow='none'
|
||||
className={clsx(
|
||||
'w-full border border-primary-100 rounded-lg mb-1 bg-opacity-30 backdrop-blur-sm text-primary-400',
|
||||
{
|
||||
hidden: !(
|
||||
apiName.includes(searchValue) ||
|
||||
api.description?.includes(searchValue)
|
||||
),
|
||||
},
|
||||
{
|
||||
'!bg-opacity-40 border border-primary-400 bg-primary-50 text-primary-600':
|
||||
apiName === selectedApi,
|
||||
}
|
||||
)}
|
||||
isPressable
|
||||
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
|
||||
>
|
||||
<CardBody>
|
||||
<h2 className='font-bold'>{api.description}</h2>
|
||||
<div
|
||||
className={clsx('text-sm text-primary-200', {
|
||||
'!text-primary-400': apiName === selectedApi,
|
||||
})}
|
||||
>
|
||||
{apiName}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -30,14 +30,14 @@ const itemVariants = {
|
||||
},
|
||||
};
|
||||
|
||||
function RequestComponent ({ data: _ }: { data: OB11Request; }) {
|
||||
function RequestComponent ({ data: _ }: { data: OB11Request }) {
|
||||
return <div>Request消息,暂未适配</div>;
|
||||
}
|
||||
|
||||
export interface OneBotItemRenderProps {
|
||||
data: AllOB11WsResponse[];
|
||||
index: number;
|
||||
style: React.CSSProperties;
|
||||
data: AllOB11WsResponse[]
|
||||
index: number
|
||||
style: React.CSSProperties
|
||||
}
|
||||
|
||||
export const getItemSize = (event: OB11AllEvent['post_type']) => {
|
||||
@ -90,7 +90,7 @@ const OneBotItemRender = ({ data, index, style }: OneBotItemRenderProps) => {
|
||||
animate='visible'
|
||||
className='h-full px-2'
|
||||
>
|
||||
<Card className='w-full h-full py-2 bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm'>
|
||||
<Card className='w-full h-full py-2 bg-opacity-50 backdrop-blur-sm'>
|
||||
<CardHeader className='py-0 text-default-500 flex-row gap-2'>
|
||||
<div className='font-bold'>
|
||||
{isEvent ? getEventName(msg.post_type) : '请求响应'}
|
||||
|
||||
@ -3,8 +3,8 @@ import { SharedSelection } from '@heroui/system';
|
||||
import type { Selection } from '@react-types/shared';
|
||||
|
||||
export interface FilterMessageTypeProps {
|
||||
filterTypes: Selection;
|
||||
onSelectionChange: (keys: SharedSelection) => void;
|
||||
filterTypes: Selection
|
||||
onSelectionChange: (keys: SharedSelection) => void
|
||||
}
|
||||
const items = [
|
||||
{ label: '元事件', value: 'meta_event' },
|
||||
@ -26,7 +26,6 @@ const FilterMessageType: React.FC<FilterMessageTypeProps> = (props) => {
|
||||
}}
|
||||
label='筛选消息类型'
|
||||
selectionMode='multiple'
|
||||
className='w-full'
|
||||
items={items}
|
||||
renderValue={(value) => {
|
||||
if (value.length === items.length) {
|
||||
|
||||
@ -43,7 +43,7 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={onOpen} color='primary' radius='full' variant='flat' size='sm' className="font-medium">
|
||||
<Button onPress={onOpen} color='primary' radius='full' variant='flat'>
|
||||
构造请求
|
||||
</Button>
|
||||
<Modal
|
||||
|
||||
@ -1,37 +1,23 @@
|
||||
import { motion } from 'motion/react';
|
||||
import { Image } from '@heroui/image';
|
||||
|
||||
import bkg_color from '@/assets/images/bkg-color.png';
|
||||
|
||||
const PageBackground = () => {
|
||||
return (
|
||||
<div className='fixed inset-0 w-full h-full -z-10 overflow-hidden bg-gradient-to-br from-indigo-50 via-white to-pink-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900'>
|
||||
{/* 动态呼吸光斑 - ACG风格 */}
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
rotate: [0, 90, 0],
|
||||
opacity: [0.3, 0.5, 0.3]
|
||||
}}
|
||||
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
|
||||
className='absolute top-[-10%] left-[-10%] w-[500px] h-[500px] rounded-full bg-primary-200/40 blur-[100px]'
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.3, 1],
|
||||
x: [0, 100, 0],
|
||||
opacity: [0.3, 0.6, 0.3]
|
||||
}}
|
||||
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut", delay: 2 }}
|
||||
className='absolute top-[20%] right-[-10%] w-[400px] h-[400px] rounded-full bg-secondary-200/40 blur-[90px]'
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.1, 1],
|
||||
y: [0, -50, 0],
|
||||
opacity: [0.2, 0.4, 0.2]
|
||||
}}
|
||||
transition={{ duration: 12, repeat: Infinity, ease: "easeInOut", delay: 5 }}
|
||||
className='absolute bottom-[-10%] left-[20%] w-[600px] h-[600px] rounded-full bg-pink-200/30 blur-[110px]'
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className='fixed w-full h-full -z-[0] flex justify-end opacity-80'>
|
||||
<Image
|
||||
className='overflow-hidden object-contain -top-42 h-[160%] -right-[30%] -rotate-45 pointer-events-none select-none -z-10 relative'
|
||||
src={bkg_color}
|
||||
/>
|
||||
</div>
|
||||
<div className='fixed w-full h-full overflow-hidden -z-[0] hue-rotate-90 flex justify-start opacity-80'>
|
||||
<Image
|
||||
className='relative -top-92 h-[180%] object-contain pointer-events-none rotate-90 select-none -z-10 top-44'
|
||||
src={bkg_color}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,29 +1,22 @@
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Image } from '@heroui/image';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { BsTencentQq } from 'react-icons/bs';
|
||||
|
||||
import key from '@/const/key';
|
||||
import { SelfInfo } from '@/types/user';
|
||||
|
||||
import PageLoading from './page_loading';
|
||||
|
||||
export interface QQInfoCardProps {
|
||||
data?: SelfInfo;
|
||||
error?: Error;
|
||||
loading?: boolean;
|
||||
data?: SelfInfo
|
||||
error?: Error
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
return (
|
||||
<Card
|
||||
className={clsx(
|
||||
'relative backdrop-blur-sm border border-white/40 dark:border-white/10 overflow-hidden flex-shrink-0 shadow-sm',
|
||||
hasBackground ? 'bg-white/10 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
|
||||
)}
|
||||
className='relative bg-primary-100 bg-opacity-60 overflow-hidden flex-shrink-0 shadow-md shadow-primary-300 dark:shadow-primary-50'
|
||||
shadow='none'
|
||||
radius='lg'
|
||||
>
|
||||
@ -38,40 +31,28 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
</CardBody>
|
||||
)
|
||||
: (
|
||||
<CardBody className='flex-row items-center gap-4 overflow-hidden relative p-4'>
|
||||
{!hasBackground && (
|
||||
<div className='absolute right-[-10px] bottom-[-10px] text-7xl text-default-400/10 rotate-12 pointer-events-none'>
|
||||
<BsTencentQq />
|
||||
</div>
|
||||
)}
|
||||
<CardBody className='flex-row items-center gap-2 overflow-hidden relative'>
|
||||
<div className='absolute right-0 bottom-0 text-5xl text-primary-400'>
|
||||
<BsTencentQq />
|
||||
</div>
|
||||
<div className='relative flex-shrink-0 z-10'>
|
||||
<Image
|
||||
src={
|
||||
data?.avatarUrl ??
|
||||
`https://q1.qlogo.cn/g?b=qq&nk=${data?.uin}&s=1`
|
||||
}
|
||||
className='shadow-sm rounded-full w-14 aspect-square ring-2 ring-white/50 dark:ring-white/10'
|
||||
data?.avatarUrl ??
|
||||
`https://q1.qlogo.cn/g?b=qq&nk=${data?.uin}&s=1`
|
||||
}
|
||||
className='shadow-md rounded-full w-12 aspect-square'
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'w-3.5 h-3.5 rounded-full absolute right-0.5 bottom-0.5 border-2 border-white dark:border-zinc-900 z-10',
|
||||
data?.online ? 'bg-success-500' : 'bg-default-400'
|
||||
'w-4 h-4 rounded-full absolute right-0.5 bottom-0 border-2 border-primary-100 z-10',
|
||||
data?.online ? 'bg-green-500' : 'bg-gray-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-col justify-center z-10'>
|
||||
<div className={clsx(
|
||||
'text-xl font-bold truncate mb-0.5',
|
||||
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-800 dark:text-gray-100'
|
||||
)}>
|
||||
{data?.nick || '未知用户'}
|
||||
</div>
|
||||
<div className={clsx(
|
||||
'font-mono text-xs tracking-wider',
|
||||
hasBackground ? 'text-white/80' : 'text-default-500 opacity-80'
|
||||
)}>
|
||||
{data?.uin || 'Unknown'}
|
||||
</div>
|
||||
<div className='flex-col justify-center'>
|
||||
<div className='text-lg truncate'>{data?.nick}</div>
|
||||
<div className='text-primary-500 text-sm'>{data?.uin}</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
)}
|
||||
|
||||
@ -1,30 +1,30 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { Image } from '@heroui/image';
|
||||
import clsx from 'clsx';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import React from 'react';
|
||||
import { IoMdLogOut } from 'react-icons/io';
|
||||
import { MdDarkMode, MdLightMode } from 'react-icons/md';
|
||||
|
||||
import key from '@/const/key';
|
||||
import useAuth from '@/hooks/auth';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import type { MenuItem } from '@/config/site';
|
||||
|
||||
import Menus from './menus';
|
||||
|
||||
interface SideBarProps {
|
||||
open: boolean;
|
||||
items: MenuItem[];
|
||||
onClose?: () => void;
|
||||
open: boolean
|
||||
items: MenuItem[]
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const SideBar: React.FC<SideBarProps> = (props) => {
|
||||
const { open, items, onClose } = props;
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const { revokeAuth } = useAuth();
|
||||
const [b64img] = useLocalStorage(key.backgroundImage, '');
|
||||
const dialog = useDialog();
|
||||
const onRevokeAuth = () => {
|
||||
dialog.confirm({
|
||||
@ -50,9 +50,7 @@ const SideBar: React.FC<SideBarProps> = (props) => {
|
||||
</AnimatePresence>
|
||||
<motion.div
|
||||
className={clsx(
|
||||
'overflow-hidden fixed top-0 left-0 h-full z-50 md:static shadow-md md:shadow-none rounded-r-md md:rounded-none',
|
||||
b64img ? 'bg-black/20 backdrop-blur-md border-r border-white/10' : 'bg-background',
|
||||
'md:bg-transparent md:border-r-0 md:backdrop-blur-none'
|
||||
'overflow-hidden fixed top-0 left-0 h-full z-50 bg-background md:bg-transparent md:static shadow-md md:shadow-none rounded-r-md md:rounded-none'
|
||||
)}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: open ? '16rem' : 0 }}
|
||||
@ -63,33 +61,40 @@ const SideBar: React.FC<SideBarProps> = (props) => {
|
||||
}}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<motion.div className='w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right p-4'>
|
||||
<div className='flex items-center justify-start gap-3 px-2 my-8 ml-2'>
|
||||
<div className="h-5 w-1 bg-primary rounded-full shadow-sm" />
|
||||
<div className="text-xl font-bold text-default-900 dark:text-white tracking-wide select-none">
|
||||
<motion.div className='w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right'>
|
||||
<div className='flex justify-center items-center my-2 gap-2'>
|
||||
<Image radius='none' height={40} src={logo} className='mb-2' />
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center font-bold',
|
||||
'!text-2xl shiny-text'
|
||||
)}
|
||||
>
|
||||
NapCat
|
||||
</div>
|
||||
</div>
|
||||
<div className='overflow-y-auto flex flex-col flex-1 px-2'>
|
||||
<div className='overflow-y-auto flex flex-col flex-1 px-4'>
|
||||
<Menus items={items} />
|
||||
<div className='mt-auto mb-10 md:mb-0 space-y-3 px-2'>
|
||||
<div className='mt-auto mb-10 md:mb-0'>
|
||||
<Button
|
||||
className='w-full bg-primary-50/50 hover:bg-primary-100/80 text-primary-600 font-medium shadow-sm hover:shadow-md transition-all duration-300 backdrop-blur-sm'
|
||||
className='w-full'
|
||||
color='primary'
|
||||
radius='full'
|
||||
variant='flat'
|
||||
variant='light'
|
||||
onPress={toggleTheme}
|
||||
startContent={
|
||||
!isDark ? <MdLightMode size={18} /> : <MdDarkMode size={18} />
|
||||
!isDark ? <MdLightMode size={16} /> : <MdDarkMode size={16} />
|
||||
}
|
||||
>
|
||||
切换主题
|
||||
</Button>
|
||||
<Button
|
||||
className='w-full mb-2 bg-danger-50/50 hover:bg-danger-100/80 text-danger-500 font-medium shadow-sm hover:shadow-md transition-all duration-300 backdrop-blur-sm'
|
||||
className='w-full mb-2'
|
||||
color='primary'
|
||||
radius='full'
|
||||
variant='flat'
|
||||
variant='light'
|
||||
onPress={onRevokeAuth}
|
||||
startContent={<IoMdLogOut size={18} />}
|
||||
startContent={<IoMdLogOut size={16} />}
|
||||
>
|
||||
退出登录
|
||||
</Button>
|
||||
|
||||
@ -50,13 +50,12 @@ const renderItems = (items: MenuItem[], children = false) => {
|
||||
<div key={item.href + item.label}>
|
||||
<Button
|
||||
className={clsx(
|
||||
'flex items-center w-full text-left justify-start dark:text-white transition-all duration-300',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-400 shadow-none font-semibold translate-x-1'
|
||||
: 'hover:bg-default-100 hover:translate-x-1',
|
||||
'flex items-center w-full text-left justify-start dark:text-white',
|
||||
// children && 'rounded-l-lg',
|
||||
isActive && 'bg-opacity-60',
|
||||
b64img && 'backdrop-blur-md text-white'
|
||||
)}
|
||||
color={isActive ? 'primary' : 'default'}
|
||||
color='primary'
|
||||
endContent={
|
||||
canOpen
|
||||
? (
|
||||
@ -105,6 +104,7 @@ const renderItems = (items: MenuItem[], children = false) => {
|
||||
/>
|
||||
)
|
||||
}
|
||||
radius='full'
|
||||
startContent={
|
||||
customIcons[item.label]
|
||||
? (
|
||||
@ -147,7 +147,7 @@ const renderItems = (items: MenuItem[], children = false) => {
|
||||
};
|
||||
|
||||
interface MenusProps {
|
||||
items: MenuItem[];
|
||||
items: MenuItem[]
|
||||
}
|
||||
const Menus: React.FC<MenusProps> = (props) => {
|
||||
const { items } = props;
|
||||
|
||||
@ -3,14 +3,14 @@ import clsx from 'clsx';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
export interface SwitchCardProps {
|
||||
label?: string;
|
||||
description?: string;
|
||||
value?: boolean;
|
||||
onValueChange?: (value: boolean) => void;
|
||||
name?: string;
|
||||
onBlur?: React.FocusEventHandler;
|
||||
disabled?: boolean;
|
||||
onChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||
label?: string
|
||||
description?: string
|
||||
value?: boolean
|
||||
onValueChange?: (value: boolean) => void
|
||||
name?: string
|
||||
onBlur?: React.FocusEventHandler
|
||||
disabled?: boolean
|
||||
onChange?: React.ChangeEventHandler<HTMLInputElement>
|
||||
}
|
||||
|
||||
const SwitchCard = forwardRef<HTMLInputElement, SwitchCardProps>(
|
||||
@ -22,9 +22,9 @@ const SwitchCard = forwardRef<HTMLInputElement, SwitchCardProps>(
|
||||
<Switch
|
||||
classNames={{
|
||||
base: clsx(
|
||||
'inline-flex flex-row-reverse w-full max-w-full bg-default-100/50 dark:bg-white/5 hover:bg-default-200/50 dark:hover:bg-white/10 items-center',
|
||||
'justify-between cursor-pointer rounded-xl gap-2 p-4 border border-transparent transition-all duration-200',
|
||||
'data-[selected=true]:border-primary/50 data-[selected=true]:bg-primary/5 backdrop-blur-md'
|
||||
'inline-flex flex-row-reverse w-full max-w-md bg-content1 hover:bg-content2 items-center',
|
||||
'justify-between cursor-pointer rounded-lg gap-2 p-3 border-2 border-transparent',
|
||||
'data-[selected=true]:border-primary bg-opacity-50 backdrop-blur-sm'
|
||||
),
|
||||
}}
|
||||
{...props}
|
||||
|
||||
@ -3,15 +3,15 @@ import { Button } from '@heroui/button';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useRequest } from 'ahooks';
|
||||
import clsx from 'clsx';
|
||||
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6';
|
||||
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io';
|
||||
import { RiMacFill } from 'react-icons/ri';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
|
||||
|
||||
import key from '@/const/key';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
|
||||
@ -21,7 +21,6 @@ export interface SystemInfoItemProps {
|
||||
icon?: React.ReactNode;
|
||||
value?: React.ReactNode;
|
||||
endContent?: React.ReactNode;
|
||||
hasBackground?: boolean;
|
||||
}
|
||||
|
||||
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
||||
@ -29,22 +28,13 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
||||
value = '--',
|
||||
icon,
|
||||
endContent,
|
||||
hasBackground = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx(
|
||||
'flex text-sm gap-3 py-2 items-center transition-colors',
|
||||
hasBackground
|
||||
? 'text-white/90'
|
||||
: 'text-default-600 dark:text-gray-300'
|
||||
)}>
|
||||
<div className="text-lg opacity-70">{icon}</div>
|
||||
<div className='w-24 font-medium'>{title}</div>
|
||||
<div className={clsx(
|
||||
'text-xs font-mono flex-1',
|
||||
hasBackground ? 'text-white/80' : 'text-default-500'
|
||||
)}>{value}</div>
|
||||
<div>{endContent}</div>
|
||||
<div className='flex text-sm gap-1 p-2 items-center shadow-sm shadow-primary-100 dark:shadow-primary-100 rounded text-primary-400'>
|
||||
{icon}
|
||||
<div className='w-24'>{title}</div>
|
||||
<div className='text-primary-200'>{value}</div>
|
||||
<div className='ml-auto'>{endContent}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -202,161 +192,16 @@ export interface NewVersionTipProps {
|
||||
// );
|
||||
// };
|
||||
|
||||
// 更新状态类型
|
||||
type UpdateStatus = 'idle' | 'updating' | 'success' | 'error';
|
||||
|
||||
// 更新对话框内容组件
|
||||
const UpdateDialogContent: React.FC<{
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
status: UpdateStatus;
|
||||
errorMessage?: string;
|
||||
}> = ({ currentVersion, latestVersion, status, errorMessage }) => {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{/* 版本信息 */}
|
||||
<div className='space-y-2'>
|
||||
<div className='text-sm space-x-2'>
|
||||
<span>当前版本</span>
|
||||
<Chip color='primary' variant='flat'>
|
||||
v{currentVersion}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className='text-sm space-x-2'>
|
||||
<span>最新版本</span>
|
||||
<Chip color='primary'>v{latestVersion}</Chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 更新状态显示 */}
|
||||
{status === 'updating' && (
|
||||
<div className='flex flex-col items-center justify-center gap-3 py-4 px-4 rounded-lg bg-primary-50/50 dark:bg-primary-900/20 border border-primary-200/50 dark:border-primary-700/30'>
|
||||
<Spinner size='md' color='primary' />
|
||||
<div className='text-center'>
|
||||
<p className='text-sm font-medium text-primary-600 dark:text-primary-400'>
|
||||
正在更新中...
|
||||
</p>
|
||||
<p className='text-xs text-default-500 mt-1'>
|
||||
请耐心等待,更新可能需要几分钟
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className='flex flex-col items-center justify-center gap-3 py-4 px-4 rounded-lg bg-success-50/50 dark:bg-success-900/20 border border-success-200/50 dark:border-success-700/30'>
|
||||
<div className='w-12 h-12 rounded-full bg-success-100 dark:bg-success-900/40 flex items-center justify-center'>
|
||||
<svg className='w-6 h-6 text-success-600 dark:text-success-400' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
|
||||
</svg>
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<p className='text-sm font-medium text-success-600 dark:text-success-400'>
|
||||
更新完成
|
||||
</p>
|
||||
<p className='text-xs text-default-500 mt-1'>
|
||||
请重启 NapCat 以应用新版本
|
||||
</p>
|
||||
</div>
|
||||
<div className='mt-2 p-3 rounded-lg bg-warning-50/50 dark:bg-warning-900/20 border border-warning-200/50 dark:border-warning-700/30'>
|
||||
<p className='text-xs text-warning-700 dark:text-warning-400 flex items-center gap-1'>
|
||||
<svg className='w-4 h-4' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' />
|
||||
</svg>
|
||||
<span>请手动重启 NapCat,更新才会生效</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className='flex flex-col items-center justify-center gap-3 py-4 px-4 rounded-lg bg-danger-50/50 dark:bg-danger-900/20 border border-danger-200/50 dark:border-danger-700/30'>
|
||||
<div className='w-12 h-12 rounded-full bg-danger-100 dark:bg-danger-900/40 flex items-center justify-center'>
|
||||
<svg className='w-6 h-6 text-danger-600 dark:text-danger-400' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
|
||||
</svg>
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<p className='text-sm font-medium text-danger-600 dark:text-danger-400'>
|
||||
更新失败
|
||||
</p>
|
||||
<p className='text-xs text-default-500 mt-1'>
|
||||
{errorMessage || '请稍后重试或手动更新'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NewVersionTip = (props: NewVersionTipProps) => {
|
||||
const { currentVersion } = props;
|
||||
const dialog = useDialog();
|
||||
const { data: latestVersion, error } = useRequest(WebUIManager.getLatestTag);
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
if (error || !latestVersion || !currentVersion || latestVersion === currentVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setUpdateStatus('updating');
|
||||
|
||||
try {
|
||||
await WebUIManager.UpdateNapCat();
|
||||
setUpdateStatus('success');
|
||||
// 显示更新成功对话框
|
||||
dialog.alert({
|
||||
title: '更新完成',
|
||||
content: (
|
||||
<UpdateDialogContent
|
||||
currentVersion={currentVersion}
|
||||
latestVersion={latestVersion}
|
||||
status='success'
|
||||
/>
|
||||
),
|
||||
confirmText: '我知道了',
|
||||
size: 'md',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Update failed:', err);
|
||||
const errMessage = err instanceof Error ? err.message : '未知错误';
|
||||
setUpdateStatus('error');
|
||||
// 显示更新失败对话框
|
||||
dialog.alert({
|
||||
title: '更新失败',
|
||||
content: (
|
||||
<UpdateDialogContent
|
||||
currentVersion={currentVersion}
|
||||
latestVersion={latestVersion}
|
||||
status='error'
|
||||
errorMessage={errMessage}
|
||||
/>
|
||||
),
|
||||
confirmText: '确定',
|
||||
size: 'md',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const showUpdateDialog = () => {
|
||||
dialog.confirm({
|
||||
title: '发现新版本',
|
||||
content: (
|
||||
<UpdateDialogContent
|
||||
currentVersion={currentVersion}
|
||||
latestVersion={latestVersion}
|
||||
status='idle'
|
||||
/>
|
||||
),
|
||||
confirmText: '立即更新',
|
||||
cancelText: '稍后更新',
|
||||
size: 'md',
|
||||
onConfirm: handleUpdate,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip content='有新版本可用'>
|
||||
<Button
|
||||
@ -365,8 +210,50 @@ const NewVersionTip = (props: NewVersionTipProps) => {
|
||||
color='primary'
|
||||
variant='shadow'
|
||||
className='!w-5 !h-5 !min-w-0 text-small shadow-md'
|
||||
isLoading={updateStatus === 'updating'}
|
||||
onPress={showUpdateDialog}
|
||||
onPress={() => {
|
||||
dialog.confirm({
|
||||
title: '有新版本可用',
|
||||
content: (
|
||||
<div className='space-y-2'>
|
||||
<div className='text-sm space-x-2'>
|
||||
<span>当前版本</span>
|
||||
<Chip color='primary' variant='flat'>
|
||||
v{currentVersion}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className='text-sm space-x-2'>
|
||||
<span>最新版本</span>
|
||||
<Chip color='primary'>v{latestVersion}</Chip>
|
||||
</div>
|
||||
{updating && (
|
||||
<div className='flex justify-center'>
|
||||
<Spinner size='sm' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
confirmText: updating ? '更新中...' : '更新',
|
||||
onConfirm: async () => {
|
||||
setUpdating(true);
|
||||
toast('更新中,预计需要几分钟,请耐心等待', {
|
||||
duration: 3000,
|
||||
});
|
||||
try {
|
||||
await WebUIManager.UpdateNapCat();
|
||||
toast.success('更新完成,重启生效', {
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update failed:', error);
|
||||
toast.success('更新异常', {
|
||||
duration: 5000,
|
||||
});
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FaInfo />
|
||||
</Button>
|
||||
@ -374,11 +261,7 @@ const NewVersionTip = (props: NewVersionTipProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface NapCatVersionProps {
|
||||
hasBackground?: boolean;
|
||||
}
|
||||
|
||||
const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false }) => {
|
||||
const NapCatVersion = () => {
|
||||
const {
|
||||
data: packageData,
|
||||
loading: packageLoading,
|
||||
@ -391,7 +274,6 @@ const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false })
|
||||
<SystemInfoItem
|
||||
title='NapCat 版本'
|
||||
icon={<IoLogoOctocat className='text-xl' />}
|
||||
hasBackground={hasBackground}
|
||||
value={
|
||||
packageError
|
||||
? (
|
||||
@ -420,28 +302,18 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
||||
loading: qqVersionLoading,
|
||||
error: qqVersionError,
|
||||
} = useRequest(WebUIManager.getQQVersion);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
return (
|
||||
<Card className={clsx(
|
||||
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm overflow-visible flex-1',
|
||||
hasBackground ? 'bg-white/10 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
|
||||
)}>
|
||||
<CardHeader className={clsx(
|
||||
'pb-0 items-center gap-2 font-bold px-4 pt-4',
|
||||
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-white'
|
||||
)}>
|
||||
<FaCircleInfo className='text-lg opacity-80' />
|
||||
<Card className='bg-opacity-60 shadow-sm shadow-primary-100 dark:shadow-primary-100 overflow-visible flex-1'>
|
||||
<CardHeader className='pb-0 items-center gap-1 text-primary-500 font-extrabold'>
|
||||
<FaCircleInfo className='text-lg' />
|
||||
<span>系统信息</span>
|
||||
</CardHeader>
|
||||
<CardBody className='flex-1'>
|
||||
<div className='flex flex-col gap-2 justify-between h-full'>
|
||||
<NapCatVersion hasBackground={hasBackground} />
|
||||
<div className='flex flex-col justify-between h-full'>
|
||||
<NapCatVersion />
|
||||
<SystemInfoItem
|
||||
title='QQ 版本'
|
||||
icon={<FaQq className='text-lg' />}
|
||||
hasBackground={hasBackground}
|
||||
value={
|
||||
qqVersionError
|
||||
? (
|
||||
@ -460,13 +332,11 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
||||
title='WebUI 版本'
|
||||
icon={<IoLogoChrome className='text-xl' />}
|
||||
value='Next'
|
||||
hasBackground={hasBackground}
|
||||
/>
|
||||
<SystemInfoItem
|
||||
title='系统版本'
|
||||
icon={<RiMacFill className='text-xl' />}
|
||||
value={archInfo}
|
||||
hasBackground={hasBackground}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
|
||||
@ -1,21 +1,18 @@
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Image } from '@heroui/image';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { BiSolidMemoryCard } from 'react-icons/bi';
|
||||
import { GiCpu } from 'react-icons/gi';
|
||||
|
||||
import bkg from '@/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png';
|
||||
import key from '@/const/key';
|
||||
|
||||
import UsagePie from './usage_pie';
|
||||
|
||||
export interface SystemStatusItemProps {
|
||||
title: string;
|
||||
value?: string | number;
|
||||
size?: 'md' | 'lg';
|
||||
unit?: string;
|
||||
hasBackground?: boolean;
|
||||
title: string
|
||||
value?: string | number
|
||||
size?: 'md' | 'lg'
|
||||
unit?: string
|
||||
}
|
||||
|
||||
const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
|
||||
@ -23,32 +20,25 @@ const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
|
||||
value = '-',
|
||||
size = 'md',
|
||||
unit,
|
||||
hasBackground = false,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'py-1.5 text-sm transition-colors',
|
||||
size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between',
|
||||
'shadow-sm shadow-primary-100 p-2 rounded-md text-sm bg-content1 bg-opacity-30',
|
||||
size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between'
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
'w-24 font-medium',
|
||||
hasBackground ? 'text-white/90' : 'text-default-600 dark:text-gray-300'
|
||||
)}>{title}</div>
|
||||
<div className={clsx(
|
||||
'font-mono text-xs',
|
||||
hasBackground ? 'text-white/80' : 'text-default-500'
|
||||
)}>
|
||||
<div className='w-24'>{title}</div>
|
||||
<div className='text-default-400'>
|
||||
{value}
|
||||
{unit && <span className="ml-0.5 opacity-70">{unit}</span>}
|
||||
{unit}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface SystemStatusDisplayProps {
|
||||
data?: SystemStatus;
|
||||
data?: SystemStatus
|
||||
}
|
||||
|
||||
const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
|
||||
@ -63,14 +53,9 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
|
||||
memoryUsage.system = (systemUsage / system) * 100;
|
||||
memoryUsage.qq = (qqUsage / system) * 100;
|
||||
}
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
return (
|
||||
<Card className={clsx(
|
||||
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm col-span-1 lg:col-span-2 relative overflow-hidden',
|
||||
hasBackground ? 'bg-white/10 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
|
||||
)}>
|
||||
<Card className='bg-opacity-60 shadow-sm shadow-primary-100 col-span-1 lg:col-span-2 relative overflow-hidden'>
|
||||
<div className='absolute h-full right-0 top-0'>
|
||||
<Image
|
||||
src={bkg}
|
||||
@ -84,35 +69,27 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
|
||||
</div>
|
||||
<CardBody className='overflow-visible md:flex-row gap-4 items-center justify-stretch z-10'>
|
||||
<div className='flex-1 w-full md:max-w-96'>
|
||||
<h2 className={clsx(
|
||||
'text-lg font-semibold flex items-center gap-2 mb-2',
|
||||
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
|
||||
)}>
|
||||
<GiCpu className='text-xl opacity-80' />
|
||||
<h2 className='text-lg font-semibold flex items-center gap-1 text-primary-400'>
|
||||
<GiCpu className='text-xl' />
|
||||
<span>CPU</span>
|
||||
</h2>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<SystemStatusItem title='型号' value={data?.cpu.model} size='lg' hasBackground={hasBackground} />
|
||||
<SystemStatusItem title='内核数' value={data?.cpu.core} hasBackground={hasBackground} />
|
||||
<SystemStatusItem title='主频' value={data?.cpu.speed} unit='GHz' hasBackground={hasBackground} />
|
||||
<SystemStatusItem title='型号' value={data?.cpu.model} size='lg' />
|
||||
<SystemStatusItem title='内核数' value={data?.cpu.core} />
|
||||
<SystemStatusItem title='主频' value={data?.cpu.speed} unit='GHz' />
|
||||
<SystemStatusItem
|
||||
title='使用率'
|
||||
value={data?.cpu.usage.system}
|
||||
unit='%'
|
||||
hasBackground={hasBackground}
|
||||
/>
|
||||
<SystemStatusItem
|
||||
title='QQ主线程'
|
||||
value={data?.cpu.usage.qq}
|
||||
unit='%'
|
||||
hasBackground={hasBackground}
|
||||
/>
|
||||
</div>
|
||||
<h2 className={clsx(
|
||||
'text-lg font-semibold flex items-center gap-2 mb-2 mt-4',
|
||||
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
|
||||
)}>
|
||||
<BiSolidMemoryCard className='text-xl opacity-80' />
|
||||
<h2 className='text-lg font-semibold flex items-center gap-1 text-primary-400 mt-2'>
|
||||
<BiSolidMemoryCard className='text-xl' />
|
||||
<span>内存</span>
|
||||
</h2>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
@ -121,19 +98,16 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
|
||||
value={data?.memory.total}
|
||||
size='lg'
|
||||
unit='MB'
|
||||
hasBackground={hasBackground}
|
||||
/>
|
||||
<SystemStatusItem
|
||||
title='使用量'
|
||||
value={data?.memory.usage.system}
|
||||
unit='MB'
|
||||
hasBackground={hasBackground}
|
||||
/>
|
||||
<SystemStatusItem
|
||||
title='QQ主线程'
|
||||
value={data?.memory.usage.qq}
|
||||
unit='MB'
|
||||
hasBackground={hasBackground}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -12,21 +12,21 @@ import { useTheme } from '@/hooks/use-theme';
|
||||
export type XTermRef = {
|
||||
write: (
|
||||
...args: Parameters<Terminal['write']>
|
||||
) => ReturnType<Terminal['write']>;
|
||||
writeAsync: (data: Parameters<Terminal['write']>[0]) => Promise<void>;
|
||||
) => ReturnType<Terminal['write']>
|
||||
writeAsync: (data: Parameters<Terminal['write']>[0]) => Promise<void>
|
||||
writeln: (
|
||||
...args: Parameters<Terminal['writeln']>
|
||||
) => ReturnType<Terminal['writeln']>;
|
||||
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>;
|
||||
clear: () => void;
|
||||
terminalRef: React.RefObject<Terminal | null>;
|
||||
) => ReturnType<Terminal['writeln']>
|
||||
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
|
||||
clear: () => void
|
||||
terminalRef: React.RefObject<Terminal | null>
|
||||
};
|
||||
|
||||
export interface XTermProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput' | 'onResize'> {
|
||||
onInput?: (data: string) => void;
|
||||
onKey?: (key: string, event: KeyboardEvent) => void;
|
||||
onResize?: (cols: number, rows: number) => void; // 新增属性
|
||||
onInput?: (data: string) => void
|
||||
onKey?: (key: string, event: KeyboardEvent) => void
|
||||
onResize?: (cols: number, rows: number) => void // 新增属性
|
||||
}
|
||||
|
||||
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
@ -35,17 +35,13 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
const { className, onInput, onKey, onResize, ...rest } = props;
|
||||
const { theme } = useTheme();
|
||||
useEffect(() => {
|
||||
// 根据屏幕宽度决定字体大小,手机端使用更小的字体
|
||||
const isMobile = window.innerWidth < 768;
|
||||
const fontSize = isMobile ? 11 : 14;
|
||||
|
||||
const terminal = new Terminal({
|
||||
allowTransparency: true,
|
||||
fontFamily:
|
||||
'"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace',
|
||||
cursorInactiveStyle: 'outline',
|
||||
drawBoldTextInBrightColors: false,
|
||||
fontSize: fontSize,
|
||||
fontSize: 14,
|
||||
lineHeight: 1.2,
|
||||
});
|
||||
terminalRef.current = terminal;
|
||||
@ -60,10 +56,7 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(domRef.current!);
|
||||
|
||||
// 只在非手机端使用 Canvas 渲染器,手机端使用默认 DOM 渲染器以避免渲染问题
|
||||
if (!isMobile) {
|
||||
terminal.loadAddon(new CanvasAddon());
|
||||
}
|
||||
terminal.loadAddon(new CanvasAddon());
|
||||
terminal.onData((data) => {
|
||||
if (onInput) {
|
||||
onInput(data);
|
||||
|
||||
@ -1,72 +1,107 @@
|
||||
import {
|
||||
LuActivity,
|
||||
LuFileText,
|
||||
LuFolderOpen,
|
||||
LuInfo,
|
||||
LuLayoutDashboard,
|
||||
LuSettings,
|
||||
LuSignal,
|
||||
LuTerminal,
|
||||
LuZap,
|
||||
} from 'react-icons/lu';
|
||||
BugIcon2,
|
||||
FileIcon,
|
||||
InfoIcon,
|
||||
LogIcon,
|
||||
RouteIcon,
|
||||
SettingsIcon,
|
||||
SignalTowerIcon,
|
||||
TerminalIcon,
|
||||
} from '@/components/icons';
|
||||
|
||||
export type SiteConfig = typeof siteConfig;
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
autoOpen?: boolean;
|
||||
href?: string;
|
||||
items?: MenuItem[];
|
||||
customIcon?: string;
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
autoOpen?: boolean
|
||||
href?: string
|
||||
items?: MenuItem[]
|
||||
customIcon?: string
|
||||
}
|
||||
|
||||
export const siteConfig = {
|
||||
name: 'NapCat',
|
||||
name: 'NapCat WebUI',
|
||||
description: 'NapCat WebUI.',
|
||||
navItems: [
|
||||
{
|
||||
label: '基础信息',
|
||||
icon: <LuLayoutDashboard className='w-5 h-5' />,
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<RouteIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/',
|
||||
},
|
||||
{
|
||||
label: '网络配置',
|
||||
icon: <LuSignal className='w-5 h-5' />,
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<SignalTowerIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/network',
|
||||
},
|
||||
{
|
||||
label: '其他配置',
|
||||
icon: <LuSettings className='w-5 h-5' />,
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<SettingsIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/config',
|
||||
},
|
||||
{
|
||||
label: '猫猫日志',
|
||||
icon: <LuFileText className='w-5 h-5' />,
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<LogIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/logs',
|
||||
},
|
||||
{
|
||||
label: '接口调试',
|
||||
icon: <LuActivity className='w-5 h-5' />,
|
||||
href: '/debug/http',
|
||||
},
|
||||
{
|
||||
label: '实时调试',
|
||||
icon: <LuZap className='w-5 h-5' />,
|
||||
href: '/debug/ws',
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<BugIcon2 />
|
||||
</div>
|
||||
),
|
||||
items: [
|
||||
{
|
||||
label: 'HTTP',
|
||||
href: '/debug/http',
|
||||
},
|
||||
{
|
||||
label: 'Websocket',
|
||||
href: '/debug/ws',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '文件管理',
|
||||
icon: <LuFolderOpen className='w-5 h-5' />,
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<FileIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/file_manager',
|
||||
},
|
||||
{
|
||||
label: '系统终端',
|
||||
icon: <LuTerminal className='w-5 h-5' />,
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<TerminalIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/terminal',
|
||||
},
|
||||
{
|
||||
label: '关于我们',
|
||||
icon: <LuInfo className='w-5 h-5' />,
|
||||
icon: (
|
||||
<div className='w-5 h-5'>
|
||||
<InfoIcon />
|
||||
</div>
|
||||
),
|
||||
href: '/about',
|
||||
},
|
||||
] as MenuItem[],
|
||||
|
||||
91
packages/napcat-webui-frontend/src/contexts/songs.tsx
Normal file
91
packages/napcat-webui-frontend/src/contexts/songs.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
// Songs Context
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { createContext, useEffect, useState } from 'react';
|
||||
|
||||
import { PlayMode } from '@/const/enum';
|
||||
import key from '@/const/key';
|
||||
|
||||
import AudioPlayer from '@/components/audio_player';
|
||||
|
||||
import { get163MusicListSongs, getNextMusic } from '@/utils/music';
|
||||
|
||||
import type { FinalMusic } from '@/types/music';
|
||||
|
||||
export interface MusicContextProps {
|
||||
setListId: (id: string) => void
|
||||
listId: string
|
||||
onNext: () => void
|
||||
onPrevious: () => void
|
||||
}
|
||||
|
||||
export interface MusicProviderProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const AudioContext = createContext<MusicContextProps>({
|
||||
setListId: () => {},
|
||||
listId: '5438670983',
|
||||
onNext: () => {},
|
||||
onPrevious: () => {},
|
||||
});
|
||||
|
||||
const AudioProvider: React.FC<MusicProviderProps> = ({ children }) => {
|
||||
const [listId, setListId] = useLocalStorage(key.musicID, '5438670983');
|
||||
const [musicList, setMusicList] = useState<FinalMusic[]>([]);
|
||||
const [musicId, setMusicId] = useState<number>(0);
|
||||
const [playMode, setPlayMode] = useState<PlayMode>(PlayMode.Loop);
|
||||
const music = musicList.find((music) => music.id === musicId);
|
||||
const [token] = useLocalStorage(key.token, '');
|
||||
const onNext = () => {
|
||||
const nextID = getNextMusic(musicList, musicId, playMode);
|
||||
setMusicId(nextID);
|
||||
};
|
||||
const onPrevious = () => {
|
||||
const index = musicList.findIndex((music) => music.id === musicId);
|
||||
if (index === 0) {
|
||||
setMusicId(musicList[musicList.length - 1].id);
|
||||
} else {
|
||||
setMusicId(musicList[index - 1].id);
|
||||
}
|
||||
};
|
||||
const onPlayEnd = () => {
|
||||
const nextID = getNextMusic(musicList, musicId, playMode);
|
||||
setMusicId(nextID);
|
||||
};
|
||||
const changeMode = (mode: PlayMode) => {
|
||||
setPlayMode(mode);
|
||||
};
|
||||
const fetchMusicList = async (id: string) => {
|
||||
const res = await get163MusicListSongs(id);
|
||||
setMusicList(res);
|
||||
setMusicId(res[0].id);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (listId && token) fetchMusicList(listId);
|
||||
}, [listId, token]);
|
||||
return (
|
||||
<AudioContext.Provider
|
||||
value={{
|
||||
setListId,
|
||||
listId,
|
||||
onNext,
|
||||
onPrevious,
|
||||
}}
|
||||
>
|
||||
<AudioPlayer
|
||||
title={music?.title}
|
||||
src={music?.url || ''}
|
||||
artist={music?.artist}
|
||||
cover={music?.cover}
|
||||
mode={playMode}
|
||||
pressNext={onNext}
|
||||
pressPrevious={onPrevious}
|
||||
onPlayEnd={onPlayEnd}
|
||||
onChangeMode={changeMode}
|
||||
/>
|
||||
{children}
|
||||
</AudioContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioProvider;
|
||||
11
packages/napcat-webui-frontend/src/hooks/use-music.ts
Normal file
11
packages/napcat-webui-frontend/src/hooks/use-music.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
import { AudioContext } from '@/contexts/songs';
|
||||
|
||||
const useMusic = () => {
|
||||
const music = React.useContext(AudioContext);
|
||||
|
||||
return music;
|
||||
};
|
||||
|
||||
export default useMusic;
|
||||
@ -1,4 +1,5 @@
|
||||
import type { Selection } from '@react-types/shared';
|
||||
import { useReactive } from 'ahooks';
|
||||
import { useCallback, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import useWebSocket, { ReadyState } from 'react-use-websocket';
|
||||
@ -10,8 +11,8 @@ import { isOB11Event, isOB11RequestResponse } from '@/utils/onebot';
|
||||
import type { AllOB11WsResponse } from '@/types/onebot';
|
||||
|
||||
export { ReadyState } from 'react-use-websocket';
|
||||
export function useWebSocketDebug (url: string, token: string, connectOnMount: boolean = true) {
|
||||
const [messageHistory, setMessageHistory] = useState<AllOB11WsResponse[]>([]);
|
||||
export function useWebSocketDebug (url: string, token: string) {
|
||||
const messageHistory = useReactive<AllOB11WsResponse[]>([]);
|
||||
const [filterTypes, setFilterTypes] = useState<Selection>('all');
|
||||
|
||||
const filteredMessages = messageHistory.filter((msg) => {
|
||||
@ -21,18 +22,11 @@ export function useWebSocketDebug (url: string, token: string, connectOnMount: b
|
||||
return false;
|
||||
});
|
||||
|
||||
const { sendMessage, readyState } = useWebSocket(connectOnMount ? url : null, {
|
||||
share: false,
|
||||
const { sendMessage, readyState } = useWebSocket(url, {
|
||||
onMessage: useCallback((event: WebSocketEventMap['message']) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
setMessageHistory((prev) => {
|
||||
const newHistory = [data, ...prev];
|
||||
if (newHistory.length > 500) {
|
||||
return newHistory.slice(0, 500);
|
||||
}
|
||||
return newHistory;
|
||||
});
|
||||
messageHistory.unshift(data);
|
||||
} catch (_error) {
|
||||
toast.error('WebSocket 消息解析失败');
|
||||
}
|
||||
@ -45,7 +39,7 @@ export function useWebSocketDebug (url: string, token: string, connectOnMount: b
|
||||
console.error('WebSocket error:', event);
|
||||
},
|
||||
onOpen: () => {
|
||||
setMessageHistory([]);
|
||||
messageHistory.splice(0, messageHistory.length);
|
||||
},
|
||||
});
|
||||
|
||||
@ -56,10 +50,6 @@ export function useWebSocketDebug (url: string, token: string, connectOnMount: b
|
||||
sendMessage(msg);
|
||||
};
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessageHistory([]);
|
||||
}, []);
|
||||
|
||||
const FilterMessagesType = renderFilterMessageType(
|
||||
filterTypes,
|
||||
setFilterTypes
|
||||
@ -73,6 +63,5 @@ export function useWebSocketDebug (url: string, token: string, connectOnMount: b
|
||||
filterTypes,
|
||||
setFilterTypes,
|
||||
FilterMessagesType,
|
||||
clearMessages,
|
||||
};
|
||||
}
|
||||
|
||||
@ -79,11 +79,10 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
|
||||
}, [location.pathname]);
|
||||
return (
|
||||
<div
|
||||
className='h-screen relative flex items-stretch overflow-hidden'
|
||||
className='h-screen relative flex bg-primary-50 dark:bg-black items-stretch'
|
||||
style={{
|
||||
backgroundImage: b64img ? `url(${b64img})` : undefined,
|
||||
backgroundImage: `url(${b64img})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<SideBar
|
||||
@ -91,17 +90,14 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
|
||||
open={openSideBar}
|
||||
onClose={() => setOpenSideBar(false)}
|
||||
/>
|
||||
<motion.div
|
||||
layout
|
||||
<div
|
||||
ref={contentRef}
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className={clsx(
|
||||
'flex-1 overflow-y-auto',
|
||||
'transition-all duration-300 ease-in-out',
|
||||
openSideBar ? 'ml-0' : 'ml-0',
|
||||
'pb-10 md:pb-0'
|
||||
'overflow-y-auto flex-1 rounded-md m-1 bg-content1 pb-10 md:pb-0',
|
||||
openSideBar ? 'ml-0' : 'ml-1',
|
||||
!b64img && 'shadow-inner',
|
||||
b64img && '!bg-opacity-50 backdrop-blur-none dark:bg-background',
|
||||
'overflow-x-hidden'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@ -113,12 +109,15 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
|
||||
'z-30 m-2 mb-0 sticky top-2 left-0'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
<motion.div
|
||||
className={clsx(
|
||||
'mr-1 ease-in-out ml-0 md:relative z-50 md:z-auto',
|
||||
openSideBar && 'pl-2',
|
||||
openSideBar && 'pl-2 absolute',
|
||||
'md:!ml-0 md:pl-0'
|
||||
)}
|
||||
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
|
||||
initial={{ marginLeft: 0 }}
|
||||
animate={{ marginLeft: openSideBar ? '15rem' : 0 }}
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
@ -128,7 +127,7 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
|
||||
>
|
||||
{openSideBar ? <MdMenuOpen size={24} /> : <MdMenu size={24} />}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
<Breadcrumbs isDisabled size='lg'>
|
||||
{title?.map((item, index) => (
|
||||
<BreadcrumbItem key={index}>
|
||||
@ -150,7 +149,7 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
|
||||
<ErrorBoundary fallbackRender={errorFallbackRender}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,20 +1,19 @@
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Divider } from '@heroui/divider';
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Image } from '@heroui/image';
|
||||
import { Link } from '@heroui/link';
|
||||
import { Skeleton } from '@heroui/skeleton';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useRequest } from 'ahooks';
|
||||
import {
|
||||
BsCodeSlash,
|
||||
BsCpu,
|
||||
BsGithub,
|
||||
BsGlobe,
|
||||
BsPlugin,
|
||||
BsTelegram,
|
||||
BsTencentQq
|
||||
} from 'react-icons/bs';
|
||||
import { IoDocument, IoRocketSharp } from 'react-icons/io5';
|
||||
import { useMemo } from 'react';
|
||||
import { BsTelegram, BsTencentQq } from 'react-icons/bs';
|
||||
import { IoDocument } from 'react-icons/io5';
|
||||
|
||||
import HoverTiltedCard from '@/components/hover_titled_card';
|
||||
import NapCatRepoInfo from '@/components/napcat_repo_info';
|
||||
import RotatingText from '@/components/rotating_text';
|
||||
|
||||
import { usePreloadImages } from '@/hooks/use-preload-images';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
@ -23,168 +22,184 @@ function VersionInfo () {
|
||||
const { data, loading, error } = useRequest(WebUIManager.GetNapCatVersion);
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
{error ? (
|
||||
<Chip color="danger" variant="flat" size="sm">{error.message}</Chip>
|
||||
) : loading ? (
|
||||
<Spinner size='sm' color="default" />
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip size="sm" color="default" variant="flat" className="text-default-500">WebUI v0.0.6</Chip>
|
||||
<Chip size="sm" color="primary" variant="flat">Core {data?.version}</Chip>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='flex items-center gap-2 text-2xl font-bold'>
|
||||
<div className='text-primary-500 drop-shadow-md'>NapCat</div>
|
||||
{error
|
||||
? (
|
||||
error.message
|
||||
)
|
||||
: loading
|
||||
? (
|
||||
<Spinner size='sm' />
|
||||
)
|
||||
: (
|
||||
<RotatingText
|
||||
texts={['WebUI', data?.version ?? '']}
|
||||
mainClassName='overflow-hidden flex items-center bg-primary-500 px-2 rounded-lg text-default-50 shadow-md'
|
||||
staggerFrom='last'
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '-120%' }}
|
||||
staggerDuration={0.025}
|
||||
splitLevelClassName='overflow-hidden'
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 400 }}
|
||||
rotationInterval={2000}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AboutPage () {
|
||||
const features = [
|
||||
{
|
||||
icon: <IoRocketSharp size={20} />,
|
||||
title: '高性能架构',
|
||||
desc: 'Node.js + Native 混合架构,资源占用低,响应速度快。',
|
||||
className: 'bg-primary-50 text-primary'
|
||||
},
|
||||
{
|
||||
icon: <BsGlobe size={20} />,
|
||||
title: '全平台支持',
|
||||
desc: '适配 Windows、Linux 及 Docker 环境。',
|
||||
className: 'bg-success-50 text-success'
|
||||
},
|
||||
{
|
||||
icon: <BsCodeSlash size={20} />,
|
||||
title: 'OneBot 11',
|
||||
desc: '深度集成标准协议,兼容现有生态。',
|
||||
className: 'bg-warning-50 text-warning'
|
||||
},
|
||||
{
|
||||
icon: <BsPlugin size={20} />,
|
||||
title: '极易扩展',
|
||||
desc: '提供丰富的 API 接口与 WebHook 支持。',
|
||||
className: 'bg-secondary-50 text-secondary'
|
||||
}
|
||||
];
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const links = [
|
||||
{ icon: <BsGithub />, name: 'GitHub', href: 'https://github.com/NapNeko/NapCatQQ' },
|
||||
{ icon: <BsTelegram />, name: 'Telegram', href: 'https://t.me/napcatqq' },
|
||||
{ icon: <BsTencentQq />, name: 'QQ 群 1', href: 'https://qm.qq.com/q/F9cgs1N3Mc' },
|
||||
{ icon: <BsTencentQq />, name: 'QQ 群 2', href: 'https://qm.qq.com/q/hSt0u9PVn' },
|
||||
{ icon: <IoDocument />, name: '文档', href: 'https://napcat.napneko.icu/' },
|
||||
];
|
||||
const imageUrls = useMemo(
|
||||
() => [
|
||||
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
|
||||
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=dark',
|
||||
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
|
||||
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=dark',
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const cardStyle = "bg-default/40 backdrop-blur-lg border-none shadow-none";
|
||||
const { loadedUrls, isLoading } = usePreloadImages(imageUrls);
|
||||
|
||||
const getImageUrl = useMemo(
|
||||
() => (baseUrl: string) => {
|
||||
const theme = isDark ? 'dark' : 'light';
|
||||
const fullUrl = baseUrl.replace(
|
||||
/color_scheme=(?:light|dark)/,
|
||||
`color_scheme=${theme}`
|
||||
);
|
||||
return isLoading ? null : loadedUrls[fullUrl] ? fullUrl : null;
|
||||
},
|
||||
[isDark, isLoading, loadedUrls]
|
||||
);
|
||||
|
||||
const renderImage = useMemo(
|
||||
() => (baseUrl: string, alt: string) => {
|
||||
const imageUrl = getImageUrl(baseUrl);
|
||||
|
||||
if (!imageUrl) {
|
||||
return <Skeleton className='h-16 rounded-lg' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
className='flex-1 pointer-events-none select-none rounded-none'
|
||||
src={imageUrl}
|
||||
alt={alt}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[getImageUrl]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col h-full w-full gap-6 p-2 md:p-6'>
|
||||
<title>关于 - NapCat WebUI</title>
|
||||
|
||||
{/* 头部标题区 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-3 text-default-900">
|
||||
<Image src={logo} alt="NapCat Logo" width={32} height={32} />
|
||||
关于 NapCat
|
||||
</h1>
|
||||
<div className="flex items-center gap-4 text-small text-default-500">
|
||||
<p>现代化、轻量级的 QQ 机器人框架</p>
|
||||
<Divider orientation="vertical" className="h-4" />
|
||||
<VersionInfo />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider className="opacity-50" />
|
||||
|
||||
{/* 主内容区:双栏布局 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-grow">
|
||||
|
||||
{/* 左侧:介绍与特性 */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card shadow="sm" className={cardStyle}>
|
||||
<CardHeader className="pb-0 pt-4 px-4 flex-col items-start">
|
||||
<h2 className="text-lg font-bold">项目简介</h2>
|
||||
</CardHeader>
|
||||
<CardBody className="py-4 text-default-600 leading-relaxed space-y-2">
|
||||
<p>
|
||||
NapCat (瞌睡猫) 是一个致力于打破 QQ 机器人开发壁垒的开源项目。我们利用 NTQQ 的底层能力,
|
||||
构建了一个无需 GUI 即可在服务器端稳定运行的 Headless 框架。
|
||||
<>
|
||||
<title>关于 NapCat WebUI</title>
|
||||
<section className='max-w-7xl py-8 md:py-10 px-5 mx-auto space-y-10'>
|
||||
<div className='w-full flex flex-col md:flex-row gap-4'>
|
||||
<div className='flex flex-col md:flex-row items-center'>
|
||||
<HoverTiltedCard imageSrc={logo} overlayContent='' />
|
||||
</div>
|
||||
<div className='flex-1 flex flex-col gap-2 py-2'>
|
||||
<VersionInfo />
|
||||
<div className='space-y-1'>
|
||||
<p className='font-bold text-primary-400'>NapCat 是什么?</p>
|
||||
<p className='text-default-800'>
|
||||
基于TypeScript构建的Bot框架,通过相应的启动器或者框架,主动调用QQ
|
||||
Node模块提供给客户端的接口,实现Bot的功能.
|
||||
</p>
|
||||
<p>
|
||||
无论是个人开发者还是企业用户,NapCat 都能提供开箱即用的 OneBot 11 协议支持,
|
||||
助您快速将创意转化为现实。
|
||||
<p className='font-bold text-primary-400'>魔法版介绍</p>
|
||||
<p className='text-default-800'>
|
||||
猫猫框架通过魔法的手段获得了 QQ 的发送消息、接收消息等接口。
|
||||
为了方便使用,猫猫框架将通过一种名为 OneBot 的约定将你的 HTTP /
|
||||
WebSocket 请求按照规范读取,
|
||||
再去调用猫猫框架所获得的QQ发送接口之类的接口。
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{features.map((item, index) => (
|
||||
<Card key={index} shadow="sm" className={cardStyle}>
|
||||
<CardBody className="flex flex-row items-start gap-4 p-4">
|
||||
<div className={`p-3 rounded-lg ${item.className}`}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-default-900">{item.title}</h3>
|
||||
<p className="text-small text-default-500 mt-1">{item.desc}</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:信息与链接 */}
|
||||
<div className="space-y-6">
|
||||
<Card shadow="sm" className={cardStyle}>
|
||||
<CardHeader className="pb-0 pt-4 px-4">
|
||||
<h2 className="text-lg font-bold">相关资源</h2>
|
||||
</CardHeader>
|
||||
<CardBody className="py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{links.map((link, idx) => (
|
||||
<Link
|
||||
key={idx}
|
||||
isExternal
|
||||
href={link.href}
|
||||
className="flex items-center justify-between p-3 rounded-xl hover:bg-default-100/50 transition-colors text-default-600"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
{link.icon}
|
||||
{link.name}
|
||||
</span>
|
||||
<span className="text-tiny text-default-400">跳转 →</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex flex-row gap-2 flex-wrap justify-around'>
|
||||
<Card
|
||||
as={Link}
|
||||
shadow='sm'
|
||||
isPressable
|
||||
isExternal
|
||||
href='https://qm.qq.com/q/F9cgs1N3Mc'
|
||||
>
|
||||
<CardBody className='flex-row items-center gap-2'>
|
||||
<span className='p-2 rounded-small bg-primary-50 text-primary-500'>
|
||||
<BsTencentQq size={16} />
|
||||
</span>
|
||||
<span>官方社群1</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" className={cardStyle}>
|
||||
<CardHeader className="pb-0 pt-4 px-4">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<BsCpu /> 技术栈
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardBody className="py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['TypeScript', 'React', 'Vite', 'Node.js', 'Electron', 'HeroUI'].map((tech) => (
|
||||
<Chip key={tech} size="sm" variant="flat" className="bg-default-100/50 text-default-600">
|
||||
{tech}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
<Card
|
||||
as={Link}
|
||||
shadow='sm'
|
||||
isPressable
|
||||
isExternal
|
||||
href='https://qm.qq.com/q/hSt0u9PVn'
|
||||
>
|
||||
<CardBody className='flex-row items-center gap-2'>
|
||||
<span className='p-2 rounded-small bg-primary-50 text-primary-500'>
|
||||
<BsTencentQq size={16} />
|
||||
</span>
|
||||
<span>官方社群2</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
as={Link}
|
||||
shadow='sm'
|
||||
isPressable
|
||||
isExternal
|
||||
href='https://t.me/napcatqq'
|
||||
>
|
||||
<CardBody className='flex-row items-center gap-2'>
|
||||
<span className='p-2 rounded-small bg-primary-50 text-primary-500'>
|
||||
<BsTelegram size={16} />
|
||||
</span>
|
||||
<span>Telegram</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
as={Link}
|
||||
shadow='sm'
|
||||
isPressable
|
||||
isExternal
|
||||
href='https://napcat.napneko.icu/'
|
||||
>
|
||||
<CardBody className='flex-row items-center gap-2'>
|
||||
<span className='p-2 rounded-small bg-primary-50 text-primary-500'>
|
||||
<IoDocument size={16} />
|
||||
</span>
|
||||
<span>使用文档</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col md:flex-row md:items-start gap-4'>
|
||||
<div className='w-full flex flex-col gap-4'>
|
||||
{renderImage(
|
||||
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
|
||||
'Contributors'
|
||||
)}
|
||||
{renderImage(
|
||||
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
|
||||
'Activity Trends'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部版权 - 移出 grid 布局 */}
|
||||
<div className="w-full text-center text-tiny text-default-400 py-4 mt-auto flex flex-col items-center gap-1">
|
||||
<p className="flex items-center justify-center gap-1">
|
||||
Made with <span className="text-danger">❤️</span> by NapCat Team
|
||||
</p>
|
||||
<p>MIT License © {new Date().getFullYear()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<NapCatRepoInfo />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import key from '@/const/key';
|
||||
|
||||
import ChangePasswordCard from './change_password';
|
||||
import LoginConfigCard from './login';
|
||||
import OneBotConfigCard from './onebot';
|
||||
@ -14,29 +12,24 @@ import ThemeConfigCard from './theme';
|
||||
import WebUIConfigCard from './webui';
|
||||
|
||||
export interface ConfigPageProps {
|
||||
children?: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children?: React.ReactNode
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const ConfigPageItem: React.FC<ConfigPageProps> = ({
|
||||
const ConfingPageItem: React.FC<ConfigPageProps> = ({
|
||||
children,
|
||||
size = 'md',
|
||||
}) => {
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
return (
|
||||
<Card className={clsx(
|
||||
'w-full mx-auto backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm rounded-2xl transition-all',
|
||||
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40',
|
||||
{
|
||||
'max-w-xl': size === 'sm',
|
||||
'max-w-3xl': size === 'md',
|
||||
'max-w-6xl': size === 'lg',
|
||||
}
|
||||
)}>
|
||||
<CardBody className='py-6 px-4 md:py-8 md:px-12'>
|
||||
<div className='w-full flex flex-col gap-5'>
|
||||
<Card className='bg-opacity-50 backdrop-blur-sm'>
|
||||
<CardBody className='items-center py-5'>
|
||||
<div
|
||||
className={clsx('max-w-full flex flex-col gap-2', {
|
||||
'w-72': size === 'sm',
|
||||
'w-96': size === 'md',
|
||||
'w-[32rem]': size === 'lg',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CardBody>
|
||||
@ -45,6 +38,7 @@ const ConfigPageItem: React.FC<ConfigPageProps> = ({
|
||||
};
|
||||
|
||||
export default function ConfigPage () {
|
||||
const isMediumUp = useMediaQuery({ minWidth: 768 });
|
||||
const navigate = useNavigate();
|
||||
const search = useSearchParams({
|
||||
tab: 'onebot',
|
||||
@ -52,55 +46,53 @@ export default function ConfigPage () {
|
||||
const tab = search.get('tab') ?? 'onebot';
|
||||
|
||||
return (
|
||||
<section className='w-full max-w-[1200px] mx-auto py-4 md:py-8 px-2 md:px-6 relative'>
|
||||
<title>其它配置 - NapCat WebUI</title>
|
||||
<section className='w-[1000px] max-w-full md:mx-auto gap-4 py-8 px-2 md:py-10'>
|
||||
<Tabs
|
||||
aria-label='config tab'
|
||||
fullWidth={false}
|
||||
fullWidth
|
||||
className='w-full'
|
||||
isVertical={isMediumUp}
|
||||
selectedKey={tab}
|
||||
onSelectionChange={(key) => {
|
||||
navigate(`/config?tab=${key}`);
|
||||
}}
|
||||
classNames={{
|
||||
base: 'w-full flex-col items-center',
|
||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-2xl p-1.5 shadow-sm border border-white/20 dark:border-white/5 mb-4 md:mb-8 w-full md:w-fit mx-auto overflow-x-auto hide-scrollbar',
|
||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm rounded-xl',
|
||||
tab: 'h-9 px-4 md:px-6',
|
||||
tabContent: 'text-default-600 dark:text-default-300 font-medium group-data-[selected=true]:text-primary',
|
||||
panel: 'w-full relative p-0',
|
||||
tabList: 'sticky flex top-14 bg-opacity-50 backdrop-blur-sm',
|
||||
panel: 'w-full relative',
|
||||
base: 'md:!w-auto flex-grow-0 flex-shrink-0 mr-0',
|
||||
cursor: 'bg-opacity-60 backdrop-blur-sm',
|
||||
}}
|
||||
>
|
||||
<Tab title='OneBot配置' key='onebot'>
|
||||
<ConfigPageItem>
|
||||
<ConfingPageItem>
|
||||
<OneBotConfigCard />
|
||||
</ConfigPageItem>
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
<Tab title='服务器配置' key='server'>
|
||||
<ConfigPageItem>
|
||||
<ConfingPageItem>
|
||||
<ServerConfigCard />
|
||||
</ConfigPageItem>
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
<Tab title='WebUI配置' key='webui'>
|
||||
<ConfigPageItem>
|
||||
<ConfingPageItem>
|
||||
<WebUIConfigCard />
|
||||
</ConfigPageItem>
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
<Tab title='登录配置' key='login'>
|
||||
<ConfigPageItem>
|
||||
<ConfingPageItem>
|
||||
<LoginConfigCard />
|
||||
</ConfigPageItem>
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
<Tab title='修改密码' key='token'>
|
||||
<ConfigPageItem size='sm'>
|
||||
<ConfingPageItem>
|
||||
<ChangePasswordCard />
|
||||
</ConfigPageItem>
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
|
||||
<Tab title='主题配置' key='theme'>
|
||||
<ConfigPageItem size='lg'>
|
||||
<ConfingPageItem size='lg'>
|
||||
<ThemeConfigCard />
|
||||
</ConfigPageItem>
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</section>
|
||||
|
||||
@ -74,11 +74,6 @@ const OneBotConfigCard = () => {
|
||||
{...field}
|
||||
label='音乐签名地址'
|
||||
placeholder='请输入音乐签名地址'
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
|
||||
input: 'bg-transparent text-default-700 placeholder:text-default-400',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Input } from '@heroui/input';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
@ -6,7 +7,6 @@ import toast from 'react-hot-toast';
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import SwitchCard from '@/components/switch_card';
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
|
||||
@ -79,8 +79,8 @@ const ServerConfigCard = () => {
|
||||
<>
|
||||
<title>服务器配置 - NapCat WebUI</title>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>服务器配置</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full'>服务器配置</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name='host'
|
||||
@ -92,11 +92,6 @@ const ServerConfigCard = () => {
|
||||
description='服务器监听的IP地址,0.0.0.0表示监听所有网卡'
|
||||
isDisabled={!!configError}
|
||||
errorMessage={configError ? '获取配置失败' : undefined}
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
|
||||
input: 'bg-transparent text-default-700 placeholder:text-default-400',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -114,11 +109,6 @@ const ServerConfigCard = () => {
|
||||
isDisabled={!!configError}
|
||||
errorMessage={configError ? '获取配置失败' : undefined}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
|
||||
input: 'bg-transparent text-gray-800 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -136,42 +126,47 @@ const ServerConfigCard = () => {
|
||||
isDisabled={!!configError}
|
||||
errorMessage={configError ? '获取配置失败' : undefined}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
|
||||
input: 'bg-transparent text-gray-800 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>安全配置</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full'>安全配置</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name='disableWebUI'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
value={field.value}
|
||||
onValueChange={(value: boolean) => field.onChange(value)}
|
||||
disabled={!!configError}
|
||||
label='禁用WebUI'
|
||||
description='启用后将完全禁用WebUI服务,需要重启生效'
|
||||
/>
|
||||
<Switch
|
||||
isSelected={field.value}
|
||||
onValueChange={(value) => field.onChange(value)}
|
||||
isDisabled={!!configError}
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<span>禁用WebUI</span>
|
||||
<span className='text-sm text-default-400'>
|
||||
启用后将完全禁用WebUI服务,需要重启生效
|
||||
</span>
|
||||
</div>
|
||||
</Switch>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='disableNonLANAccess'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
value={field.value}
|
||||
onValueChange={(value: boolean) => field.onChange(value)}
|
||||
disabled={!!configError}
|
||||
label='禁用非局域网访问'
|
||||
description='启用后只允许局域网内的设备访问WebUI,提高安全性'
|
||||
/>
|
||||
<Switch
|
||||
isSelected={field.value}
|
||||
onValueChange={(value) => field.onChange(value)}
|
||||
isDisabled={!!configError}
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<span>禁用非局域网访问</span>
|
||||
<span className='text-sm text-default-400'>
|
||||
启用后只允许局域网内的设备访问WebUI,提高安全性
|
||||
</span>
|
||||
</div>
|
||||
</Switch>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Input } from '@heroui/input';
|
||||
import { Button } from '@heroui/button';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useEffect, useState } from 'react';
|
||||
@ -10,6 +11,8 @@ import SaveButtons from '@/components/button/save_buttons';
|
||||
import FileInput from '@/components/input/file_input';
|
||||
import ImageInput from '@/components/input/image_input';
|
||||
|
||||
import useMusic from '@/hooks/use-music';
|
||||
|
||||
import { siteConfig } from '@/config/site';
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
@ -40,6 +43,7 @@ const WebUIConfigCard = () => {
|
||||
} = useForm<IConfig['webui']>({
|
||||
defaultValues: {
|
||||
background: '',
|
||||
musicListID: '',
|
||||
customIcons: {},
|
||||
},
|
||||
});
|
||||
@ -49,6 +53,7 @@ const WebUIConfigCard = () => {
|
||||
key.customIcons,
|
||||
{}
|
||||
);
|
||||
const { setListId, listId } = useMusic();
|
||||
const [registrationOptions, setRegistrationOptions] = useState<any>(null);
|
||||
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
||||
|
||||
@ -70,12 +75,14 @@ const WebUIConfigCard = () => {
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setWebuiValue('musicListID', listId);
|
||||
setWebuiValue('customIcons', customIcons);
|
||||
setWebuiValue('background', b64img);
|
||||
};
|
||||
|
||||
const onSubmit = handleWebuiSubmit((data) => {
|
||||
try {
|
||||
setListId(data.musicListID);
|
||||
setCustomIcons(data.customIcons);
|
||||
setB64img(data.background);
|
||||
toast.success('保存成功');
|
||||
@ -87,19 +94,17 @@ const WebUIConfigCard = () => {
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [customIcons, b64img]);
|
||||
}, [listId, customIcons, b64img]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>WebUI配置 - NapCat WebUI</title>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>WebUI字体</div>
|
||||
<div className='flex-shrink-0 w-full'>WebUI字体</div>
|
||||
<div className='text-sm text-default-400'>
|
||||
此项不需要手动保存,上传成功后需清空网页缓存并刷新
|
||||
<FileInput
|
||||
label='中文字体'
|
||||
placeholder='选择字体文件'
|
||||
accept='.ttf,.otf,.woff,.woff2'
|
||||
onChange={async (file) => {
|
||||
try {
|
||||
await FileManager.uploadWebUIFont(file);
|
||||
@ -126,35 +131,40 @@ const WebUIConfigCard = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>背景图</div>
|
||||
<div className='flex-shrink-0 w-full'>WebUI音乐播放器</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name='background'
|
||||
name='musicListID'
|
||||
render={({ field }) => (
|
||||
<ImageInput
|
||||
<Input
|
||||
{...field}
|
||||
label='网易云音乐歌单ID(网页内音乐播放器)'
|
||||
placeholder='请输入歌单ID'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>自定义图标</div>
|
||||
<div className='flex-shrink-0 w-full'>背景图</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name='background'
|
||||
render={({ field }) => <ImageInput {...field} />}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div>自定义图标</div>
|
||||
{siteConfig.navItems.map((item) => (
|
||||
<Controller
|
||||
key={item.label}
|
||||
control={control}
|
||||
name={`customIcons.${item.label}`}
|
||||
render={({ field }) => (
|
||||
<ImageInput
|
||||
{...field}
|
||||
label={item.label}
|
||||
/>
|
||||
)}
|
||||
render={({ field }) => <ImageInput {...field} label={item.label} />}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>Passkey认证</div>
|
||||
<div className='flex-shrink-0 w-full'>Passkey认证</div>
|
||||
<div className='text-sm text-default-400 mb-2'>
|
||||
注册Passkey后,您可以更便捷地登录WebUI,无需每次输入token
|
||||
</div>
|
||||
|
||||
@ -1,198 +1,62 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
import { motion } from 'motion/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { TbSquareRoundedChevronLeftFilled } from 'react-icons/tb';
|
||||
|
||||
import key from '@/const/key';
|
||||
import oneBotHttpApi from '@/const/ob_api';
|
||||
import type { OneBotHttpApiPath } from '@/const/ob_api';
|
||||
import type { OneBotHttpApi } from '@/const/ob_api';
|
||||
|
||||
import OneBotApiDebug from '@/components/onebot/api/debug';
|
||||
import OneBotApiNavList from '@/components/onebot/api/nav_list';
|
||||
|
||||
export default function HttpDebug () {
|
||||
const [activeApi, setActiveApi] = useState<OneBotHttpApiPath | null>('/set_qq_profile');
|
||||
const [openApis, setOpenApis] = useState<OneBotHttpApiPath[]>(['/set_qq_profile']);
|
||||
const [selectedApi, setSelectedApi] =
|
||||
useState<keyof OneBotHttpApi>('/set_qq_profile');
|
||||
const data = oneBotHttpApi[selectedApi];
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [openSideBar, setOpenSideBar] = useState(true);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const [adapterName, setAdapterName] = useState<string>('');
|
||||
|
||||
// Auto-collapse sidebar on mobile initial load
|
||||
useEffect(() => {
|
||||
if (window.innerWidth < 768) {
|
||||
setOpenSideBar(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize Debug Adapter
|
||||
useEffect(() => {
|
||||
let currentAdapterName = '';
|
||||
|
||||
const initAdapter = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/Debug/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.code === 0) {
|
||||
currentAdapterName = data.data.adapterName;
|
||||
setAdapterName(currentAdapterName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create debug adapter:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initAdapter();
|
||||
|
||||
return () => {
|
||||
// 不再主动关闭 adapter,由后端自动管理活跃状态
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
const handleSelectApi = (api: OneBotHttpApiPath) => {
|
||||
if (!openApis.includes(api)) {
|
||||
setOpenApis([...openApis, api]);
|
||||
}
|
||||
setActiveApi(api);
|
||||
if (window.innerWidth < 768) {
|
||||
setOpenSideBar(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseTab = (e: React.MouseEvent, apiToRemove: OneBotHttpApiPath) => {
|
||||
e.stopPropagation();
|
||||
const newOpenApis = openApis.filter((api) => api !== apiToRemove);
|
||||
setOpenApis(newOpenApis);
|
||||
|
||||
if (activeApi === apiToRemove) {
|
||||
if (newOpenApis.length > 0) {
|
||||
// Switch to the last opened tab or the previous one?
|
||||
// Usually the one to the right or left. Let's pick the last one for simplicity or neighbor.
|
||||
// Finding index of removed api to pick neighbor is better UX, but last one is acceptable.
|
||||
setActiveApi(newOpenApis[newOpenApis.length - 1]);
|
||||
} else {
|
||||
setActiveApi(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
contentRef?.current?.scrollTo?.({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [selectedApi]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>HTTP调试 - NapCat WebUI</title>
|
||||
<div className='h-[calc(100vh-3.5rem)] p-0 md:p-4'>
|
||||
<div className={clsx(
|
||||
'h-full flex flex-col overflow-hidden transition-all relative',
|
||||
'rounded-none md:rounded-2xl',
|
||||
hasBackground
|
||||
? 'bg-white/5 dark:bg-black/5 backdrop-blur-sm'
|
||||
: 'bg-white/20 dark:bg-black/10 backdrop-blur-sm shadow-sm'
|
||||
)}>
|
||||
{/* Unifed Header */}
|
||||
<div className='h-12 border-b border-white/10 flex items-center justify-between px-4 z-50 bg-white/5 flex-shrink-0'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
className={clsx(
|
||||
"opacity-50 hover:opacity-100 transition-all",
|
||||
openSideBar && "text-primary opacity-100"
|
||||
)}
|
||||
onPress={() => setOpenSideBar(!openSideBar)}
|
||||
>
|
||||
<TbSquareRoundedChevronLeftFilled className={clsx("text-lg transform transition-transform", !openSideBar && "rotate-180")} />
|
||||
</Button>
|
||||
<h1 className={clsx(
|
||||
'text-sm font-bold tracking-tight',
|
||||
hasBackground ? 'text-white/80' : 'text-default-700 dark:text-gray-200'
|
||||
)}>接口调试</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 flex flex-row overflow-hidden relative'>
|
||||
<OneBotApiNavList
|
||||
data={oneBotHttpApi}
|
||||
selectedApi={activeApi || '' as any}
|
||||
onSelect={handleSelectApi}
|
||||
openSideBar={openSideBar}
|
||||
onToggle={setOpenSideBar}
|
||||
<OneBotApiNavList
|
||||
data={oneBotHttpApi}
|
||||
selectedApi={selectedApi}
|
||||
onSelect={setSelectedApi}
|
||||
openSideBar={openSideBar}
|
||||
/>
|
||||
<div ref={contentRef} className='flex-1 h-full overflow-x-hidden'>
|
||||
<motion.div
|
||||
className='absolute top-16 z-30 md:!ml-4'
|
||||
animate={{ marginLeft: openSideBar ? '16rem' : '1rem' }}
|
||||
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='primary'
|
||||
radius='md'
|
||||
variant='shadow'
|
||||
size='sm'
|
||||
onPress={() => setOpenSideBar(!openSideBar)}
|
||||
>
|
||||
<TbSquareRoundedChevronLeftFilled
|
||||
size={24}
|
||||
className={clsx(
|
||||
'transition-transform',
|
||||
openSideBar ? '' : 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className='flex-1 h-full overflow-hidden flex flex-col relative'
|
||||
>
|
||||
{/* Tab Bar */}
|
||||
<div className='flex items-center w-full overflow-x-auto no-scrollbar border-b border-white/5 bg-white/5 flex-shrink-0'>
|
||||
{openApis.map((api) => {
|
||||
const isActive = api === activeApi;
|
||||
const item = oneBotHttpApi[api];
|
||||
return (
|
||||
<div
|
||||
key={api}
|
||||
onClick={() => setActiveApi(api)}
|
||||
className={clsx(
|
||||
'group flex items-center gap-2 px-4 h-9 cursor-pointer border-r border-white/5 select-none transition-all min-w-[120px] max-w-[200px]',
|
||||
isActive
|
||||
? (hasBackground ? 'bg-white/10 text-white' : 'bg-white/40 dark:bg-white/5 text-primary font-medium')
|
||||
: 'opacity-50 hover:opacity-100 hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<span className={clsx(
|
||||
'text-[10px] font-bold uppercase tracking-wider',
|
||||
isActive ? 'opacity-100' : 'opacity-50'
|
||||
)}>POST</span>
|
||||
<span className='text-xs truncate flex-1'>{item?.description || api}</span>
|
||||
<div
|
||||
className={clsx(
|
||||
'p-0.5 rounded-full hover:bg-black/10 dark:hover:bg-white/20 transition-opacity',
|
||||
isActive ? 'opacity-50 hover:opacity-100' : 'opacity-0 group-hover:opacity-50'
|
||||
)}
|
||||
onClick={(e) => handleCloseTab(e, api)}
|
||||
>
|
||||
<IoClose size={12} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content Panels */}
|
||||
<div className='flex-1 relative overflow-hidden'>
|
||||
{activeApi === null && (
|
||||
<div className='h-full flex items-center justify-center text-default-400 text-sm opacity-50 select-none'>
|
||||
选择一个接口开始调试
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openApis.map((api) => (
|
||||
<div
|
||||
key={api}
|
||||
className={clsx(
|
||||
'h-full w-full absolute top-0 left-0 transition-opacity duration-200',
|
||||
api === activeApi ? 'opacity-100 z-10' : 'opacity-0 z-0 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<OneBotApiDebug
|
||||
path={api}
|
||||
data={oneBotHttpApi[api]}
|
||||
adapterName={adapterName}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</motion.div>
|
||||
<OneBotApiDebug path={selectedApi} data={data} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -2,10 +2,8 @@ import { Button } from '@heroui/button';
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Input } from '@heroui/input';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoFlash, IoFlashOff, IoRefresh } from 'react-icons/io5';
|
||||
|
||||
import key from '@/const/key';
|
||||
|
||||
@ -26,206 +24,69 @@ export default function WSDebug () {
|
||||
});
|
||||
const [inputUrl, setInputUrl] = useState(socketConfig.url);
|
||||
const [inputToken, setInputToken] = useState(socketConfig.token);
|
||||
const [shouldConnect, setShouldConnect] = useState(false);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const { sendMessage, readyState, FilterMessagesType, filteredMessages, clearMessages } =
|
||||
useWebSocketDebug(socketConfig.url, socketConfig.token, shouldConnect);
|
||||
|
||||
// Auto fetch adapter and set URL
|
||||
useEffect(() => {
|
||||
// 检查是否应该覆盖 URL
|
||||
const isDefaultUrl = socketConfig.url === defaultWsUrl || socketConfig.url === '';
|
||||
const isWebDebugUrl = socketConfig.url && socketConfig.url.includes('/api/Debug/ws');
|
||||
|
||||
if (!isDefaultUrl && !isWebDebugUrl) {
|
||||
setInputUrl(socketConfig.url);
|
||||
setInputToken(socketConfig.token);
|
||||
return; // 已经有自定义/有效的配置,跳过自动创建
|
||||
}
|
||||
|
||||
const initAdapter = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/Debug/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.code === 0) {
|
||||
//const adapterName = data.data.adapterName;
|
||||
const token = data.data.token;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
|
||||
if (token) {
|
||||
// URL 中不再包含 Token,Token 单独放入输入框
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/Debug/ws`;
|
||||
|
||||
setSocketConfig({
|
||||
url: wsUrl,
|
||||
token: token
|
||||
});
|
||||
setInputUrl(wsUrl);
|
||||
setInputToken(token);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create debug adapter:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initAdapter();
|
||||
}, []);
|
||||
const { sendMessage, readyState, FilterMessagesType, filteredMessages } =
|
||||
useWebSocketDebug(socketConfig.url, socketConfig.token);
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
// 允许以 / 开头的相对路径(如代理情况),以及标准的 ws/wss
|
||||
let finalUrl = inputUrl;
|
||||
if (finalUrl.startsWith('/')) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
finalUrl = `${protocol}//${window.location.host}${finalUrl}`;
|
||||
}
|
||||
|
||||
if (!finalUrl.startsWith('ws://') && !finalUrl.startsWith('wss://')) {
|
||||
if (!inputUrl.startsWith('ws://') && !inputUrl.startsWith('wss://')) {
|
||||
toast.error('WebSocket URL 不合法');
|
||||
return;
|
||||
}
|
||||
|
||||
setSocketConfig({
|
||||
url: finalUrl,
|
||||
url: inputUrl,
|
||||
token: inputToken,
|
||||
});
|
||||
setShouldConnect(true);
|
||||
}, [inputUrl, inputToken, setSocketConfig]);
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
setShouldConnect(false);
|
||||
}, []);
|
||||
|
||||
const handleResetConfig = useCallback(() => {
|
||||
setSocketConfig({ url: '', token: '' });
|
||||
// 刷新页面以重新触发初始逻辑
|
||||
window.location.reload();
|
||||
}, [setSocketConfig]);
|
||||
}, [inputUrl, inputToken]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>Websocket调试 - NapCat WebUI</title>
|
||||
<div className='h-[calc(100vh-4rem)] overflow-hidden flex flex-col p-2 md:p-4 gap-2 md:gap-4'>
|
||||
{/* Config Card */}
|
||||
<Card className={clsx(
|
||||
'flex-shrink-0 backdrop-blur-xl border shadow-sm',
|
||||
hasBackground
|
||||
? 'bg-white/10 dark:bg-black/10 border-white/40 dark:border-white/10'
|
||||
: 'bg-white/60 dark:bg-black/40 border-white/40 dark:border-white/10'
|
||||
)}>
|
||||
<CardBody className='gap-3 p-3 md:p-4'>
|
||||
{/* Connection Config */}
|
||||
<div className='grid gap-3 items-end md:grid-cols-[1fr_1fr_auto]'>
|
||||
<div className='h-[calc(100vh-4rem)] overflow-hidden flex flex-col'>
|
||||
<Card className='mx-2 mt-2 flex-shrink-0 bg-opacity-50 backdrop-blur-sm'>
|
||||
<CardBody className='gap-2'>
|
||||
<div className='grid gap-2 items-center md:grid-cols-5'>
|
||||
<Input
|
||||
className='col-span-2'
|
||||
label='WebSocket URL'
|
||||
type='text'
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
placeholder='输入 WebSocket URL'
|
||||
size='sm'
|
||||
variant='bordered'
|
||||
classNames={{
|
||||
inputWrapper: clsx(
|
||||
'backdrop-blur-sm border',
|
||||
hasBackground
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-default-100/50 border-default-200/50'
|
||||
),
|
||||
label: hasBackground ? 'text-white/80' : '',
|
||||
input: hasBackground ? 'text-white placeholder:text-white/50' : '',
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className='col-span-2'
|
||||
label='Token'
|
||||
type='text'
|
||||
value={inputToken}
|
||||
onChange={(e) => setInputToken(e.target.value)}
|
||||
placeholder='输入 Token (可选)'
|
||||
size='sm'
|
||||
variant='bordered'
|
||||
classNames={{
|
||||
inputWrapper: clsx(
|
||||
'backdrop-blur-sm border',
|
||||
hasBackground
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-default-100/50 border-default-200/50'
|
||||
),
|
||||
label: hasBackground ? 'text-white/80' : '',
|
||||
input: hasBackground ? 'text-white placeholder:text-white/50' : '',
|
||||
}}
|
||||
placeholder='输入 Token'
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<div className='flex-shrink-0 flex gap-2 col-span-2 md:col-span-1'>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="md"
|
||||
radius="full"
|
||||
color="warning"
|
||||
variant="flat"
|
||||
onPress={handleResetConfig}
|
||||
title="重置配置"
|
||||
>
|
||||
<IoRefresh className="text-xl" />
|
||||
</Button>
|
||||
<Button
|
||||
onPress={shouldConnect ? handleDisconnect : handleConnect}
|
||||
size='md'
|
||||
color='primary'
|
||||
onPress={handleConnect}
|
||||
size='lg'
|
||||
radius='full'
|
||||
color={shouldConnect ? 'danger' : 'primary'}
|
||||
className='font-bold shadow-lg min-w-[100px] flex-1'
|
||||
startContent={shouldConnect ? <IoFlashOff /> : <IoFlash />}
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
{shouldConnect ? '断开' : '连接'}
|
||||
连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className={clsx(
|
||||
'p-2.5 rounded-xl border transition-colors flex flex-col md:flex-row gap-3 md:items-center md:justify-between',
|
||||
hasBackground
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/50 dark:bg-white/5 border-white/20'
|
||||
)}>
|
||||
<div className='flex items-center gap-3 w-full md:w-auto'>
|
||||
<div className="flex-shrink-0">
|
||||
<WSStatus state={readyState} />
|
||||
</div>
|
||||
<div className='flex-1 md:w-56 overflow-hidden'>
|
||||
<div className='p-2 border border-default-100 bg-content1 bg-opacity-50 rounded-md dark:bg-[rgb(30,30,30)]'>
|
||||
<div className='grid gap-2 md:grid-cols-5 items-center md:w-fit'>
|
||||
<WSStatus state={readyState} />
|
||||
<div className='md:w-64 max-w-full col-span-2'>
|
||||
{FilterMessagesType}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2 justify-end w-full md:w-auto pt-1 md:pt-0 border-t border-white/5 md:border-t-0'>
|
||||
<Button
|
||||
size='sm'
|
||||
color='danger'
|
||||
variant='flat'
|
||||
radius='full'
|
||||
className='font-medium'
|
||||
onPress={clearMessages}
|
||||
>
|
||||
清空日志
|
||||
</Button>
|
||||
<OneBotSendModal sendMessage={sendMessage} />
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Message List */}
|
||||
<div className={clsx(
|
||||
'flex-1 overflow-hidden rounded-2xl border backdrop-blur-xl',
|
||||
hasBackground
|
||||
? 'bg-white/10 dark:bg-black/10 border-white/40 dark:border-white/10'
|
||||
: 'bg-white/60 dark:bg-black/40 border-white/40 dark:border-white/10'
|
||||
)}>
|
||||
<div className='flex-1 overflow-hidden'>
|
||||
<OneBotMessageList messages={filteredMessages} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,6 @@ import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import type { Selection, SortDescriptor } from '@react-types/shared';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { motion } from 'motion/react';
|
||||
import path from 'path-browserify';
|
||||
@ -15,7 +14,6 @@ import { TbTrash } from 'react-icons/tb';
|
||||
import { TiArrowBack } from 'react-icons/ti';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import key from '@/const/key';
|
||||
import CreateFileModal from '@/components/file_manage/create_file_modal';
|
||||
import FileEditModal from '@/components/file_manage/file_edit_modal';
|
||||
import FilePreviewModal from '@/components/file_manage/file_preview_modal';
|
||||
@ -330,139 +328,123 @@ export default function FileManagerPage () {
|
||||
useFsAccessApi: false, // 添加此选项以避免某些浏览器的文件系统API问题
|
||||
});
|
||||
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
return (
|
||||
<div className='h-full flex flex-col relative gap-4 w-full p-2 md:p-4'>
|
||||
<div className={clsx(
|
||||
'mb-4 flex flex-col md:flex-row items-stretch md:items-center gap-4 sticky top-14 z-10 backdrop-blur-sm shadow-sm py-2 px-4 rounded-xl transition-colors',
|
||||
hasBackground
|
||||
? 'bg-white/20 dark:bg-black/10 border border-white/40 dark:border-white/10'
|
||||
: 'bg-white/60 dark:bg-black/40 border border-white/40 dark:border-white/10'
|
||||
)}>
|
||||
<div className='flex items-center gap-2 overflow-x-auto hide-scrollbar pb-1 md:pb-0'>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
onPress={() => handleDirectoryClick('..')}
|
||||
className='text-lg min-w-8'
|
||||
>
|
||||
<TiArrowBack />
|
||||
</Button>
|
||||
<div className='p-4'>
|
||||
<div className='mb-4 flex items-center gap-4 sticky top-14 z-10 bg-content1 py-1'>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
onPress={() => handleDirectoryClick('..')}
|
||||
className='text-lg'
|
||||
>
|
||||
<TiArrowBack />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
onPress={() => setIsCreateModalOpen(true)}
|
||||
className='text-lg min-w-8'
|
||||
>
|
||||
<FiPlus />
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
onPress={() => setIsCreateModalOpen(true)}
|
||||
className='text-lg'
|
||||
>
|
||||
<FiPlus />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color='primary'
|
||||
isLoading={loading}
|
||||
size='sm'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
onPress={loadFiles}
|
||||
className='text-lg min-w-8'
|
||||
>
|
||||
<MdRefresh />
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
onPress={() => setShowUpload((prev) => !prev)}
|
||||
className='text-lg min-w-8'
|
||||
>
|
||||
<FiUpload />
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
isLoading={loading}
|
||||
size='sm'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
onPress={loadFiles}
|
||||
className='text-lg'
|
||||
>
|
||||
<MdRefresh />
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
onPress={() => setShowUpload((prev) => !prev)}
|
||||
className='text-lg'
|
||||
>
|
||||
<FiUpload />
|
||||
</Button>
|
||||
|
||||
{((selectedFiles instanceof Set && selectedFiles.size > 0) ||
|
||||
selectedFiles === 'all') && (
|
||||
<>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
onPress={handleBatchDelete}
|
||||
className='text-sm px-2 min-w-fit'
|
||||
startContent={<TbTrash className='text-lg' />}
|
||||
>
|
||||
(
|
||||
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
|
||||
)
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
onPress={() => {
|
||||
setMoveTargetPath('');
|
||||
setIsMoveModalOpen(true);
|
||||
}}
|
||||
className='text-sm px-2 min-w-fit'
|
||||
startContent={<FiMove className='text-lg' />}
|
||||
>
|
||||
(
|
||||
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
|
||||
)
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
onPress={handleBatchDownload}
|
||||
className='text-sm px-2 min-w-fit'
|
||||
startContent={<FiDownload className='text-lg' />}
|
||||
>
|
||||
(
|
||||
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
|
||||
)
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col md:flex-row flex-1 gap-2 overflow-hidden items-stretch md:items-center'>
|
||||
<Breadcrumbs className='flex-1 bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border border-white/20 px-2 py-2 rounded-lg overflow-x-auto hide-scrollbar whitespace-nowrap'>
|
||||
{currentPath.split('/').map((part, index, parts) => (
|
||||
<BreadcrumbItem
|
||||
key={part}
|
||||
isCurrent={index === parts.length - 1}
|
||||
onPress={() => {
|
||||
const newPath = parts.slice(0, index + 1).join('/');
|
||||
navigate(`/file_manager#${encodeURIComponent(newPath)}`);
|
||||
}}
|
||||
{((selectedFiles instanceof Set && selectedFiles.size > 0) ||
|
||||
selectedFiles === 'all') && (
|
||||
<>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
onPress={handleBatchDelete}
|
||||
className='text-sm'
|
||||
startContent={<TbTrash className='text-lg' />}
|
||||
>
|
||||
{part}
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='输入跳转路径'
|
||||
value={jumpPath}
|
||||
onChange={(e) => setJumpPath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && jumpPath.trim() !== '') {
|
||||
navigate(`/file_manager#${encodeURIComponent(jumpPath.trim())}`);
|
||||
}
|
||||
}}
|
||||
className='w-full md:w-64'
|
||||
classNames={{
|
||||
inputWrapper: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
(
|
||||
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
|
||||
)
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
onPress={() => {
|
||||
setMoveTargetPath('');
|
||||
setIsMoveModalOpen(true);
|
||||
}}
|
||||
className='text-sm'
|
||||
startContent={<FiMove className='text-lg' />}
|
||||
>
|
||||
(
|
||||
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
|
||||
)
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
onPress={handleBatchDownload}
|
||||
className='text-sm'
|
||||
startContent={<FiDownload className='text-lg' />}
|
||||
>
|
||||
(
|
||||
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
|
||||
)
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Breadcrumbs className='flex-1 shadow-small px-2 py-2 rounded-lg'>
|
||||
{currentPath.split('/').map((part, index, parts) => (
|
||||
<BreadcrumbItem
|
||||
key={part}
|
||||
isCurrent={index === parts.length - 1}
|
||||
onPress={() => {
|
||||
const newPath = parts.slice(0, index + 1).join('/');
|
||||
navigate(`/file_manager#${encodeURIComponent(newPath)}`);
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='输入跳转路径'
|
||||
value={jumpPath}
|
||||
onChange={(e) => setJumpPath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && jumpPath.trim() !== '') {
|
||||
navigate(`/file_manager#${encodeURIComponent(jumpPath.trim())}`);
|
||||
}
|
||||
}}
|
||||
className='ml-auto w-64'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useRequest } from 'ahooks';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import key from '@/const/key';
|
||||
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@ -95,9 +92,6 @@ const SystemStatusCard: React.FC<SystemStatusCardProps> = ({ setArchInfo }) => {
|
||||
|
||||
const DashboardIndexPage: React.FC = () => {
|
||||
const [archInfo, setArchInfo] = useState<string>();
|
||||
// @ts-ignore
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -111,10 +105,7 @@ const DashboardIndexPage: React.FC = () => {
|
||||
<SystemStatusCard setArchInfo={setArchInfo} />
|
||||
</div>
|
||||
<Networks />
|
||||
<Card className={clsx(
|
||||
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all',
|
||||
hasBackground ? 'bg-white/10 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
|
||||
)}>
|
||||
<Card className='bg-opacity-60 shadow-sm shadow-primary-100'>
|
||||
<CardBody>
|
||||
<Hitokoto />
|
||||
</CardBody>
|
||||
|
||||
@ -53,8 +53,8 @@ export default function LogsPage () {
|
||||
classNames={{
|
||||
panel: 'w-full flex-1 h-full py-0 flex flex-col gap-4',
|
||||
base: 'flex-shrink-0 !h-fit',
|
||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
|
||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
||||
tabList: 'bg-opacity-50 backdrop-blur-sm',
|
||||
cursor: 'bg-opacity-60 backdrop-blur-sm',
|
||||
}}
|
||||
>
|
||||
<Tab title='实时日志'>
|
||||
|
||||
@ -375,8 +375,9 @@ export default function NetworkPage () {
|
||||
<AddButton onOpen={handleClickCreate} />
|
||||
<Button
|
||||
isIconOnly
|
||||
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
||||
color='primary'
|
||||
radius='full'
|
||||
variant='flat'
|
||||
onPress={refresh}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
@ -387,8 +388,8 @@ export default function NetworkPage () {
|
||||
className='max-w-full'
|
||||
items={tabs}
|
||||
classNames={{
|
||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
|
||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
||||
tabList: 'bg-opacity-50 backdrop-blur-sm',
|
||||
cursor: 'bg-opacity-60 backdrop-blur-sm',
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
|
||||
@ -12,13 +12,10 @@ import {
|
||||
horizontalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Button } from '@heroui/button';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoAdd, IoClose } from 'react-icons/io5';
|
||||
|
||||
import key from '@/const/key';
|
||||
import { TabList, TabPanel, Tabs } from '@/components/tabs';
|
||||
import { SortableTab } from '@/components/tabs/sortable_tab.tsx';
|
||||
import { TerminalInstance } from '@/components/terminal/terminal-instance';
|
||||
@ -33,8 +30,6 @@ interface TerminalTab {
|
||||
export default function TerminalPage () {
|
||||
const [tabs, setTabs] = useState<TerminalTab[]>([]);
|
||||
const [selectedTab, setSelectedTab] = useState<string>('');
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
useEffect(() => {
|
||||
// 获取已存在的终端列表
|
||||
@ -117,40 +112,35 @@ export default function TerminalPage () {
|
||||
className='h-full overflow-hidden'
|
||||
>
|
||||
<div className='flex items-center gap-2 flex-shrink-0 flex-grow-0'>
|
||||
{tabs.length > 0 && (
|
||||
<TabList className={clsx(
|
||||
'flex-1 !overflow-x-auto w-full hide-scrollbar backdrop-blur-sm p-1 rounded-lg border border-white/20',
|
||||
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/40 dark:bg-black/20'
|
||||
)}>
|
||||
<SortableContext
|
||||
items={tabs}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<SortableTab
|
||||
key={tab.id}
|
||||
id={tab.id}
|
||||
value={tab.id}
|
||||
isSelected={selectedTab === tab.id}
|
||||
className='flex gap-2 items-center flex-shrink-0'
|
||||
<TabList className='flex-1 !overflow-x-auto w-full hide-scrollbar'>
|
||||
<SortableContext
|
||||
items={tabs}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<SortableTab
|
||||
key={tab.id}
|
||||
id={tab.id}
|
||||
value={tab.id}
|
||||
isSelected={selectedTab === tab.id}
|
||||
className='flex gap-2 items-center flex-shrink-0'
|
||||
>
|
||||
{tab.title}
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
variant='flat'
|
||||
size='sm'
|
||||
className='min-w-0 w-4 h-4 flex-shrink-0'
|
||||
onPress={() => closeTerminal(tab.id)}
|
||||
color={selectedTab === tab.id ? 'primary' : 'default'}
|
||||
>
|
||||
{tab.title}
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
variant='flat'
|
||||
size='sm'
|
||||
className='min-w-0 w-4 h-4 flex-shrink-0'
|
||||
onPress={() => closeTerminal(tab.id)}
|
||||
color={selectedTab === tab.id ? 'primary' : 'default'}
|
||||
>
|
||||
<IoClose />
|
||||
</Button>
|
||||
</SortableTab>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TabList>
|
||||
)}
|
||||
<IoClose />
|
||||
</Button>
|
||||
</SortableTab>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TabList>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='primary'
|
||||
|
||||
@ -6,8 +6,6 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
|
||||
import HoverEffectCard from '@/components/effect_card';
|
||||
import { title } from '@/components/primitives';
|
||||
import QrCodeLogin from '@/components/qr_code_login';
|
||||
@ -15,9 +13,9 @@ import QuickLogin from '@/components/quick_login';
|
||||
import type { QQItem } from '@/components/quick_login';
|
||||
import { ThemeSwitch } from '@/components/theme-switch';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
import PureLayout from '@/layouts/pure';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
export default function QQLoginPage () {
|
||||
const navigate = useNavigate();
|
||||
@ -114,12 +112,7 @@ export default function QQLoginPage () {
|
||||
<>
|
||||
<title>QQ登录 - NapCat WebUI</title>
|
||||
<PureLayout>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.5, type: 'spring', stiffness: 120, damping: 20 }}
|
||||
className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'
|
||||
>
|
||||
<div className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'>
|
||||
<HoverEffectCard
|
||||
className='items-center gap-4 pt-0 pb-6 bg-default-50'
|
||||
maxXRotation={3}
|
||||
@ -176,7 +169,7 @@ export default function QQLoginPage () {
|
||||
</Button>
|
||||
</CardBody>
|
||||
</HoverEffectCard>
|
||||
</motion.div>
|
||||
</div>
|
||||
</PureLayout>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -8,17 +8,15 @@ import { toast } from 'react-hot-toast';
|
||||
import { IoKeyOutline } from 'react-icons/io5';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
|
||||
import key from '@/const/key';
|
||||
|
||||
import HoverEffectCard from '@/components/effect_card';
|
||||
import { title } from '@/components/primitives';
|
||||
import { ThemeSwitch } from '@/components/theme-switch';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
import PureLayout from '@/layouts/pure';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
export default function WebLoginPage () {
|
||||
const urlSearchParams = new URLSearchParams(window.location.search);
|
||||
@ -152,12 +150,7 @@ export default function WebLoginPage () {
|
||||
<>
|
||||
<title>WebUI登录 - NapCat WebUI</title>
|
||||
<PureLayout>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.5, type: "spring", stiffness: 120, damping: 20 }}
|
||||
className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'
|
||||
>
|
||||
<div className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'>
|
||||
<HoverEffectCard
|
||||
className='items-center gap-4 pt-0 pb-6 bg-default-50'
|
||||
maxXRotation={3}
|
||||
@ -264,7 +257,7 @@ export default function WebLoginPage () {
|
||||
</Button>
|
||||
</CardBody>
|
||||
</HoverEffectCard>
|
||||
</motion.div>
|
||||
</div>
|
||||
</PureLayout>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -6,45 +6,15 @@
|
||||
|
||||
body {
|
||||
font-family:
|
||||
'Quicksand',
|
||||
'Nunito',
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Arial,
|
||||
'PingFang SC',
|
||||
'Microsoft YaHei',
|
||||
'Aa偷吃可爱长大的',
|
||||
PingFang SC,
|
||||
Helvetica Neue,
|
||||
Microsoft YaHei,
|
||||
sans-serif !important;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-smooth: always;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
:root {
|
||||
--heroui-primary: 217.2 91.2% 59.8%; /* 自然的现代蓝 */
|
||||
--heroui-primary-foreground: 210 40% 98%;
|
||||
--heroui-radius: 0.75rem;
|
||||
--text-primary: 222.2 47.4% 11.2%;
|
||||
--text-secondary: 215.4 16.3% 46.9%;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: hsl(var(--text-primary));
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.dark h1, .dark h2, .dark h3, .dark h4, .dark h5, .dark h6 {
|
||||
color: hsl(210 40% 98%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--heroui-primary: 217.2 91.2% 59.8%;
|
||||
--heroui-primary-foreground: 210 40% 98%;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@ -64,29 +34,23 @@ h1, h2, h3, h4, h5, h6 {
|
||||
}
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: #ffcdba;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
border-radius: 3px;
|
||||
-webkit-border-radius: 2em;
|
||||
-moz-border-radius: 2em;
|
||||
border-radius: 2em;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 182, 193, 0.4); /* 浅粉色滚动条 */
|
||||
border-radius: 3px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 127, 172, 0.6);
|
||||
background-color: rgb(147, 147, 153, 0.5);
|
||||
-webkit-border-radius: 2em;
|
||||
-moz-border-radius: 2em;
|
||||
border-radius: 2em;
|
||||
}
|
||||
|
||||
.monaco-editor {
|
||||
|
||||
122
packages/napcat-webui-frontend/src/utils/music.ts
Normal file
122
packages/napcat-webui-frontend/src/utils/music.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { PlayMode } from '@/const/enum';
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
import type {
|
||||
FinalMusic,
|
||||
Music163ListResponse,
|
||||
Music163URLResponse,
|
||||
} from '@/types/music';
|
||||
|
||||
/**
|
||||
* 获取网易云音乐歌单
|
||||
* @param id 歌单id
|
||||
* @returns 歌单信息
|
||||
*/
|
||||
export const get163MusicList = async (id: string) => {
|
||||
const res = await WebUIManager.proxy<Music163ListResponse>(
|
||||
'https://wavesgame.top/playlist/track/all?id=' + id
|
||||
);
|
||||
// const res = await request.get<Music163ListResponse>(
|
||||
// `https://wavesgame.top/playlist/track/all?id=${id}`
|
||||
// )
|
||||
if (res?.data?.code !== 200) {
|
||||
throw new Error('获取歌曲列表失败');
|
||||
}
|
||||
return res.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取歌曲地址
|
||||
* @param ids 歌曲id
|
||||
* @returns 歌曲地址
|
||||
*/
|
||||
export const getSongsURL = async (ids: number[]) => {
|
||||
const _ids = ids.reduce((prev, cur, index) => {
|
||||
const groupIndex = Math.floor(index / 10);
|
||||
if (!prev[groupIndex]) {
|
||||
prev[groupIndex] = [];
|
||||
}
|
||||
prev[groupIndex].push(cur);
|
||||
return prev;
|
||||
}, [] as number[][]);
|
||||
const res = await Promise.all(
|
||||
_ids.map(async (id) => {
|
||||
const res = await WebUIManager.proxy<Music163URLResponse>(
|
||||
`https://wavesgame.top/song/url?id=${id.join(',')}`
|
||||
);
|
||||
if (res?.data?.code !== 200) {
|
||||
throw new Error('获取歌曲地址失败');
|
||||
}
|
||||
return res.data.data;
|
||||
})
|
||||
);
|
||||
const result = res.reduce((prev, cur) => {
|
||||
return prev.concat(...cur);
|
||||
}, []);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取网易云音乐歌单歌曲
|
||||
* @param id 歌单id
|
||||
* @returns 歌曲信息
|
||||
*/
|
||||
export const get163MusicListSongs = async (id: string) => {
|
||||
const listRes = await get163MusicList(id);
|
||||
const songs = listRes.songs.map((song) => song.id);
|
||||
const songsRes = await getSongsURL(songs);
|
||||
const finalMusic: FinalMusic[] = [];
|
||||
for (let i = 0; i < listRes.songs.length; i++) {
|
||||
const song = listRes.songs[i];
|
||||
const music = songsRes.find((s) => s.id === song.id);
|
||||
const songURL = music?.url;
|
||||
if (songURL) {
|
||||
finalMusic.push({
|
||||
id: song.id,
|
||||
url: songURL.replace(/http:\/\//, '//').replace(/https:\/\//, '//'),
|
||||
title: song.name,
|
||||
artist: song.ar.map((p) => p.name).join('/'),
|
||||
cover: song.al.picUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
return finalMusic;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取随机音乐
|
||||
* @param ids 歌曲id
|
||||
* @param currentId 当前音乐id
|
||||
* @returns 随机音乐id
|
||||
*/
|
||||
export const getRandomMusic = (ids: number[], currentId: number): number => {
|
||||
const randomIndex = Math.floor(Math.random() * ids.length);
|
||||
const randomId = ids[randomIndex];
|
||||
if (randomId === currentId) {
|
||||
return getRandomMusic(ids, currentId);
|
||||
}
|
||||
return randomId;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取下一首音乐id
|
||||
* @param ids 歌曲id
|
||||
* @param currentId 当前音乐ID
|
||||
* @param mode 播放模式
|
||||
*/
|
||||
export const getNextMusic = (
|
||||
musics: FinalMusic[],
|
||||
currentId: number,
|
||||
mode: PlayMode
|
||||
): number => {
|
||||
const ids = musics.map((music) => music.id);
|
||||
if (mode === PlayMode.Loop) {
|
||||
const currentIndex = ids.findIndex((id) => id === currentId);
|
||||
const nextIndex = currentIndex + 1;
|
||||
return ids[nextIndex] || ids[0];
|
||||
}
|
||||
if (mode === PlayMode.Random) {
|
||||
return getRandomMusic(ids, currentId);
|
||||
}
|
||||
return currentId;
|
||||
};
|
||||
@ -25,32 +25,18 @@ export default {
|
||||
light: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#FF7FAC', // 樱花粉
|
||||
DEFAULT: '#f31260',
|
||||
foreground: '#fff',
|
||||
50: '#FFF0F5',
|
||||
100: '#FFE4E9',
|
||||
200: '#FFCDD9',
|
||||
300: '#FF9EB5',
|
||||
400: '#FF7FAC',
|
||||
500: '#F33B7C',
|
||||
600: '#C92462',
|
||||
700: '#991B4B',
|
||||
800: '#691233',
|
||||
900: '#380A1B',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: '#88C0D0', // 冰霜蓝
|
||||
foreground: '#fff',
|
||||
50: '#F0F9FC',
|
||||
100: '#D7F0F8',
|
||||
200: '#AEE1F2',
|
||||
300: '#88C0D0',
|
||||
400: '#5E9FBF',
|
||||
500: '#4C8DAE',
|
||||
600: '#3A708C',
|
||||
700: '#2A546A',
|
||||
800: '#1A3748',
|
||||
900: '#0B1B26',
|
||||
50: '#fee7ef',
|
||||
100: '#fdd0df',
|
||||
200: '#faa0bf',
|
||||
300: '#f871a0',
|
||||
400: '#f54180',
|
||||
500: '#f31260',
|
||||
600: '#c20e4d',
|
||||
700: '#920b3a',
|
||||
800: '#610726',
|
||||
900: '#310413',
|
||||
},
|
||||
danger: {
|
||||
DEFAULT: '#DB3694',
|
||||
|
||||
@ -34,11 +34,6 @@ export default defineConfig(({ mode }) => {
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api/Debug/ws': {
|
||||
target: backendDebugUrl,
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api': backendDebugUrl,
|
||||
'/files': backendDebugUrl,
|
||||
},
|
||||
|
||||
12167
pnpm-lock.yaml
12167
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user