Compare commits

..

No commits in common. "main" and "v4.9.76" have entirely different histories.

97 changed files with 2504 additions and 18214 deletions

View File

@ -43,7 +43,7 @@ _Modern protocol-side framework implemented based on NTQQ._
**首次使用**请务必查看如下文档看使用教程
> 项目非盈利,涉及 对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
## Link

View File

@ -466,37 +466,5 @@
"6.9.85-42086": {
"appid": 537320237,
"qua": "V1_MAC_NQ_6.9.85_42086_GW_B"
},
"9.9.23-42430": {
"appid": 537320212,
"qua": "V1_WIN_NQ_9.9.23_42430_GW_B"
},
"9.9.25-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"
}
}

View File

@ -90,41 +90,5 @@
"3.2.21-42086-x64": {
"send": "5B42CF0",
"recv": "2FDA6F0"
},
"9.9.23-42430-x64": {
"send": "0A01A34",
"recv": "1D1CFF9"
},
"9.9.25-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"
}
}

View File

@ -602,41 +602,5 @@
"3.2.21-42086-arm64": {
"send": "6B13038",
"recv": "6B169C8"
},
"9.9.23-42430-x64": {
"send": "2C9A4A0",
"recv": "2C9DA20"
},
"9.9.25-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"
}
}

View File

@ -126,7 +126,7 @@ export class NapCatCore {
container.bind(TypedEventEmitter).toConstantValue(this.event);
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
container.bind(ServiceClass).toSelf();
//console.log(`Registering service handler for: ${serviceName}`);
console.log(`Registering service handler for: ${serviceName}`);
this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => {
const serviceInstance = container.get(ServiceClass);
return serviceInstance.handler(seq, hex_data);

View File

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

View File

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

View File

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

View File

@ -27,7 +27,6 @@ const ShellBaseConfigPlugin: PluginOption[] = [
targets: [
{ src: '../napcat-native/', dest: 'dist/native', flatten: false },
{ src: '../napcat-webui-frontend/dist/', dest: 'dist/static/', flatten: false },
{ src: '../napcat-webui-backend/src/assets/sw_template.js', dest: 'dist/static/' },
{ src: '../napcat-core/external/napcat.json', dest: 'dist/config/' },
{ src: '../../package.json', dest: 'dist' },
{ src: '../napcat-shell-loader', dest: 'dist' },

View File

@ -22,14 +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';
import compression from 'compression';
import { napCatVersion } from 'napcat-common/src/version';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 实例化Express
const app = express();
/**
@ -150,31 +142,18 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
// ------------注册中间件------------
// 使用express的json中间件
app.use(express.json());
// 启用gzip压缩对所有响应启用阈值1KB
app.use(compression({
level: 6, // 压缩级别 1-96 是性能和压缩率的平衡点
threshold: 1024, // 只压缩大于 1KB 的响应
filter: (req, res) => {
// 不压缩 SSE 和 WebSocket 升级请求
if (req.headers['accept'] === 'text/event-stream') {
return false;
}
// 使用默认过滤器
return compression.filter(req, res);
},
}));
// CORS中间件
// TODO:
app.use(cors);
// 自定义字体文件路由 - 返回用户上传的字体文件
app.use('/webui/fonts/CustomFont.woff', async (_req, res) => {
const fontPath = await WebUiConfig.GetWebUIFontPath();
if (fontPath) {
res.sendFile(fontPath);
// 如果是webui字体文件挂载字体文件
app.use('/webui/fonts/AaCute.woff', async (_req, res, next) => {
const isFontExist = await WebUiConfig.CheckWebUIFontExist();
if (isFontExist) {
res.sendFile(WebUiConfig.GetWebUIFontPath());
} else {
res.status(404).send('Custom font not found');
next();
}
});
@ -196,32 +175,6 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
res.send(css);
});
// 动态生成 sw.js
app.get('/webui/sw.js', async (_req, res) => {
try {
// 读取模板文件
let templatePath = resolve(__dirname, 'static', 'sw_template.js');
if (!existsSync(templatePath)) {
templatePath = resolve(__dirname, 'src', 'assets', 'sw_template.js');
}
let swContent = readFileSync(templatePath, 'utf-8');
// 替换版本号
// 使用 napCatVersion如果为 alpha 则尝试加上时间戳或其他标识以避免缓存冲突,或者直接使用
// 用户要求控制 sw.js 版本napCatVersion 是核心控制点
swContent = swContent.replace('{{VERSION}}', napCatVersion);
res.header('Content-Type', 'application/javascript');
res.header('Service-Worker-Allowed', '/webui/');
res.header('Cache-Control', 'no-cache, no-store, must-revalidate');
res.send(swContent);
} catch (error) {
console.error('[NapCat] [WebUi] Error generating sw.js', error);
res.status(500).send('Error generating service worker');
}
});
// ------------中间件结束------------
// ------------挂载路由------------
@ -234,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);

View File

@ -20,7 +20,6 @@
"@sinclair/typebox": "^0.34.38",
"ajv": "^8.13.0",
"compressing": "^1.10.3",
"compression": "^1.8.1",
"express": "^5.0.0",
"express-rate-limit": "^7.5.0",
"json5": "^2.2.3",
@ -30,7 +29,6 @@
"ws": "^8.18.3"
},
"devDependencies": {
"@types/compression": "^1.8.1",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.0.1",

View File

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

View File

@ -640,10 +640,10 @@ export const UploadWebUIFontHandler: RequestHandler = async (req, res) => {
// 删除WebUI字体文件处理方法
export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => {
try {
const fontPath = await WebUiConfig.GetWebUIFontPath();
const fontPath = WebUiConfig.GetWebUIFontPath();
const exists = await WebUiConfig.CheckWebUIFontExist();
if (!exists || !fontPath) {
if (!exists) {
return sendSuccess(res, true);
}

View File

@ -1,132 +0,0 @@
const CACHE_NAME = 'napcat-webui-v{{VERSION}}';
const ASSETS_TO_CACHE = [
'/webui/'
];
// 安装阶段:预缓存核心文件
self.addEventListener('install', (event) => {
self.skipWaiting(); // 强制立即接管
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
// 这里的资源如果加载失败不应该阻断 SW 安装
return cache.addAll(ASSETS_TO_CACHE).catch(err => console.warn('Failed to cache core assets', err));
})
);
});
// 激活阶段:清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName.startsWith('napcat-webui-') && cacheName !== CACHE_NAME) {
console.log('[SW] Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
self.clients.claim(); // 立即控制所有客户端
});
// 拦截请求
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// 1. API 请求:仅网络 (Network Only)
if (url.pathname.startsWith('/api/') || url.pathname.includes('/socket')) {
return;
}
// 2. 强缓存策略 (Cache First)
// - 外部 QQ 头像 (q1.qlogo.cn)
// - 静态资源 (assets, fonts)
// - 常见静态文件后缀
const isQLogo = url.hostname === 'q1.qlogo.cn';
const isCustomFont = url.pathname.includes('CustomFont.woff'); // 用户自定义字体,不强缓存
const isThemeCss = url.pathname.includes('files/theme.css'); // 主题 CSS不强缓存
const isStaticAsset = url.pathname.includes('/webui/assets/') ||
url.pathname.includes('/webui/fonts/');
const isStaticFile = /\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|ico)$/i.test(url.pathname);
if (!isCustomFont && !isThemeCss && (isQLogo || isStaticAsset || isStaticFile)) {
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
return response;
}
// 跨域请求 (qlogo) 需要 mode: 'no-cors' 才能缓存 opaque response
// 但 fetch(event.request) 默认会继承 request 的 mode。
// 如果是 img标签发起的请求通常 mode 是 no-cors 或 cors。
// 对于 opaque response (status 0), cache API 允许缓存。
return fetch(event.request).then((response) => {
// 对 qlogo 允许 status 0 (opaque)
// 对其他资源要求 status 200
const isValidResponse = response && (
response.status === 200 ||
response.type === 'basic' ||
(isQLogo && response.type === 'opaque')
);
if (!isValidResponse) {
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
})
);
return;
}
// 3. HTML 页面 / 导航请求 -> 网络优先 (Network First)
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.then((response) => {
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
return;
}
// 4. 其他 Same-Origin 请求 -> Stale-While-Revalidate
// 优先返回缓存,同时后台更新缓存,保证下次访问是新的
if (url.origin === self.location.origin) {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
const fetchPromise = fetch(event.request).then((networkResponse) => {
if (networkResponse && networkResponse.status === 200) {
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
}
return networkResponse;
});
// 如果有缓存,返回缓存;否则等待网络
return cachedResponse || fetchPromise;
})
);
return;
}
// 默认:网络优先
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
});

View File

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

View File

@ -176,35 +176,17 @@ export class WebUiConfigWrapper {
return [];
}
// 判断字体是否存在(支持多种格式
// 判断字体是否存在(webui.woff
async CheckWebUIFontExist (): Promise<boolean> {
const fontPath = await this.GetWebUIFontPath();
if (!fontPath) return false;
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
return await fs
.access(fontPath, constants.F_OK)
.access(resolve(fontsPath, './webui.woff'), constants.F_OK)
.then(() => true)
.catch(() => false);
}
// 获取webui字体文件路径支持多种格式
async GetWebUIFontPath (): Promise<string | null> {
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
const extensions = ['.woff', '.woff2', '.ttf', '.otf'];
for (const ext of extensions) {
const fontPath = resolve(fontsPath, `webui${ext}`);
const exists = await fs
.access(fontPath, constants.F_OK)
.then(() => true)
.catch(() => false);
if (exists) {
return fontPath;
}
}
return null;
}
// 同步版本,用于 multer 配置
GetWebUIFontPathSync (): string {
// 获取webui字体文件路径
GetWebUIFontPath (): string {
return resolve(webUiPathWrapper.configPath, './fonts/webui.woff');
}

View File

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

View File

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

View File

@ -4,11 +4,9 @@ export const themeType = Type.Object(
{
dark: Type.Record(Type.String(), Type.String()),
light: Type.Record(Type.String(), Type.String()),
fontMode: Type.String({ default: 'system' }),
},
{
default: {
fontMode: 'system',
dark: {
'--heroui-background': '0 0% 0%',
'--heroui-foreground-50': '240 5.88% 10%',
@ -126,11 +124,11 @@ export const themeType = Type.Object(
'--heroui-border-width-medium': '2px',
'--heroui-border-width-large': '3px',
'--heroui-box-shadow-small':
'0px 0px 5px 0px rgba(0, 0, 0, .05), 0px 2px 10px 0px rgba(0, 0, 0, .2), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'0px 0px 5px 0px rgba(0, 0, 0, .05), 0px 2px 10px 0px rgba(0, 0, 0, .2), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'--heroui-box-shadow-medium':
'0px 0px 15px 0px rgba(0, 0, 0, .06), 0px 2px 30px 0px rgba(0, 0, 0, .22), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'0px 0px 15px 0px rgba(0, 0, 0, .06), 0px 2px 30px 0px rgba(0, 0, 0, .22), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'--heroui-box-shadow-large':
'0px 0px 30px 0px rgba(0, 0, 0, .07), 0px 30px 60px 0px rgba(0, 0, 0, .26), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'0px 0px 30px 0px rgba(0, 0, 0, .07), 0px 30px 60px 0px rgba(0, 0, 0, .26), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'--heroui-hover-opacity': '.9',
},
light: {
@ -250,11 +248,11 @@ export const themeType = Type.Object(
'--heroui-border-width-medium': '2px',
'--heroui-border-width-large': '3px',
'--heroui-box-shadow-small':
'0px 0px 5px 0px rgba(0, 0, 0, .02), 0px 2px 10px 0px rgba(0, 0, 0, .06), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'0px 0px 5px 0px rgba(0, 0, 0, .02), 0px 2px 10px 0px rgba(0, 0, 0, .06), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'--heroui-box-shadow-medium':
'0px 0px 15px 0px rgba(0, 0, 0, .03), 0px 2px 30px 0px rgba(0, 0, 0, .08), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'0px 0px 15px 0px rgba(0, 0, 0, .03), 0px 2px 30px 0px rgba(0, 0, 0, .08), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'--heroui-box-shadow-large':
'0px 0px 30px 0px rgba(0, 0, 0, .04), 0px 30px 60px 0px rgba(0, 0, 0, .12), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'0px 0px 30px 0px rgba(0, 0, 0, .04), 0px 30px 60px 0px rgba(0, 0, 0, .12), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'--heroui-hover-opacity': '.8',
},
},

View File

@ -4,51 +4,30 @@ import fs from 'fs';
import type { Request, Response } from 'express';
import { WebUiConfig } from '@/napcat-webui-backend/index';
// 支持的字体格式
const SUPPORTED_FONT_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf'];
// 清理旧的字体文件
const cleanOldFontFiles = (fontsPath: string) => {
for (const ext of SUPPORTED_FONT_EXTENSIONS) {
const fontPath = path.join(fontsPath, `webui${ext}`);
try {
if (fs.existsSync(fontPath)) {
fs.unlinkSync(fontPath);
}
} catch {
// 忽略删除失败
}
}
};
export const webUIFontStorage = multer.diskStorage({
destination: (_, __, cb) => {
try {
const fontsPath = path.dirname(WebUiConfig.GetWebUIFontPathSync());
const fontsPath = path.dirname(WebUiConfig.GetWebUIFontPath());
// 确保字体目录存在
fs.mkdirSync(fontsPath, { recursive: true });
// 清理旧的字体文件
cleanOldFontFiles(fontsPath);
cb(null, fontsPath);
} catch (error) {
// 确保错误信息被正确传递
cb(new Error(`创建字体目录失败:${(error as Error).message}`), '');
}
},
filename: (_, file, cb) => {
// 保留原始扩展名,统一文件名为 webui
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `webui${ext}`);
filename: (_, __, cb) => {
// 统一保存为webui.woff
cb(null, 'webui.woff');
},
});
export const webUIFontUpload = multer({
storage: webUIFontStorage,
fileFilter: (_, file, cb) => {
// 验证文件类型
const ext = path.extname(file.originalname).toLowerCase();
if (!SUPPORTED_FONT_EXTENSIONS.includes(ext)) {
cb(new Error('只支持 WOFF/WOFF2/TTF/OTF 格式的字体文件'));
// 再次验证文件类型
if (!file.originalname.toLowerCase().endsWith('.woff')) {
cb(new Error('只支持WOFF格式的字体文件'));
return;
}
cb(null, true);
@ -62,6 +41,8 @@ const webUIFontUploader = (req: Request, res: Response) => {
return new Promise((resolve, reject) => {
webUIFontUpload(req, res, (error) => {
if (error) {
// 错误处理
// sendError(res, error.message, true);
return reject(error);
}
return resolve(true);

View File

@ -26,5 +26,7 @@ dist-ssr
# NPM LOCK files
package-lock.json
yarn.lock
pnpm-lock.yaml
dist.zip

View File

@ -5,19 +5,12 @@
"type": "module",
"scripts": {
"dev": "vite --host=0.0.0.0",
"build": "vite build",
"build:full": "tsc && vite build",
"fontmin": "node scripts/fontmin.cjs",
"build": "tsc && vite build",
"typecheck": "tsc --noEmit",
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.6",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@ -29,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",
@ -53,10 +45,11 @@
"@heroui/theme": "2.4.6",
"@heroui/tooltip": "2.2.8",
"@monaco-editor/loader": "^1.4.0",
"@monaco-editor/react": "4.7.0-rc.0",
"@react-aria/visually-hidden": "^3.8.19",
"@reduxjs/toolkit": "^2.5.1",
"@simplewebauthn/browser": "^13.2.2",
"@uidotdev/usehooks": "^2.4.1",
"@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
@ -65,7 +58,10 @@
"axios": "^1.7.9",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"echarts": "^5.5.1",
"event-source-polyfill": "^1.0.31",
"framer-motion": "^12.0.6",
"monaco-editor": "^0.52.2",
"motion": "^12.0.6",
"path-browserify": "^1.0.1",
"qface": "^1.4.1",
@ -82,6 +78,7 @@
"react-markdown": "^9.0.3",
"react-photo-view": "^1.2.7",
"react-redux": "^9.2.0",
"react-responsive": "^10.0.0",
"react-router-dom": "^7.1.4",
"react-use-websocket": "^4.11.1",
"react-window": "^1.8.11",
@ -109,15 +106,10 @@
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "5.2.3",
"eslint-plugin-unused-imports": "^4.1.4",
"fontmin": "^0.9.9",
"glob": "^10.3.10",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"sharp": "^0.34.5",
"typescript": "^5.7.3",
"vite": "^6.0.5",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-image-optimizer": "^2.0.3",
"vite-plugin-static-copy": "^2.2.0",
"vite-tsconfig-paths": "^5.1.4"
},
@ -131,4 +123,4 @@
"react-dom": "$react-dom"
}
}
}
}

View File

@ -1,137 +0,0 @@
/**
* Fontmin Script - 动态裁剪字体
* 扫描 src 目录中所有中文字符生成字体子集
*/
const Fontmin = require('fontmin');
const fs = require('fs');
const path = require('path');
const glob = require('glob');
// 配置
const SOURCE_FONT = path.resolve(__dirname, '../src/assets/fonts/AaCute-full.ttf');
const SOURCE_TTF_ORIGINAL = path.resolve(__dirname, '../src/assets/fonts/AaCute.ttf');
const OUTPUT_DIR = path.resolve(__dirname, '../public/fonts');
const OUTPUT_NAME = 'AaCute.woff';
const SRC_DIR = path.resolve(__dirname, '../src');
// 基础字符集(常用汉字 + 标点 + 数字 + 字母)
const BASE_CHARS = `
0123456789
abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ
""''·
,.:;!?'"()[]<>-_+=*/\\|@#$%^&~\`
基础信息系统版本网络配置服务器客户端终端日志调试关于设置主题
登录退出确定取消保存删除编辑新建刷新加载更新下载上传
成功失败错误警告提示信息状态在线离线连接断开
用户名密码账号验证码记住自动
文件管理打开关闭复制粘贴剪切重命名移动
发送消息输入内容搜索查找筛选排序
帮助文档教程反馈问题建议
开启关闭启用禁用显示隐藏展开收起
返回前进上一步下一步完成跳过
今天昨天明天时间日期年月日时分秒
总量使用占用剩余内存内核主频型号
有新版本可用当前最新立即稍后
`;
/**
* 从源码文件中提取所有中文字符
*/
function extractCharsFromSource () {
const chars = new Set(BASE_CHARS.replace(/\s/g, ''));
// 匹配所有 .tsx, .ts, .jsx, .js, .css 文件
const files = glob.sync(`${SRC_DIR}/**/*.{tsx,ts,jsx,js,css}`, {
ignore: ['**/node_modules/**']
});
// 中文字符正则
const chineseRegex = /[\u4e00-\u9fa5]/g;
files.forEach(file => {
try {
const content = fs.readFileSync(file, 'utf-8');
const matches = content.match(chineseRegex);
if (matches) {
matches.forEach(char => chars.add(char));
}
} catch (e) {
console.warn(`Warning: Could not read file ${file}`);
}
});
return Array.from(chars).join('');
}
/**
* 运行 fontmin
*/
async function run () {
console.log('🔍 Scanning source files for Chinese characters...');
const text = extractCharsFromSource();
console.log(`📝 Found ${text.length} unique characters`);
// 检查源字体是否存在
let sourceFont = SOURCE_FONT;
if (!fs.existsSync(SOURCE_FONT)) {
// 尝试查找原始 TTF 并复制(不重命名,保留原始)
if (fs.existsSync(SOURCE_TTF_ORIGINAL)) {
console.log('📦 Copying original font to AaCute-full.ttf...');
fs.copyFileSync(SOURCE_TTF_ORIGINAL, SOURCE_FONT);
} else {
console.error(`❌ Source font not found: ${SOURCE_FONT}`);
console.log('💡 Please ensure AaCute.ttf exists in src/assets/fonts/');
process.exit(1);
}
}
console.log('✂️ Subsetting font...');
// 确保输出目录存在
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
const fontmin = new Fontmin()
.src(sourceFont)
.use(Fontmin.glyph({ text }))
.use(Fontmin.ttf2woff())
.dest(OUTPUT_DIR);
return new Promise((resolve, reject) => {
fontmin.run((err, files) => {
if (err) {
console.error('❌ Fontmin error:', err);
reject(err);
} else {
// 重命名输出文件
const generatedWoff = path.join(OUTPUT_DIR, 'AaCute-full.woff');
const targetFile = path.join(OUTPUT_DIR, OUTPUT_NAME);
if (fs.existsSync(generatedWoff)) {
// 如果目标文件存在,先删除
if (fs.existsSync(targetFile)) {
fs.unlinkSync(targetFile);
}
fs.renameSync(generatedWoff, targetFile);
}
// 清理生成的 TTF 文件
const generatedTtf = path.join(OUTPUT_DIR, 'AaCute-full.ttf');
if (fs.existsSync(generatedTtf)) {
fs.unlinkSync(generatedTtf);
}
if (fs.existsSync(targetFile)) {
const stats = fs.statSync(targetFile);
const sizeKB = (stats.size / 1024).toFixed(2);
console.log(`✅ Font subset created: ${targetFile} (${sizeKB} KB)`);
}
resolve();
}
});
});
}
run().catch(console.error);

View File

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

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

View File

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

View File

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

View File

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

View File

@ -1,126 +1,55 @@
import React, { useImperativeHandle, useEffect, useState } from 'react';
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { json } from '@codemirror/lang-json';
import { oneDark } from '@codemirror/theme-one-dark';
import Editor, { OnMount, loader } from '@monaco-editor/react';
import React from 'react';
import { useTheme } from '@/hooks/use-theme';
import { EditorView } from '@codemirror/view';
import clsx from 'clsx';
const getLanguageExtension = (lang?: string) => {
switch (lang) {
case 'json': return json();
default: return [];
}
};
import monaco from '@/monaco';
export interface CodeEditorProps {
value?: string;
defaultValue?: string;
language?: string;
defaultLanguage?: string;
onChange?: (value: string | undefined) => void;
height?: string;
options?: any;
onMount?: any;
}
export interface CodeEditorRef {
getValue: () => string;
}
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>((props, ref) => {
const { isDark } = useTheme();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [val, setVal] = useState(props.value || props.defaultValue || '');
const internalRef = React.useRef<ReactCodeMirrorRef>(null);
useEffect(() => {
if (props.value !== undefined) {
setVal(props.value);
}
}, [props.value]);
useImperativeHandle(ref, () => ({
getValue: () => {
// Prefer getting dynamic value from view, fallback to state
return internalRef.current?.view?.state.doc.toString() || val;
}
}));
const customTheme = EditorView.theme({
"&": {
fontSize: "14px",
height: "100% !important",
},
".cm-scroller": {
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
lineHeight: "1.6",
overflow: "auto !important",
height: "100% !important",
},
".cm-gutters": {
backgroundColor: "transparent",
borderRight: "none",
color: isDark ? "#ffffff50" : "#00000040",
},
".cm-gutterElement": {
paddingLeft: "12px",
paddingRight: "12px",
},
".cm-activeLineGutter": {
backgroundColor: "transparent",
color: isDark ? "#fff" : "#000",
},
".cm-content": {
caretColor: isDark ? "#fff" : "#000",
paddingTop: "12px",
paddingBottom: "12px",
},
".cm-activeLine": {
backgroundColor: isDark ? "#ffffff10" : "#00000008",
},
".cm-selectionMatch": {
backgroundColor: isDark ? "#ffffff20" : "#00000010",
},
});
const extensions = [
customTheme,
getLanguageExtension(props.language || props.defaultLanguage),
props.options?.wordWrap === 'on' ? EditorView.lineWrapping : [],
props.options?.readOnly ? EditorView.editable.of(false) : [],
].flat();
return (
<div
style={{ fontSize: props.options?.fontSize || 14, height: props.height || '100%', display: 'flex', flexDirection: 'column' }}
className={clsx(
'rounded-xl border overflow-hidden transition-colors',
isDark
? 'border-white/10 bg-[#282c34]'
: 'border-default-200 bg-white'
)}
>
<CodeMirror
ref={internalRef}
value={props.value ?? props.defaultValue}
height="100%"
className="h-full w-full"
theme={isDark ? oneDark : 'light'}
extensions={extensions}
onChange={(value) => {
setVal(value);
props.onChange?.(value);
}}
readOnly={props.options?.readOnly}
basicSetup={{
lineNumbers: props.options?.lineNumbers !== 'off',
foldGutter: props.options?.folding !== false,
highlightActiveLine: props.options?.renderLineHighlight !== 'none',
}}
/>
</div>
);
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
}
export type CodeEditorRef = monaco.editor.IStandaloneCodeEditor;
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>(
(props, ref) => {
const { isDark } = useTheme();
const handleEditorDidMount: OnMount = (editor, monaco) => {
if (ref) {
if (typeof ref === 'function') {
ref(editor);
} else {
(ref as React.RefObject<CodeEditorRef>).current = editor;
}
}
if (props.onMount) {
props.onMount(editor, monaco);
}
};
return (
<Editor
{...props}
onMount={handleEditorDidMount}
theme={isDark ? 'vs-dark' : 'light'}
/>
);
}
);
export default CodeEditor;

View File

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

View File

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

View File

@ -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) => {

View File

@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,11 +11,11 @@ import {
import CodeEditor from '@/components/code_editor';
interface FileEditModalProps {
isOpen: boolean;
file: { path: string; content: string; } | null;
onClose: () => void;
onSave: () => void;
onContentChange: (newContent?: string) => void;
isOpen: boolean
file: { path: string; content: string } | null
onClose: () => void
onSave: () => void
onContentChange: (newContent?: string) => void
}
export default function FileEditModal ({
@ -65,20 +65,12 @@ export default function FileEditModal ({
return (
<Modal size='full' isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader className='flex items-center gap-2 border-b border-default-200/50'>
<ModalHeader className='flex items-center gap-2 bg-content2 bg-opacity-50'>
<span></span>
<Code className='text-xs'>{file?.path}</Code>
<div className="ml-auto text-xs text-default-400 font-normal px-2">
<span className="px-1 py-0.5 rounded border border-default-300 bg-default-100">Ctrl/Cmd + S</span>
</div>
</ModalHeader>
<ModalBody className='p-4 bg-content2/50'>
<div className='h-full' onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
onSave();
}
}}>
<ModalBody className='p-0'>
<div className='h-full'>
<CodeEditor
height='100%'
value={file?.content || ''}
@ -88,7 +80,7 @@ export default function FileEditModal ({
/>
</div>
</ModalBody>
<ModalFooter className="border-t border-default-200/50">
<ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}>
</Button>

View File

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

View File

@ -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';
@ -19,17 +15,10 @@ export default function Hitokoto () {
loading,
run,
} = useRequest(() => request.get<IHitokoto>('https://hitokoto.152710.xyz/'), {
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}`;
@ -41,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>

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -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,249 +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 transition-all',
activeTab !== 'request' && 'rounded-xl overflow-y-auto no-scrollbar',
hasBackground ? 'bg-transparent' : (activeTab !== 'request' && '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-transparent">
<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>
);

View File

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

View File

@ -1,179 +1,85 @@
import { Card, CardBody } from '@heroui/card';
import { Input } from '@heroui/input';
import { useLocalStorage } from '@uidotdev/usehooks';
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 key from '@/const/key';
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 [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
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',
hasBackground
? 'bg-white/10 dark:bg-black/40 backdrop-blur-xl md:bg-transparent md:backdrop-blur-none'
: 'bg-white/80 dark:bg-black/40 backdrop-blur-xl md:bg-transparent 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
? (hasBackground ? '' : '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>
);
};

View File

@ -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) : '请求响应'}

View File

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

View File

@ -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
@ -61,7 +61,7 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
</ModalHeader>
<ModalBody>
<div className='h-96'>
<div className='h-96 dark:bg-[rgb(30,30,30)] p-2 rounded-md border border-default-100'>
<CodeEditor
height='100%'
defaultLanguage='json'

View File

@ -1,18 +1,23 @@
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风格 */}
<div
className='absolute top-[-10%] left-[-10%] w-[500px] h-[500px] rounded-full bg-primary-200/40 blur-[100px]'
/>
<div
className='absolute top-[20%] right-[-10%] w-[400px] h-[400px] rounded-full bg-secondary-200/40 blur-[90px]'
/>
<div
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>
</>
);
};

View File

@ -2,13 +2,13 @@ import { Spinner } from '@heroui/spinner';
import clsx from 'clsx';
export interface PageLoadingProps {
loading?: boolean;
loading?: boolean
}
const PageLoading: React.FC<PageLoadingProps> = ({ loading }) => {
return (
<div
className={clsx(
'absolute top-0 left-0 w-full h-full bg-zinc-500 bg-opacity-10 z-30 flex justify-center items-center backdrop-blur',
'absolute top-0 left-0 w-full h-full bg-zinc-500 bg-opacity-10 z-50 flex justify-center items-center backdrop-blur',
{
hidden: !loading,
}

View File

@ -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 dark:hidden'>
<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>
)}

View File

@ -1,23 +1,24 @@
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) => {
@ -25,9 +26,6 @@ const SideBar: React.FC<SideBarProps> = (props) => {
const { toggleTheme, isDark } = useTheme();
const { revokeAuth } = useAuth();
const dialog = useDialog();
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const onRevokeAuth = () => {
dialog.confirm({
title: '退出登录',
@ -52,11 +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 md:shadow-none rounded-r-2xl md:rounded-none',
hasBackground
? 'bg-transparent backdrop-blur-md'
: 'bg-content1/70 backdrop-blur-xl backdrop-saturate-150 shadow-xl',
'md:bg-transparent md:backdrop-blur-none md:backdrop-saturate-100 md:shadow-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 }}
@ -67,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>

View File

@ -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
? (
@ -97,15 +96,15 @@ const renderItems = (items: MenuItem[], children = false) => {
: (
<div
className={clsx(
'w-3 h-1.5 rounded-full ml-auto',
'w-3 h-1.5 rounded-full ml-auto shadow-lg',
isActive
? 'bg-primary-500 animate-nav-spin'
: 'bg-primary-200 dark:bg-white shadow-lg'
? 'bg-primary-500 animate-spinner-ease-spin'
: 'bg-primary-200 dark:bg-white'
)}
aria-hidden="true"
/>
)
}
radius='full'
startContent={
customIcons[item.label]
? (
@ -148,7 +147,7 @@ const renderItems = (items: MenuItem[], children = false) => {
};
interface MenusProps {
items: MenuItem[];
items: MenuItem[]
}
const Menus: React.FC<MenusProps> = (props) => {
const { items } = props;

View File

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

View File

@ -1,17 +1,17 @@
import { Card, CardBody, CardHeader } from '@heroui/card';
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, FaQq } from 'react-icons/fa6';
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,198 +192,81 @@ 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, {
cacheKey: 'napcat-latest-tag',
staleTime: 10 * 60 * 1000,
cacheTime: 30 * 60 * 1000,
});
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
const { data: latestVersion, error } = useRequest(WebUIManager.getLatestTag);
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='有新版本可用'>
<div className="cursor-pointer" onClick={updateStatus === 'updating' ? undefined : showUpdateDialog}>
<Chip
size="sm"
color="danger"
variant="flat"
classNames={{
content: "font-bold text-[10px] px-1",
base: "h-5 min-h-5"
}}
>
{updateStatus === 'updating' ? <Spinner size="sm" color="danger" /> : 'New'}
</Chip>
</div>
<Button
isIconOnly
radius='full'
color='primary'
variant='shadow'
className='!w-5 !h-5 !min-w-0 text-small shadow-md'
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>
</Tooltip>
);
};
interface NapCatVersionProps {
hasBackground?: boolean;
}
const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false }) => {
const NapCatVersion = () => {
const {
data: packageData,
loading: packageLoading,
error: packageError,
} = useRequest(WebUIManager.GetNapCatVersion, {
cacheKey: 'napcat-version',
staleTime: 60 * 60 * 1000,
cacheTime: 24 * 60 * 60 * 1000,
});
} = useRequest(WebUIManager.GetNapCatVersion);
const currentVersion = packageData?.version;
@ -401,7 +274,6 @@ const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false })
<SystemInfoItem
title='NapCat 版本'
icon={<IoLogoOctocat className='text-xl' />}
hasBackground={hasBackground}
value={
packageError
? (
@ -429,33 +301,19 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
data: qqVersionData,
loading: qqVersionLoading,
error: qqVersionError,
} = useRequest(WebUIManager.getQQVersion, {
cacheKey: 'qq-version',
staleTime: 60 * 60 * 1000,
cacheTime: 24 * 60 * 60 * 1000,
});
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
} = useRequest(WebUIManager.getQQVersion);
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
? (
@ -474,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>

View File

@ -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>
@ -142,13 +116,11 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
systemUsage={Number(data?.cpu.usage.system) || 0}
processUsage={Number(data?.cpu.usage.qq) || 0}
title='CPU占用'
hasBackground={hasBackground}
/>
<UsagePie
systemUsage={memoryUsage.system}
processUsage={memoryUsage.qq}
title='内存占用'
hasBackground={hasBackground}
/>
</div>
</CardBody>

View File

@ -1,121 +1,143 @@
import React, { useMemo } from 'react';
import clsx from 'clsx';
import * as echarts from 'echarts';
import React, { useEffect, useRef } from 'react';
import { useTheme } from '@/hooks/use-theme';
interface UsagePieProps {
systemUsage: number;
processUsage: number;
title?: string;
hasBackground?: boolean;
systemUsage: number
processUsage: number
title?: string
}
const defaultOption: echarts.EChartsOption = {
tooltip: {
trigger: 'item',
formatter: '<center>{b}<br/><b>{d}%</b></center>',
borderRadius: 10,
extraCssText: 'backdrop-filter: blur(10px);',
},
series: [
{
name: '系统占用',
type: 'pie',
radius: ['70%', '90%'],
avoidLabelOverlap: false,
label: {
show: true,
position: 'center',
formatter: '系统占用',
fontSize: 14,
},
itemStyle: {
borderWidth: 1,
borderRadius: 10,
},
labelLine: {
show: false,
},
data: [
{
value: 100,
name: '系统总量',
},
],
},
],
};
const UsagePie: React.FC<UsagePieProps> = ({
systemUsage,
processUsage,
title,
hasBackground,
}) => {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const { theme } = useTheme();
// Ensure values are clean
const cleanSystem = Math.min(Math.max(systemUsage || 0, 0), 100);
const cleanProcess = Math.min(Math.max(processUsage || 0, 0), cleanSystem);
useEffect(() => {
if (chartRef.current) {
chartInstance.current = echarts.init(chartRef.current);
const option = defaultOption;
chartInstance.current.setOption(option);
const observer = new ResizeObserver(() => {
chartInstance.current?.resize();
});
observer.observe(chartRef.current);
return () => {
chartInstance.current?.dispose();
observer.disconnect();
};
}
}, []);
// SVG Config
const size = 100;
const strokeWidth = 10;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const center = size / 2;
useEffect(() => {
if (chartInstance.current) {
chartInstance.current.setOption({
series: [
{
label: {
formatter: title,
},
},
],
});
}
}, [title]);
// Colors
const colors = {
qq: '#D33FF0',
other: theme === 'dark' ? '#EF8664' : '#EA7D9B',
track: theme === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)',
};
useEffect(() => {
if (chartInstance.current) {
chartInstance.current.setOption({
darkMode: theme === 'dark',
tooltip: {
backgroundColor:
theme === 'dark'
? 'rgba(0, 0, 0, 0.8)'
: 'rgba(255, 255, 255, 0.8)',
textStyle: {
color: theme === 'dark' ? '#fff' : '#333',
},
},
color:
theme === 'dark'
? ['#D33FF0', '#EF8664', '#E25180']
: ['#D33FF0', '#EA7D9B', '#FFC107'],
series: [
{
itemStyle: {
borderColor: theme === 'dark' ? '#333' : '#F0A9A7',
},
},
],
});
}
}, [theme]);
// Dash Arrays
// 1. Total System Usage (QQ + Others)
const systemDash = useMemo(() => {
return `${(cleanSystem / 100) * circumference} ${circumference}`;
}, [cleanSystem, circumference]);
useEffect(() => {
if (chartInstance.current) {
chartInstance.current.setOption({
series: [
{
data: [
{
value: processUsage,
name: 'QQ占用',
},
{
value: systemUsage - processUsage,
name: '其他进程占用',
},
{
value: 100 - systemUsage,
name: '剩余系统总量',
},
],
},
],
});
}
}, [systemUsage, processUsage]);
// 2. QQ Usage (Subset of System)
const processDash = useMemo(() => {
return `${(cleanProcess / 100) * circumference} ${circumference}`;
}, [cleanProcess, circumference]);
return (
<div className="relative w-36 h-36 flex items-center justify-center">
<svg
className="w-full h-full -rotate-90"
viewBox={`0 0 ${size} ${size}`}
>
{/* Track / Free Space */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={colors.track}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
{/* System Usage (Background for QQ) - effectively "Others" + "QQ" */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={colors.other}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={systemDash}
className="transition-all duration-700 ease-out"
/>
{/* QQ Usage - Layered on top */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={colors.qq}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={processDash}
className="transition-all duration-700 ease-out"
/>
</svg>
{/* Center Content */}
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none select-none">
{title && (
<span className={clsx(
"text-[10px] font-medium mb-0.5 opacity-80 uppercase tracking-widest scale-90",
hasBackground ? 'text-white/80' : 'text-default-500 dark:text-default-400'
)}>
{title}
</span>
)}
<div className="flex items-baseline gap-0.5">
<span className={clsx(
"text-2xl font-bold font-mono tracking-tight",
hasBackground ? 'text-white' : 'text-default-900 dark:text-white'
)}>
{Math.round(cleanSystem)}
</span>
<span className={clsx(
"text-xs font-bold",
hasBackground ? 'text-white/60' : 'text-default-400 dark:text-default-500'
)}>%</span>
</div>
</div>
</div>
);
return <div ref={chartRef} className='w-36 h-36 flex-shrink-0' />;
};
export default UsagePie;

View File

@ -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,27 +35,13 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
const { className, onInput, onKey, onResize, ...rest } = props;
const { theme } = useTheme();
useEffect(() => {
// 根据屏幕宽度决定字体大小,手机端使用更小的字体
const width = window.innerWidth;
// 按屏幕宽度自适应字体大小
let fontSize = 16;
if (width < 400) {
fontSize = 4;
} else if (width < 600) {
fontSize = 5;
} else if (width < 900) {
fontSize = 6;
} else if (width < 1280) {
fontSize = 12;
} // ≥1280: 16
const terminal = new Terminal({
allowTransparency: true,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", "JetBrains Mono", monospace',
'"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace',
cursorInactiveStyle: 'outline',
drawBoldTextInBrightColors: false,
fontSize: fontSize,
fontSize: 14,
lineHeight: 1.2,
});
terminalRef.current = terminal;
@ -70,7 +56,6 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
terminal.loadAddon(fitAddon);
terminal.open(domRef.current!);
// 所有端都使用 Canvas 渲染器(包括手机端)
terminal.loadAddon(new CanvasAddon());
terminal.onData((data) => {
if (onInput) {

View File

@ -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[],

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

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import '@/styles/globals.css';
import key from './const/key';
import WebUIManager from './controllers/webui_manager';
import { initFont, loadTheme } from './utils/theme';
import { loadTheme } from './utils/theme';
WebUIManager.checkWebUiLogined();
@ -24,7 +24,6 @@ if (theme && !theme.startsWith('"')) {
}
loadTheme();
initFont();
ReactDOM.createRoot(document.getElementById('root')!).render(
// <React.StrictMode>
@ -35,19 +34,3 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</BrowserRouter>
// </React.StrictMode>
);
if (!import.meta.env.DEV) {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
const baseUrl = import.meta.env.BASE_URL;
const swUrl = `${baseUrl}sw.js`;
navigator.serviceWorker.register(swUrl, { scope: baseUrl })
.then((registration) => {
console.log('SW registered: ', registration);
})
.catch((registrationError) => {
console.log('SW registration failed: ', registrationError);
});
});
}
}

View File

@ -0,0 +1,33 @@
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
self.MonacoEnvironment = {
getWorker (_: unknown, label: string) {
if (label === 'json') {
// eslint-disable-next-line new-cap
return new jsonWorker();
}
if (label === 'css' || label === 'scss' || label === 'less') {
// eslint-disable-next-line new-cap
return new cssWorker();
}
if (label === 'html' || label === 'handlebars' || label === 'razor') {
// eslint-disable-next-line new-cap
return new htmlWorker();
}
if (label === 'typescript' || label === 'javascript') {
// eslint-disable-next-line new-cap
return new tsWorker();
}
// eslint-disable-next-line new-cap
return new editorWorker();
},
};
monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true);
export default monaco;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,34 +1,28 @@
import { Accordion, AccordionItem } from '@heroui/accordion';
import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card';
import { Select, SelectItem } from '@heroui/select';
import { Chip } from '@heroui/chip';
import { useRequest } from 'ahooks';
import clsx from 'clsx';
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { useEffect, useRef } from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';
import toast from 'react-hot-toast';
import { FaFont, FaUserAstronaut, FaCheck } from 'react-icons/fa';
import { FaUserAstronaut } from 'react-icons/fa';
import { FaPaintbrush } from 'react-icons/fa6';
import { IoIosColorPalette } from 'react-icons/io';
import { MdDarkMode, MdLightMode } from 'react-icons/md';
import { IoMdRefresh } from 'react-icons/io';
import themes from '@/const/themes';
import ColorPicker from '@/components/ColorPicker';
import FileInput from '@/components/input/file_input';
import SaveButtons from '@/components/button/save_buttons';
import PageLoading from '@/components/page_loading';
import FileManager from '@/controllers/file_manager';
import { applyFont, colorKeys, generateTheme, loadTheme, updateFontCache } from '@/utils/theme';
import { colorKeys, generateTheme, loadTheme } from '@/utils/theme';
import WebUIManager from '@/controllers/webui_manager';
export type PreviewThemeCardProps = {
theme: ThemeInfo;
onPreview: () => void;
isSelected?: boolean;
};
const values = [
@ -53,7 +47,7 @@ const colors = [
'default',
];
function PreviewThemeCard ({ theme, onPreview, isSelected }: PreviewThemeCardProps) {
function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
const style = document.createElement('style');
style.innerHTML = generateTheme(theme.theme, theme.name);
const cardRef = useRef<HTMLDivElement>(null);
@ -70,19 +64,8 @@ function PreviewThemeCard ({ theme, onPreview, isSelected }: PreviewThemeCardPro
radius='sm'
isPressable
onPress={onPreview}
className={clsx(
'text-primary bg-primary-50 relative transition-all',
theme.name,
isSelected && 'ring-2 ring-primary ring-offset-2'
)}
className={clsx('text-primary bg-primary-50', theme.name)}
>
{isSelected && (
<div className="absolute top-1 right-1 z-10">
<Chip size="sm" color="primary" variant="solid">
<FaCheck size={10} />
</Chip>
</div>
)}
<CardHeader className='pb-0 flex flex-col items-start gap-1'>
<div className='px-1 rounded-md bg-primary text-primary-foreground'>
{theme.name}
@ -117,29 +100,6 @@ function PreviewThemeCard ({ theme, onPreview, isSelected }: PreviewThemeCardPro
);
}
// 比较两个主题配置是否相同(不比较 fontMode
const isThemeColorsEqual = (a: ThemeConfig, b: ThemeConfig): boolean => {
if (!a || !b) return false;
const aKeys = [...Object.keys(a.light || {}), ...Object.keys(a.dark || {})];
const bKeys = [...Object.keys(b.light || {}), ...Object.keys(b.dark || {})];
if (aKeys.length !== bKeys.length) return false;
for (const key of Object.keys(a.light || {})) {
if (a.light?.[key as keyof ThemeConfigItem] !== b.light?.[key as keyof ThemeConfigItem]) return false;
}
for (const key of Object.keys(a.dark || {})) {
if (a.dark?.[key as keyof ThemeConfigItem] !== b.dark?.[key as keyof ThemeConfigItem]) return false;
}
return true;
};
// 字体模式显示名称映射
const fontModeNames: Record<string, string> = {
'aacute': 'Aa 偷吃可爱长大的',
'system': '系统默认',
'custom': '自定义字体',
};
const ThemeConfigCard = () => {
const { data, loading, error, refreshAsync } = useRequest(
WebUIManager.getThemeConfig
@ -156,17 +116,12 @@ const ThemeConfigCard = () => {
theme: {
dark: {},
light: {},
fontMode: 'aacute',
},
},
});
const [dataLoaded, setDataLoaded] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// 使用 useRef 存储 style 标签引用
const styleTagRef = useRef<HTMLStyleElement | null>(null);
const originalDataRef = useRef<ThemeConfig | null>(null);
// 在组件挂载时创建 style 标签,并在卸载时清理
useEffect(() => {
@ -182,45 +137,13 @@ const ThemeConfigCard = () => {
const theme = useWatch({ control, name: 'theme' });
// 检测是否有未保存的更改
useEffect(() => {
if (originalDataRef.current && dataLoaded) {
const colorsChanged = !isThemeColorsEqual(theme, originalDataRef.current);
const fontChanged = theme.fontMode !== originalDataRef.current.fontMode;
setHasUnsavedChanges(colorsChanged || fontChanged);
}
}, [theme, dataLoaded]);
const reset = () => {
if (data) setOnebotValue('theme', data);
};
const reset = useCallback(() => {
if (data) {
setOnebotValue('theme', data);
originalDataRef.current = data;
// 应用已保存的字体设置
if (data.fontMode) {
applyFont(data.fontMode);
}
}
setDataLoaded(true);
setHasUnsavedChanges(false);
}, [data, setOnebotValue]);
// 实时应用字体预设(预览)
useEffect(() => {
if (dataLoaded && theme.fontMode) {
applyFont(theme.fontMode);
}
}, [theme.fontMode, dataLoaded]);
const onSubmit = handleOnebotSubmit(async (formData) => {
const onSubmit = handleOnebotSubmit(async (data) => {
try {
await WebUIManager.setThemeConfig(formData.theme);
// 更新原始数据引用
originalDataRef.current = formData.theme;
// 更新字体缓存
if (formData.theme.fontMode) {
updateFontCache(formData.theme.fontMode);
}
setHasUnsavedChanges(false);
await WebUIManager.setThemeConfig(data.theme);
toast.success('保存成功');
loadTheme();
} catch (error) {
@ -241,7 +164,7 @@ const ThemeConfigCard = () => {
useEffect(() => {
reset();
}, [data, reset]);
}, [data]);
useEffect(() => {
if (theme && styleTagRef.current) {
@ -250,25 +173,6 @@ const ThemeConfigCard = () => {
}
}, [theme]);
// 找到当前选中的主题(预览中的)
const selectedThemeName = useMemo(() => {
return themes.find(t => isThemeColorsEqual(t.theme, theme))?.name;
}, [theme]);
// 找到已保存的主题名称
const savedThemeName = useMemo(() => {
if (!originalDataRef.current) return null;
return themes.find(t => isThemeColorsEqual(t.theme, originalDataRef.current!))?.name || '自定义';
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataLoaded, hasUnsavedChanges]);
// 已保存的字体模式显示名称
const savedFontModeDisplayName = useMemo(() => {
const mode = originalDataRef.current?.fontMode || 'aacute';
return fontModeNames[mode] || mode;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataLoaded, hasUnsavedChanges]);
if (loading) return <PageLoading loading />;
if (error) {
@ -281,209 +185,96 @@ const ThemeConfigCard = () => {
<>
<title> - NapCat WebUI</title>
{/* 顶部操作栏 */}
<div className="sticky top-0 z-20 bg-background/80 backdrop-blur-md border-b border-divider">
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 text-sm">
<span className="text-default-400">:</span>
<Chip size="sm" color="primary" variant="flat">
{savedThemeName || '加载中...'}
</Chip>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-default-400">:</span>
<Chip size="sm" color="secondary" variant="flat">
{savedFontModeDisplayName}
</Chip>
</div>
{hasUnsavedChanges && (
<Chip size="sm" color="warning" variant="solid">
</Chip>
)}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
radius="full"
variant="flat"
className="font-medium bg-default-100 text-default-600 dark:bg-default-50/50"
onPress={() => {
reset();
toast.success('已重置');
}}
isDisabled={!hasUnsavedChanges}
>
</Button>
<Button
size="sm"
color='primary'
radius="full"
className="font-medium shadow-md shadow-primary/20"
isLoading={isSubmitting}
onPress={() => onSubmit()}
isDisabled={!hasUnsavedChanges}
>
</Button>
<Button
size="sm"
isIconOnly
radius='full'
variant='flat'
className="text-default-500 bg-default-100 dark:bg-default-50/50"
onPress={onRefresh}
>
<IoMdRefresh size={18} />
</Button>
</div>
</div>
</div>
<div className="p-4">
<Accordion variant='splitted' defaultExpandedKeys={['font', 'select']}>
<AccordionItem
key='font'
aria-label='Font Settings'
title='字体设置'
subtitle='自定义WebUI显示的字体'
className='shadow-small'
startContent={<FaFont />}
>
<div className='flex flex-col gap-4'>
<Controller
control={control}
name="theme.fontMode"
render={({ field }) => (
<Select
label="字体预设"
selectedKeys={field.value ? [field.value] : ['aacute']}
onChange={(e) => field.onChange(e.target.value)}
className="max-w-xs"
disallowEmptySelection
>
<SelectItem key="aacute">Aa </SelectItem>
<SelectItem key="system"></SelectItem>
<SelectItem key="custom"></SelectItem>
</Select>
)}
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
refresh={onRefresh}
className='items-end w-full p-4'
/>
<div className='px-4 text-sm text-default-600'></div>
<Accordion variant='splitted' defaultExpandedKeys={['select']}>
<AccordionItem
key='select'
aria-label='Pick Color'
title='选择主题'
subtitle='可以切换夜间/白昼模式查看对应颜色'
className='shadow-small'
startContent={<IoIosColorPalette />}
>
<div className='flex flex-wrap gap-2'>
{themes.map((theme) => (
<PreviewThemeCard
key={theme.name}
theme={theme}
onPreview={() => {
setOnebotValue('theme', theme.theme);
}}
/>
<div className='p-3 rounded-lg bg-default-100 dark:bg-default-50/30'>
<div className='text-sm text-default-500 mb-2'>
"自定义字体"
</div>
<FileInput
label='上传字体文件'
placeholder='选择字体文件 (.woff/.woff2/.ttf/.otf)'
accept='.ttf,.otf,.woff,.woff2'
onChange={async (file) => {
try {
await FileManager.uploadWebUIFont(file);
toast.success('上传成功,即将刷新页面');
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error) {
toast.error('上传失败: ' + (error as Error).message);
}
}}
onDelete={async () => {
try {
await FileManager.deleteWebUIFont();
toast.success('删除成功,即将刷新页面');
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error) {
toast.error('删除失败: ' + (error as Error).message);
}
}}
/>
</div>
</div>
</AccordionItem>
))}
</div>
</AccordionItem>
<AccordionItem
key='select'
aria-label='Pick Color'
title='选择主题'
subtitle='点击主题卡片即可预览,记得保存'
className='shadow-small'
startContent={<IoIosColorPalette />}
>
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3'>
{themes.map((t) => (
<PreviewThemeCard
key={t.name}
theme={t}
isSelected={selectedThemeName === t.name}
onPreview={() => {
setOnebotValue('theme', { ...t.theme, fontMode: theme.fontMode });
}}
/>
))}
</div>
</AccordionItem>
<AccordionItem
key='pick'
aria-label='Pick Color'
title='自定义配色'
subtitle='精细调整每个颜色变量'
className='shadow-small'
startContent={<FaPaintbrush />}
>
<div className='space-y-4'>
{(['light', 'dark'] as const).map((mode) => (
<div
key={mode}
className={clsx(
'p-4 rounded-lg',
mode === 'dark' ? 'bg-zinc-900 text-white' : 'bg-zinc-100 text-black'
)}
>
<h3 className='flex items-center justify-center gap-2 p-2 rounded-md bg-opacity-20 mb-4 font-medium'>
{mode === 'dark' ? <MdDarkMode size={20} /> : <MdLightMode size={20} />}
{mode === 'dark' ? '深色模式' : '浅色模式'}
</h3>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
{colorKeys.map((colorKey) => (
<div
key={colorKey}
className='flex items-center gap-2 p-2 rounded bg-black/5 dark:bg-white/5'
>
<Controller
control={control}
name={`theme.${mode}.${colorKey}`}
render={({ field: { value, onChange } }) => {
const hslArray = value?.split(' ') ?? [0, 0, 0];
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
return (
<ColorPicker
color={color}
onChange={(result) => {
onChange(
`${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%`
);
}}
/>
);
}}
/>
<span className='text-xs font-mono truncate flex-1' title={colorKey}>
{colorKey.replace('--heroui-', '')}
</span>
</div>
))}
<AccordionItem
key='pick'
aria-label='Pick Color'
title='自定义配色'
className='shadow-small'
startContent={<FaPaintbrush />}
>
<div className='space-y-2'>
{(['dark', 'light'] as const).map((mode) => (
<div
key={mode}
className={clsx(
'p-2 rounded-md',
mode === 'dark' ? 'text-white' : 'text-black',
mode === 'dark'
? 'bg-content1-foreground dark:bg-content1'
: 'bg-content1 dark:bg-content1-foreground'
)}
>
<h3 className='text-center p-2 rounded-md bg-content2 mb-2 text-default-800 flex items-center justify-center'>
{mode === 'dark'
? (
<MdDarkMode size={24} />
)
: (
<MdLightMode size={24} />
)}
{mode === 'dark' ? '夜间模式主题' : '白昼模式主题'}
</h3>
{colorKeys.map((key) => (
<div
key={key}
className='grid grid-cols-2 items-center mb-2 gap-2'
>
<label className='text-right'>{key}</label>
<Controller
control={control}
name={`theme.${mode}.${key}`}
render={({ field: { value, onChange } }) => {
const hslArray = value?.split(' ') ?? [0, 0, 0];
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
return (
<ColorPicker
color={color}
onChange={(result) => {
onChange(
`${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%`
);
}}
/>
);
}}
/>
</div>
</div>
))}
</div>
</AccordionItem>
</Accordion>
</div>
))}
</div>
))}
</div>
</AccordionItem>
</Accordion>
</>
);
};

View File

@ -1,3 +1,4 @@
import { Input } from '@heroui/input';
import { Button } from '@heroui/button';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useEffect, useState } from 'react';
@ -7,9 +8,13 @@ import toast from 'react-hot-toast';
import key from '@/const/key';
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';
// Base64URL to Uint8Array converter
@ -35,10 +40,11 @@ const WebUIConfigCard = () => {
handleSubmit: handleWebuiSubmit,
formState: { isSubmitting },
setValue: setWebuiValue,
} = useForm({
} = useForm<IConfig['webui']>({
defaultValues: {
background: '',
customIcons: {} as Record<string, string>,
musicListID: '',
customIcons: {},
},
});
@ -47,6 +53,7 @@ const WebUIConfigCard = () => {
key.customIcons,
{}
);
const { setListId, listId } = useMusic();
const [registrationOptions, setRegistrationOptions] = useState<any>(null);
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
@ -68,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('保存成功');
@ -85,41 +94,77 @@ 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'></div>
<div className='flex-shrink-0 w-full'>WebUI字体</div>
<div className='text-sm text-default-400'>
<FileInput
label='中文字体'
onChange={async (file) => {
try {
await FileManager.uploadWebUIFont(file);
toast.success('上传成功');
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error) {
toast.error('上传失败: ' + (error as Error).message);
}
}}
onDelete={async () => {
try {
await FileManager.deleteWebUIFont();
toast.success('删除成功');
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error) {
toast.error('删除失败: ' + (error as Error).message);
}
}}
/>
</div>
</div>
<div className='flex flex-col gap-2'>
<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后便WebUItoken
</div>

View File

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

View File

@ -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 中不再包含 TokenToken 单独放入输入框
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>

View File

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

View File

@ -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, useRef } from 'react';
import key from '@/const/key';
import { useCallback, useEffect, useState, useRef } from 'react';
import toast from 'react-hot-toast';
@ -65,7 +62,7 @@ export interface SystemStatusCardProps {
setArchInfo: (arch: string | undefined) => void;
}
const SystemStatusCard: React.FC<SystemStatusCardProps> = ({ setArchInfo }) => {
const [systemStatus, setSystemStatus] = useLocalStorage<SystemStatus | undefined>('napcat_system_status_cache', undefined);
const [systemStatus, setSystemStatus] = useState<SystemStatus>();
const isSetted = useRef(false);
const getStatus = useCallback(() => {
try {
@ -94,10 +91,7 @@ const SystemStatusCard: React.FC<SystemStatusCardProps> = ({ setArchInfo }) => {
};
const DashboardIndexPage: React.FC = () => {
const [archInfo, setArchInfo] = useLocalStorage<string | undefined>('napcat_arch_info_cache', undefined);
// @ts-ignore
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const [archInfo, setArchInfo] = useState<string>();
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>

View File

@ -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='实时日志'>

View File

@ -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) => (

View File

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

View File

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

View File

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

View File

@ -1,10 +1,13 @@
@font-face {
font-family: 'JetBrains Mono';
src: url('/webui/fonts/JetBrainsMono.ttf') format('truetype');
font-family: 'Aa偷吃可爱长大的';
src: url('/fonts/AaCute.woff') format('woff');
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/webui/fonts/JetBrainsMono-Italic.ttf') format('truetype');
src: url('/fonts/JetBrainsMono.ttf') format('truetype');
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/JetBrainsMono-Italic.ttf') format('truetype');
font-style: italic;
}
}

View File

@ -5,48 +5,16 @@
@tailwind utilities;
body {
font-family: var(--font-family-base, 'Quicksand', 'Nunito', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC', '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 {
/* 字体变量:可被 JS 动态覆盖 */
--font-family-fallbacks: 'Quicksand', 'Nunito', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-family-base: var(--font-family-fallbacks);
--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%;
font-family:
'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;
}
@layer components {
@ -54,13 +22,11 @@ h6 {
width: 0 !important;
height: 0 !important;
}
.hide-scrollbar::-webkit-scrollbar-thumb {
width: 0 !important;
height: 0 !important;
background-color: transparent !important;
}
.hide-scrollbar::-webkit-scrollbar-track {
width: 0 !important;
height: 0 !important;
@ -68,30 +34,23 @@ 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 {
@ -128,18 +87,16 @@ h6 {
.context-view.monaco-menu-container * {
font-family:
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
'PingFang SC',
'Microsoft YaHei',
PingFang SC,
'Aa偷吃可爱长大的',
Helvetica Neue,
Microsoft YaHei,
sans-serif !important;
}
.ql-hidden {
@apply hidden;
}
.ql-editor img {
@apply inline-block;
}

View File

@ -1,192 +1,191 @@
interface ServerResponse<T> {
code: number;
data: T;
message: string;
code: number
data: T
message: string
}
interface AuthResponse {
Credential: string;
Credential: string
}
interface LoginListItem {
uin: string;
uid: string;
nickName: string;
faceUrl: string;
facePath: string;
loginType: 1; // 1是二维码登录
isQuickLogin: boolean; // 是否可以快速登录
isAutoLogin: boolean; // 是否可以自动登录
uin: string
uid: string
nickName: string
faceUrl: string
facePath: string
loginType: 1 // 1是二维码登录
isQuickLogin: boolean // 是否可以快速登录
isAutoLogin: boolean // 是否可以自动登录
}
interface PackageInfo {
name: string;
version: string;
private: boolean;
type: string;
scripts: Record<string, string>;
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
name: string
version: string
private: boolean
type: string
scripts: Record<string, string>
dependencies: Record<string, string>
devDependencies: Record<string, string>
}
interface SystemStatus {
cpu: {
core: number;
model: string;
speed: string;
core: number
model: string
speed: string
usage: {
system: string;
qq: string;
};
};
system: string
qq: string
}
}
memory: {
total: string;
total: string
usage: {
system: string;
qq: string;
};
};
arch: string;
system: string
qq: string
}
}
arch: string
}
interface ThemeConfigItem {
'--heroui-background': string;
'--heroui-foreground-50': string;
'--heroui-foreground-100': string;
'--heroui-foreground-200': string;
'--heroui-foreground-300': string;
'--heroui-foreground-400': string;
'--heroui-foreground-500': string;
'--heroui-foreground-600': string;
'--heroui-foreground-700': string;
'--heroui-foreground-800': string;
'--heroui-foreground-900': string;
'--heroui-foreground': string;
'--heroui-focus': string;
'--heroui-overlay': string;
'--heroui-divider': string;
'--heroui-divider-opacity': string;
'--heroui-content1': string;
'--heroui-content1-foreground': string;
'--heroui-content2': string;
'--heroui-content2-foreground': string;
'--heroui-content3': string;
'--heroui-content3-foreground': string;
'--heroui-content4': string;
'--heroui-content4-foreground': string;
'--heroui-default-50': string;
'--heroui-default-100': string;
'--heroui-default-200': string;
'--heroui-default-300': string;
'--heroui-default-400': string;
'--heroui-default-500': string;
'--heroui-default-600': string;
'--heroui-default-700': string;
'--heroui-default-800': string;
'--heroui-default-900': string;
'--heroui-default-foreground': string;
'--heroui-default': string;
'--heroui-background': string
'--heroui-foreground-50': string
'--heroui-foreground-100': string
'--heroui-foreground-200': string
'--heroui-foreground-300': string
'--heroui-foreground-400': string
'--heroui-foreground-500': string
'--heroui-foreground-600': string
'--heroui-foreground-700': string
'--heroui-foreground-800': string
'--heroui-foreground-900': string
'--heroui-foreground': string
'--heroui-focus': string
'--heroui-overlay': string
'--heroui-divider': string
'--heroui-divider-opacity': string
'--heroui-content1': string
'--heroui-content1-foreground': string
'--heroui-content2': string
'--heroui-content2-foreground': string
'--heroui-content3': string
'--heroui-content3-foreground': string
'--heroui-content4': string
'--heroui-content4-foreground': string
'--heroui-default-50': string
'--heroui-default-100': string
'--heroui-default-200': string
'--heroui-default-300': string
'--heroui-default-400': string
'--heroui-default-500': string
'--heroui-default-600': string
'--heroui-default-700': string
'--heroui-default-800': string
'--heroui-default-900': string
'--heroui-default-foreground': string
'--heroui-default': string
// 新增 danger
'--heroui-danger-50': string;
'--heroui-danger-100': string;
'--heroui-danger-200': string;
'--heroui-danger-300': string;
'--heroui-danger-400': string;
'--heroui-danger-500': string;
'--heroui-danger-600': string;
'--heroui-danger-700': string;
'--heroui-danger-800': string;
'--heroui-danger-900': string;
'--heroui-danger-foreground': string;
'--heroui-danger': string;
'--heroui-danger-50': string
'--heroui-danger-100': string
'--heroui-danger-200': string
'--heroui-danger-300': string
'--heroui-danger-400': string
'--heroui-danger-500': string
'--heroui-danger-600': string
'--heroui-danger-700': string
'--heroui-danger-800': string
'--heroui-danger-900': string
'--heroui-danger-foreground': string
'--heroui-danger': string
// 新增 primary
'--heroui-primary-50': string;
'--heroui-primary-100': string;
'--heroui-primary-200': string;
'--heroui-primary-300': string;
'--heroui-primary-400': string;
'--heroui-primary-500': string;
'--heroui-primary-600': string;
'--heroui-primary-700': string;
'--heroui-primary-800': string;
'--heroui-primary-900': string;
'--heroui-primary-foreground': string;
'--heroui-primary': string;
'--heroui-primary-50': string
'--heroui-primary-100': string
'--heroui-primary-200': string
'--heroui-primary-300': string
'--heroui-primary-400': string
'--heroui-primary-500': string
'--heroui-primary-600': string
'--heroui-primary-700': string
'--heroui-primary-800': string
'--heroui-primary-900': string
'--heroui-primary-foreground': string
'--heroui-primary': string
// 新增 secondary
'--heroui-secondary-50': string;
'--heroui-secondary-100': string;
'--heroui-secondary-200': string;
'--heroui-secondary-300': string;
'--heroui-secondary-400': string;
'--heroui-secondary-500': string;
'--heroui-secondary-600': string;
'--heroui-secondary-700': string;
'--heroui-secondary-800': string;
'--heroui-secondary-900': string;
'--heroui-secondary-foreground': string;
'--heroui-secondary': string;
'--heroui-secondary-50': string
'--heroui-secondary-100': string
'--heroui-secondary-200': string
'--heroui-secondary-300': string
'--heroui-secondary-400': string
'--heroui-secondary-500': string
'--heroui-secondary-600': string
'--heroui-secondary-700': string
'--heroui-secondary-800': string
'--heroui-secondary-900': string
'--heroui-secondary-foreground': string
'--heroui-secondary': string
// 新增 success
'--heroui-success-50': string;
'--heroui-success-100': string;
'--heroui-success-200': string;
'--heroui-success-300': string;
'--heroui-success-400': string;
'--heroui-success-500': string;
'--heroui-success-600': string;
'--heroui-success-700': string;
'--heroui-success-800': string;
'--heroui-success-900': string;
'--heroui-success-foreground': string;
'--heroui-success': string;
'--heroui-success-50': string
'--heroui-success-100': string
'--heroui-success-200': string
'--heroui-success-300': string
'--heroui-success-400': string
'--heroui-success-500': string
'--heroui-success-600': string
'--heroui-success-700': string
'--heroui-success-800': string
'--heroui-success-900': string
'--heroui-success-foreground': string
'--heroui-success': string
// 新增 warning
'--heroui-warning-50': string;
'--heroui-warning-100': string;
'--heroui-warning-200': string;
'--heroui-warning-300': string;
'--heroui-warning-400': string;
'--heroui-warning-500': string;
'--heroui-warning-600': string;
'--heroui-warning-700': string;
'--heroui-warning-800': string;
'--heroui-warning-900': string;
'--heroui-warning-foreground': string;
'--heroui-warning': string;
'--heroui-warning-50': string
'--heroui-warning-100': string
'--heroui-warning-200': string
'--heroui-warning-300': string
'--heroui-warning-400': string
'--heroui-warning-500': string
'--heroui-warning-600': string
'--heroui-warning-700': string
'--heroui-warning-800': string
'--heroui-warning-900': string
'--heroui-warning-foreground': string
'--heroui-warning': string
// 其它配置
'--heroui-code-background': string;
'--heroui-strong': string;
'--heroui-code-mdx': string;
'--heroui-divider-weight': string;
'--heroui-disabled-opacity': string;
'--heroui-font-size-tiny': string;
'--heroui-font-size-small': string;
'--heroui-font-size-medium': string;
'--heroui-font-size-large': string;
'--heroui-line-height-tiny': string;
'--heroui-line-height-small': string;
'--heroui-line-height-medium': string;
'--heroui-line-height-large': string;
'--heroui-radius-small': string;
'--heroui-radius-medium': string;
'--heroui-radius-large': string;
'--heroui-border-width-small': string;
'--heroui-border-width-medium': string;
'--heroui-border-width-large': string;
'--heroui-box-shadow-small': string;
'--heroui-box-shadow-medium': string;
'--heroui-box-shadow-large': string;
'--heroui-hover-opacity': string;
'--heroui-code-background': string
'--heroui-strong': string
'--heroui-code-mdx': string
'--heroui-divider-weight': string
'--heroui-disabled-opacity': string
'--heroui-font-size-tiny': string
'--heroui-font-size-small': string
'--heroui-font-size-medium': string
'--heroui-font-size-large': string
'--heroui-line-height-tiny': string
'--heroui-line-height-small': string
'--heroui-line-height-medium': string
'--heroui-line-height-large': string
'--heroui-radius-small': string
'--heroui-radius-medium': string
'--heroui-radius-large': string
'--heroui-border-width-small': string
'--heroui-border-width-medium': string
'--heroui-border-width-large': string
'--heroui-box-shadow-small': string
'--heroui-box-shadow-medium': string
'--heroui-box-shadow-large': string
'--heroui-hover-opacity': string
}
interface ThemeConfig {
dark: ThemeConfigItem;
light: ThemeConfigItem;
fontMode?: string;
dark: ThemeConfigItem
light: ThemeConfigItem
}
interface WebUIConfig {
host: string;
port: number;
loginRate: number;
disableWebUI: boolean;
disableNonLANAccess: boolean;
host: string
port: number
loginRate: number
disableWebUI: boolean
disableNonLANAccess: boolean
}

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

View File

@ -3,11 +3,6 @@ import { request } from './request';
const style = document.createElement('style');
document.head.appendChild(style);
// 字体样式标签
const fontStyle = document.createElement('style');
fontStyle.id = 'dynamic-font-style';
document.head.appendChild(fontStyle);
export function loadTheme () {
request('/files/theme.css?_t=' + Date.now())
.then((res) => res.data)
@ -19,29 +14,6 @@ export function loadTheme () {
});
}
// 动态加载字体 CSS
const loadFontCSS = (mode: string) => {
let css = '';
if (mode === 'aacute') {
css = `
@font-face {
font-family: 'Aa偷吃可爱长大的';
src: url('/webui/fonts/AaCute.woff') format('woff');
font-display: swap;
}`;
} else if (mode === 'custom') {
css = `
@font-face {
font-family: 'CustomFont';
src: url('/webui/fonts/CustomFont.woff') format('woff');
font-display: swap;
}`;
}
fontStyle.innerHTML = css;
};
export const colorKeys = [
'--heroui-background',
@ -167,53 +139,3 @@ export const generateTheme = (theme: ThemeConfig, validField?: string) => {
css += '}';
return css;
};
export const applyFont = (mode: string) => {
const root = document.documentElement;
// 先加载字体 CSS
loadFontCSS(mode);
if (mode === 'aacute') {
root.style.setProperty('--font-family-base', "'Aa偷吃可爱长大的', var(--font-family-fallbacks)", 'important');
} else if (mode === 'custom') {
root.style.setProperty('--font-family-base', "'CustomFont', var(--font-family-fallbacks)", 'important');
} else {
// system or default - restore default
root.style.setProperty('--font-family-base', 'var(--font-family-fallbacks)', 'important');
}
};
const FONT_MODE_CACHE_KEY = 'webui-font-mode-cache';
export const initFont = () => {
// 先从缓存读取,立即应用
const cached = localStorage.getItem(FONT_MODE_CACHE_KEY);
if (cached) {
applyFont(cached);
} else {
// 默认使用系统字体
applyFont('system');
}
// 后台拉取最新配置并更新缓存
request('/api/base/Theme')
.then((res) => {
const data = res.data as { data: ThemeConfig; };
const fontMode = data?.data?.fontMode || 'system';
// 更新缓存
localStorage.setItem(FONT_MODE_CACHE_KEY, fontMode);
// 如果与当前不同,则应用新字体
if (fontMode !== cached) {
applyFont(fontMode);
}
})
.catch((e) => {
console.error('Failed to fetch font config', e);
});
};
// 保存时更新缓存
export const updateFontCache = (fontMode: string) => {
localStorage.setItem(FONT_MODE_CACHE_KEY, fontMode);
};

View File

@ -16,20 +16,7 @@ export default {
},
],
theme: {
extend: {
fontFamily: {
mono: [
'ui-monospace',
'SFMono-Regular',
'SF Mono',
'Menlo',
'Consolas',
'Liberation Mono',
'JetBrains Mono',
'monospace',
],
},
},
extend: {},
},
darkMode: 'class',
plugins: [
@ -38,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',

View File

@ -1,9 +1,13 @@
import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv } from 'vite';
import viteCompression from 'vite-plugin-compression';
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
import path from 'node:path';
import { defineConfig, loadEnv, normalizePath } from 'vite';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import tsconfigPaths from 'vite-tsconfig-paths';
const monacoEditorPath = normalizePath(
path.resolve(__dirname, 'node_modules/monaco-editor/min/vs')
);
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
@ -13,7 +17,14 @@ export default defineConfig(({ mode }) => {
plugins: [
react(),
tsconfigPaths(),
ViteImageOptimizer({})
viteStaticCopy({
targets: [
{
src: monacoEditorPath,
dest: 'monaco-editor/min',
},
],
}),
],
base: '/webui/',
server: {
@ -23,48 +34,22 @@ export default defineConfig(({ mode }) => {
ws: true,
changeOrigin: true,
},
'/api/Debug/ws': {
target: backendDebugUrl,
ws: true,
changeOrigin: true,
},
'/api': backendDebugUrl,
'/files': backendDebugUrl,
'/webui/fonts/CustomFont.woff': backendDebugUrl,
'/webui/sw.js': backendDebugUrl,
},
},
build: {
assetsInlineLimit: 0,
rollupOptions: {
output: {
manualChunks (id) {
if (id.includes('node_modules')) {
// if (id.includes('@heroui/')) {
// return 'heroui';
// }
if (id.includes('react-dom')) {
return 'react-dom';
}
if (id.includes('react-router-dom')) {
return 'react-router-dom';
}
if (id.includes('react-hook-form')) {
return 'react-hook-form';
}
if (id.includes('react-hot-toast')) {
return 'react-hot-toast';
}
if (id.includes('qface')) {
return 'qface';
}
if (id.includes('@uiw/react-codemirror') || id.includes('@codemirror/view') || id.includes('@codemirror/theme-one-dark')) {
return 'codemirror-core';
}
if (id.includes('@codemirror/lang-')) {
return 'codemirror-lang';
}
}
manualChunks: {
'monaco-editor': ['monaco-editor'],
'react-dom': ['react-dom'],
'react-router-dom': ['react-router-dom'],
'react-hook-form': ['react-hook-form'],
'react-icons': ['react-icons'],
'react-hot-toast': ['react-hot-toast'],
qface: ['qface'],
},
},
},

File diff suppressed because it is too large Load Diff