Compare commits

..

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

98 changed files with 2516 additions and 18205 deletions

View File

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

View File

@ -471,32 +471,12 @@
"appid": 537320212, "appid": 537320212,
"qua": "V1_WIN_NQ_9.9.23_42430_GW_B" "qua": "V1_WIN_NQ_9.9.23_42430_GW_B"
}, },
"9.9.25-42744": { "9.9.23-42744": {
"appid": 537328470, "appid": 537328470,
"qua": "V1_WIN_NQ_9.9.23_42744_GW_B" "qua": "V1_WIN_NQ_9.9.23_42744_GW_B"
}, },
"6.9.86-42744": { "6.9.86-42744": {
"appid": 537328495, "appid": 537328495,
"qua": "V1_MAC_NQ_6.9.85_42744_GW_B" "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

@ -95,36 +95,12 @@
"send": "0A01A34", "send": "0A01A34",
"recv": "1D1CFF9" "recv": "1D1CFF9"
}, },
"9.9.25-42744-x64": { "9.9.23-42744-x64": {
"send": "0A0D104", "send": "0A0D104",
"recv": "1D3E7F9" "recv": "1D3E7F9"
}, },
"6.9.85-42744-arm64": { "6.9.85-42744-arm64": {
"send": "23DFEF0", "send": "23DFEF0",
"recv": "095FD80" "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

@ -607,36 +607,12 @@
"send": "2C9A4A0", "send": "2C9A4A0",
"recv": "2C9DA20" "recv": "2C9DA20"
}, },
"9.9.25-42744-x64": { "9.9.23-42744-x64": {
"send": "2CD8E40", "send": "2CD8E40",
"recv": "2CDC3C0" "recv": "2CDC3C0"
}, },
"6.9.86-42744-arm64": { "6.9.86-42744-arm64": {
"send": "3DCC840", "send": "3DCC840",
"recv": "3DCF150" "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); container.bind(TypedEventEmitter).toConstantValue(this.event);
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => { ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
container.bind(ServiceClass).toSelf(); 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 }) => { this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => {
const serviceInstance = container.get(ServiceClass); const serviceInstance = container.get(ServiceClass);
return serviceInstance.handler(seq, hex_data); return serviceInstance.handler(seq, hex_data);

View File

@ -79,10 +79,7 @@ export async function NCoreInitFramework (
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework); WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework);
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e)); InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
// 初始化LLNC的Onebot实现 // 初始化LLNC的Onebot实现
const oneBotAdapter = new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper); await new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper).InitOneBot();
// 注册到 WebUiDataRuntime供调试功能使用
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
await oneBotAdapter.InitOneBot();
} }
export class NapCatFramework { export class NapCatFramework {

View File

@ -174,6 +174,7 @@ export class OneBotGroupApi {
async registerParseGroupReactEventByCore () { async registerParseGroupReactEventByCore () {
this.core.event.on('event:emoji_like', async (data) => { this.core.event.on('event:emoji_like', async (data) => {
console.log('Received emoji_like event from core:', data);
const event = await this.createGroupEmojiLikeEvent( const event = await this.createGroupEmojiLikeEvent(
data.groupId, data.groupId,
data.senderUin, data.senderUin,

View File

@ -749,31 +749,26 @@ export class OneBotMsgApi {
[OB11MessageDataType.music]: async ({ data }, context) => { [OB11MessageDataType.music]: async ({ data }, context) => {
// 保留, 直到...找到更好的解决方案 // 保留, 直到...找到更好的解决方案
const supportedPlatforms = ['qq', '163', 'kugou', 'kuwo', 'migu'];
const supportedPlatformsWithCustom = [...supportedPlatforms, 'custom'];
// 验证音乐类型
if (data.id !== undefined) { if (data.id !== undefined) {
if (!supportedPlatforms.includes(data.type)) { if (!['qq', '163', 'kugou', 'kuwo', 'migu'].includes(data.type)) {
this.core.context.logger.logError(`[音乐卡片] type参数错误: "${data.type}",仅支持: ${supportedPlatforms.join('、')}`); this.core.context.logger.logError('音乐卡片type错误, 只支持qq、163、kugou、kuwo、migu当前type:', data.type);
return undefined; return undefined;
} }
} else { } else {
if (!supportedPlatformsWithCustom.includes(data.type)) { if (!['qq', '163', 'kugou', 'kuwo', 'migu', 'custom'].includes(data.type)) {
this.core.context.logger.logError(`[音乐卡片] type参数错误: "${data.type}",仅支持: ${supportedPlatformsWithCustom.join('、')}`); this.core.context.logger.logError('音乐卡片type错误, 只支持qq、163、kugou、kuwo、migu、custom当前type:', data.type);
return undefined; return undefined;
} }
if (!data.url) { if (!data.url) {
this.core.context.logger.logError('[音乐卡片] 自定义音缺少必需参数: url'); this.core.context.logger.logError('自定义音卡缺少参数url');
return undefined; return undefined;
} }
if (!data.image) { if (!data.image) {
this.core.context.logger.logError('[音乐卡片] 自定义音缺少必需参数: image'); this.core.context.logger.logError('自定义音卡缺少参数image');
return undefined; return undefined;
} }
} }
// 构建请求数据
let postData: IdMusicSignPostData | CustomMusicSignPostData; let postData: IdMusicSignPostData | CustomMusicSignPostData;
if (data.id === undefined && data.content) { if (data.id === undefined && data.content) {
const { content, ...others } = data; const { content, ...others } = data;
@ -781,14 +776,11 @@ export class OneBotMsgApi {
} else { } else {
postData = data; postData = data;
} }
// 获取签名服务地址
let signUrl = this.obContext.configLoader.configData.musicSignUrl; let signUrl = this.obContext.configLoader.configData.musicSignUrl;
if (!signUrl) { if (!signUrl) {
signUrl = 'https://ss.xingzhige.com/music_card/card';// 感谢思思!已获思思许可 其余地方使用请自行询问 signUrl = 'https://ss.xingzhige.com/music_card/card';// 感谢思思!已获思思许可 其余地方使用请自行询问
// throw Error('音乐消息签名地址未配置');
} }
// 请求签名服务
try { try {
const musicJson = await RequestUtil.HttpGetJson<string>(signUrl, 'POST', postData); const musicJson = await RequestUtil.HttpGetJson<string>(signUrl, 'POST', postData);
return this.ob11ToRawConverters.json({ return this.ob11ToRawConverters.json({
@ -796,16 +788,9 @@ export class OneBotMsgApi {
type: OB11MessageDataType.json, type: OB11MessageDataType.json,
}, context); }, context);
} catch (e) { } catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e); this.core.context.logger.logError('生成音乐消息失败', e);
this.core.context.logger.logError(
'[音乐卡片签名失败] 签名服务请求出错!\n' +
` ├─ 音乐类型: ${data.type}\n` +
` ├─ 音乐ID: ${data.id ?? '自定义'}\n` +
` ├─ 错误信息: ${errorMessage}\n` +
' └─ 提示: 请检查网络连接,或尝试在配置中更换其他音乐签名服务地址(musicSignUrl)'
);
return undefined;
} }
return undefined;
}, },
[OB11MessageDataType.node]: async () => undefined, [OB11MessageDataType.node]: async () => undefined,

View File

@ -455,11 +455,7 @@ export class NapCatShell {
async InitNapCat () { async InitNapCat () {
await this.core.initCore(); await this.core.initCore();
const oneBotAdapter = new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper); new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper).InitOneBot()
// 注册到 WebUiDataRuntime供调试功能使用
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
oneBotAdapter.InitOneBot()
.catch(e => this.context.logger.logError('初始化OneBot失败', e)); .catch(e => this.context.logger.logError('初始化OneBot失败', e));
} }
} }

View File

@ -27,7 +27,6 @@ const ShellBaseConfigPlugin: PluginOption[] = [
targets: [ targets: [
{ src: '../napcat-native/', dest: 'dist/native', flatten: false }, { src: '../napcat-native/', dest: 'dist/native', flatten: false },
{ src: '../napcat-webui-frontend/dist/', dest: 'dist/static/', 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: '../napcat-core/external/napcat.json', dest: 'dist/config/' },
{ src: '../../package.json', dest: 'dist' }, { src: '../../package.json', dest: 'dist' },
{ src: '../napcat-shell-loader', 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 { ILogWrapper } from 'napcat-common/src/log-interface';
import { ISubscription } from 'napcat-common/src/subscription-interface'; import { ISubscription } from 'napcat-common/src/subscription-interface';
import { IStatusHelperSubscription } from '@/napcat-common/src/status-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 // 实例化Express
const app = express(); const app = express();
/** /**
@ -150,31 +142,18 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
// ------------注册中间件------------ // ------------注册中间件------------
// 使用express的json中间件 // 使用express的json中间件
app.use(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中间件 // CORS中间件
// TODO: // TODO:
app.use(cors); app.use(cors);
// 自定义字体文件路由 - 返回用户上传的字体文件 // 如果是webui字体文件挂载字体文件
app.use('/webui/fonts/CustomFont.woff', async (_req, res) => { app.use('/webui/fonts/AaCute.woff', async (_req, res, next) => {
const fontPath = await WebUiConfig.GetWebUIFontPath(); const isFontExist = await WebUiConfig.CheckWebUIFontExist();
if (fontPath) { if (isFontExist) {
res.sendFile(fontPath); res.sendFile(WebUiConfig.GetWebUIFontPath());
} else { } 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); 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 isHttps = !!sslCerts;
const server = isHttps && sslCerts ? createHttpsServer(sslCerts, app) : createServer(app); const server = isHttps && sslCerts ? createHttpsServer(sslCerts, app) : createServer(app);
server.on('upgrade', (request, socket, head) => { server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url || '', `http://${request.headers.host}`); terminalManager.initialize(request, socket, head, logger);
// 检查是否是调试 WebSocket 连接
if (url.pathname.startsWith('/api/Debug/ws')) {
handleDebugWebSocket(request, socket, head);
} else {
// 默认为终端 WebSocket
terminalManager.initialize(request, socket, head, logger);
}
}); });
// 挂载API接口 // 挂载API接口
app.use('/api', ALLRouter); app.use('/api', ALLRouter);

View File

@ -20,7 +20,6 @@
"@sinclair/typebox": "^0.34.38", "@sinclair/typebox": "^0.34.38",
"ajv": "^8.13.0", "ajv": "^8.13.0",
"compressing": "^1.10.3", "compressing": "^1.10.3",
"compression": "^1.8.1",
"express": "^5.0.0", "express": "^5.0.0",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"json5": "^2.2.3", "json5": "^2.2.3",
@ -30,7 +29,6 @@
"ws": "^8.18.3" "ws": "^8.18.3"
}, },
"devDependencies": { "devDependencies": {
"@types/compression": "^1.8.1",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/multer": "^1.4.12", "@types/multer": "^1.4.12",
"@types/node": "^22.0.1", "@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字体文件处理方法 // 删除WebUI字体文件处理方法
export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => { export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => {
try { try {
const fontPath = await WebUiConfig.GetWebUIFontPath(); const fontPath = WebUiConfig.GetWebUIFontPath();
const exists = await WebUiConfig.CheckWebUIFontExist(); const exists = await WebUiConfig.CheckWebUIFontExist();
if (!exists || !fontPath) { if (!exists) {
return sendSuccess(res, true); 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: '', nick: '',
}, },
QQVersion: 'unknown', QQVersion: 'unknown',
OneBotContext: null,
onQQLoginStatusChange: async (status: boolean) => { onQQLoginStatusChange: async (status: boolean) => {
LoginRuntime.QQLoginStatus = status; LoginRuntime.QQLoginStatus = status;
}, },
@ -155,12 +154,4 @@ export const WebUiDataRuntime = {
runWebUiConfigQuickFunction: async function () { runWebUiConfigQuickFunction: async function () {
await LoginRuntime.WebUiConfigQuickFunction(); 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 []; return [];
} }
// 判断字体是否存在(支持多种格式 // 判断字体是否存在(webui.woff
async CheckWebUIFontExist (): Promise<boolean> { async CheckWebUIFontExist (): Promise<boolean> {
const fontPath = await this.GetWebUIFontPath(); const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
if (!fontPath) return false;
return await fs return await fs
.access(fontPath, constants.F_OK) .access(resolve(fontsPath, './webui.woff'), constants.F_OK)
.then(() => true) .then(() => true)
.catch(() => false); .catch(() => false);
} }
// 获取webui字体文件路径支持多种格式 // 获取webui字体文件路径
async GetWebUIFontPath (): Promise<string | null> { GetWebUIFontPath (): string {
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 {
return resolve(webUiPathWrapper.configPath, './fonts/webui.woff'); 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 { FileRouter } from './File';
import { WebUIConfigRouter } from './WebUIConfig'; import { WebUIConfigRouter } from './WebUIConfig';
import { UpdateNapCatRouter } from './UpdateNapCat'; import { UpdateNapCatRouter } from './UpdateNapCat';
import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
const router = Router(); const router = Router();
@ -42,7 +41,5 @@ router.use('/File', FileRouter);
router.use('/WebUIConfig', WebUIConfigRouter); router.use('/WebUIConfig', WebUIConfigRouter);
// router:更新NapCat相关路由 // router:更新NapCat相关路由
router.use('/UpdateNapCat', UpdateNapCatRouter); router.use('/UpdateNapCat', UpdateNapCatRouter);
// router:调试相关路由
router.use('/Debug', DebugRouter);
export { router as ALLRouter }; export { router as ALLRouter };

View File

@ -47,7 +47,6 @@ export interface LoginRuntimeType {
onQQLoginStatusChange: (status: boolean) => Promise<void>; onQQLoginStatusChange: (status: boolean) => Promise<void>;
onWebUiTokenChange: (token: string) => Promise<void>; onWebUiTokenChange: (token: string) => Promise<void>;
WebUiConfigQuickFunction: () => Promise<void>; WebUiConfigQuickFunction: () => Promise<void>;
OneBotContext: any | null; // OneBot 上下文,用于调试功能
NapCatHelper: { NapCatHelper: {
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>; onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>; onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;

View File

@ -4,11 +4,9 @@ export const themeType = Type.Object(
{ {
dark: Type.Record(Type.String(), Type.String()), dark: Type.Record(Type.String(), Type.String()),
light: Type.Record(Type.String(), Type.String()), light: Type.Record(Type.String(), Type.String()),
fontMode: Type.String({ default: 'system' }),
}, },
{ {
default: { default: {
fontMode: 'system',
dark: { dark: {
'--heroui-background': '0 0% 0%', '--heroui-background': '0 0% 0%',
'--heroui-foreground-50': '240 5.88% 10%', '--heroui-foreground-50': '240 5.88% 10%',
@ -126,11 +124,11 @@ export const themeType = Type.Object(
'--heroui-border-width-medium': '2px', '--heroui-border-width-medium': '2px',
'--heroui-border-width-large': '3px', '--heroui-border-width-large': '3px',
'--heroui-box-shadow-small': '--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': '--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': '--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', '--heroui-hover-opacity': '.9',
}, },
light: { light: {
@ -250,11 +248,11 @@ export const themeType = Type.Object(
'--heroui-border-width-medium': '2px', '--heroui-border-width-medium': '2px',
'--heroui-border-width-large': '3px', '--heroui-border-width-large': '3px',
'--heroui-box-shadow-small': '--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': '--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': '--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', '--heroui-hover-opacity': '.8',
}, },
}, },

View File

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

View File

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

View File

@ -5,19 +5,12 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host=0.0.0.0", "dev": "vite --host=0.0.0.0",
"build": "vite build", "build": "tsc && vite build",
"build:full": "tsc && vite build",
"fontmin": "node scripts/fontmin.cjs",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix", "lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "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/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@ -29,7 +22,6 @@
"@heroui/checkbox": "2.3.9", "@heroui/checkbox": "2.3.9",
"@heroui/chip": "2.2.7", "@heroui/chip": "2.2.7",
"@heroui/code": "2.2.7", "@heroui/code": "2.2.7",
"@heroui/divider": "^2.2.21",
"@heroui/dropdown": "2.3.10", "@heroui/dropdown": "2.3.10",
"@heroui/form": "2.1.9", "@heroui/form": "2.1.9",
"@heroui/image": "2.2.6", "@heroui/image": "2.2.6",
@ -53,10 +45,11 @@
"@heroui/theme": "2.4.6", "@heroui/theme": "2.4.6",
"@heroui/tooltip": "2.2.8", "@heroui/tooltip": "2.2.8",
"@monaco-editor/loader": "^1.4.0", "@monaco-editor/loader": "^1.4.0",
"@monaco-editor/react": "4.7.0-rc.0",
"@react-aria/visually-hidden": "^3.8.19", "@react-aria/visually-hidden": "^3.8.19",
"@reduxjs/toolkit": "^2.5.1", "@reduxjs/toolkit": "^2.5.1",
"@simplewebauthn/browser": "^13.2.2",
"@uidotdev/usehooks": "^2.4.1", "@uidotdev/usehooks": "^2.4.1",
"@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
@ -65,7 +58,10 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"echarts": "^5.5.1",
"event-source-polyfill": "^1.0.31", "event-source-polyfill": "^1.0.31",
"framer-motion": "^12.0.6",
"monaco-editor": "^0.52.2",
"motion": "^12.0.6", "motion": "^12.0.6",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"qface": "^1.4.1", "qface": "^1.4.1",
@ -82,6 +78,7 @@
"react-markdown": "^9.0.3", "react-markdown": "^9.0.3",
"react-photo-view": "^1.2.7", "react-photo-view": "^1.2.7",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-responsive": "^10.0.0",
"react-router-dom": "^7.1.4", "react-router-dom": "^7.1.4",
"react-use-websocket": "^4.11.1", "react-use-websocket": "^4.11.1",
"react-window": "^1.8.11", "react-window": "^1.8.11",
@ -109,15 +106,10 @@
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "5.2.3", "eslint-plugin-prettier": "5.2.3",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"fontmin": "^0.9.9",
"glob": "^10.3.10",
"postcss": "^8.5.1", "postcss": "^8.5.1",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"sharp": "^0.34.5",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.0.5", "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-plugin-static-copy": "^2.2.0",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
@ -131,4 +123,4 @@
"react-dom": "$react-dom" "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 Toaster from '@/components/toaster';
import DialogProvider from '@/contexts/dialog'; import DialogProvider from '@/contexts/dialog';
import AudioProvider from '@/contexts/songs';
import useAuth from '@/hooks/auth'; import useAuth from '@/hooks/auth';
@ -32,11 +33,13 @@ function App () {
<Provider store={store}> <Provider store={store}>
<PageBackground /> <PageBackground />
<Toaster /> <Toaster />
<Suspense fallback={<PageLoading />}> <AudioProvider>
<AuthChecker> <Suspense fallback={<PageLoading />}>
<AppRoutes /> <AuthChecker>
</AuthChecker> <AppRoutes />
</Suspense> </AuthChecker>
</Suspense>
</AudioProvider>
</Provider> </Provider>
</DialogProvider> </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'; } from '../icons';
export interface AddButtonProps { export interface AddButtonProps {
onOpen: (key: keyof OneBotConfig['network']) => void; onOpen: (key: keyof OneBotConfig['network']) => void
} }
const AddButton: React.FC<AddButtonProps> = (props) => { const AddButton: React.FC<AddButtonProps> = (props) => {
@ -33,7 +33,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
> >
<DropdownTrigger> <DropdownTrigger>
<Button <Button
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md" color='primary'
startContent={<IoAddCircleOutline className='text-2xl' />} startContent={<IoAddCircleOutline className='text-2xl' />}
> >
@ -41,7 +41,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu <DropdownMenu
aria-label='Create Network Config' aria-label='Create Network Config'
color='default' color='primary'
variant='flat' variant='flat'
onAction={(key) => { onAction={(key) => {
onOpen(key as keyof OneBotConfig['network']); onOpen(key as keyof OneBotConfig['network']);

View File

@ -4,11 +4,11 @@ import toast from 'react-hot-toast';
import { IoMdRefresh } from 'react-icons/io'; import { IoMdRefresh } from 'react-icons/io';
export interface SaveButtonsProps { export interface SaveButtonsProps {
onSubmit: () => void; onSubmit: () => void
reset: () => void; reset: () => void
refresh?: () => void; refresh?: () => void
isSubmitting: boolean; isSubmitting: boolean
className?: string; className?: string
} }
const SaveButtons: React.FC<SaveButtonsProps> = ({ const SaveButtons: React.FC<SaveButtonsProps> = ({
@ -20,15 +20,13 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
}) => ( }) => (
<div <div
className={clsx( 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 className
)} )}
> >
<div className='flex items-center justify-center gap-2 mt-5'> <div className='flex items-center justify-center gap-2 mt-5'>
<Button <Button
radius="full" color='default'
variant="flat"
className="font-medium bg-default-100 text-default-600 dark:bg-default-50/50"
onPress={() => { onPress={() => {
reset(); reset();
toast.success('重置成功'); toast.success('重置成功');
@ -38,8 +36,6 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
</Button> </Button>
<Button <Button
color='primary' color='primary'
radius="full"
className="font-medium shadow-md shadow-primary/20"
isLoading={isSubmitting} isLoading={isSubmitting}
onPress={() => onSubmit()} onPress={() => onSubmit()}
> >
@ -48,12 +44,12 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
{refresh && ( {refresh && (
<Button <Button
isIconOnly isIconOnly
color='secondary'
radius='full' radius='full'
variant='flat' variant='flat'
className="text-default-500 bg-default-100 dark:bg-default-50/50"
onPress={() => refresh()} onPress={() => refresh()}
> >
<IoMdRefresh size={20} /> <IoMdRefresh size={24} />
</Button> </Button>
)} )}
</div> </div>

View File

@ -10,27 +10,14 @@ import {
import ChatInput from '.'; import ChatInput from '.';
interface ChatInputModalProps { export default function ChatInputModal () {
children?: (onOpen: () => void) => React.ReactNode;
}
export default function ChatInputModal ({ children }: ChatInputModalProps) {
const { isOpen, onOpen, onOpenChange } = useDisclosure(); const { isOpen, onOpen, onOpenChange } = useDisclosure();
return ( return (
<> <>
{children ? children(onOpen) : ( <Button onPress={onOpen} color='primary' radius='full' variant='flat'>
<Button
onPress={onOpen} </Button>
color='primary'
radius='full'
variant='flat'
size='sm'
className="bg-primary/10 text-primary"
>
</Button>
)}
<Modal <Modal
size='4xl' size='4xl'
scrollBehavior='inside' scrollBehavior='inside'

View File

@ -1,126 +1,55 @@
import React, { useImperativeHandle, useEffect, useState } from 'react'; import Editor, { OnMount, loader } from '@monaco-editor/react';
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { json } from '@codemirror/lang-json'; import React from 'react';
import { oneDark } from '@codemirror/theme-one-dark';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/hooks/use-theme';
import { EditorView } from '@codemirror/view';
import clsx from 'clsx';
const getLanguageExtension = (lang?: string) => { import monaco from '@/monaco';
switch (lang) {
case 'json': return json();
default: return [];
}
};
export interface CodeEditorProps { loader.config({
value?: string; monaco,
defaultValue?: string; paths: {
language?: string; vs: '/webui/monaco-editor/min/vs',
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({
'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; 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 { Switch } from '@heroui/switch';
import clsx from 'clsx';
import { useState } from 'react'; import { useState } from 'react';
import { CgDebug } from 'react-icons/cg'; import { CgDebug } from 'react-icons/cg';
import { FiEdit3 } from 'react-icons/fi'; import { FiEdit3 } from 'react-icons/fi';
@ -11,26 +10,27 @@ import DisplayCardContainer from './container';
type NetworkType = OneBotConfig['network']; type NetworkType = OneBotConfig['network'];
export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{ export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{
label: string; label: string
value: NetworkType[T][0][keyof NetworkType[T][0]]; value: NetworkType[T][0][keyof NetworkType[T][0]]
render?: ( render?: (
value: NetworkType[T][0][keyof NetworkType[T][0]] value: NetworkType[T][0][keyof NetworkType[T][0]]
) => React.ReactNode; ) => React.ReactNode
}>; }>;
export interface NetworkDisplayCardProps<T extends keyof NetworkType> { export interface NetworkDisplayCardProps<T extends keyof NetworkType> {
data: NetworkType[T][0]; data: NetworkType[T][0]
typeLabel: string; showType?: boolean
fields: NetworkDisplayCardFields<T>; typeLabel: string
onEdit: () => void; fields: NetworkDisplayCardFields<T>
onEnable: () => Promise<void>; onEdit: () => void
onDelete: () => Promise<void>; onEnable: () => Promise<void>
onEnableDebug: () => Promise<void>; onDelete: () => Promise<void>
showType?: boolean; onEnableDebug: () => Promise<void>
} }
const NetworkDisplayCard = <T extends keyof NetworkType> ({ const NetworkDisplayCard = <T extends keyof NetworkType>({
data, data,
showType,
typeLabel, typeLabel,
fields, fields,
onEdit, onEdit,
@ -56,146 +56,79 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
onEnableDebug().finally(() => setEditing(false)); onEnableDebug().finally(() => setEditing(false));
}; };
const isFullWidthField = (label: string) => ['URL', 'Token', 'AccessToken'].includes(label);
return ( return (
<DisplayCardContainer <DisplayCardContainer
className="w-full max-w-[420px]"
action={ action={
<div className="flex gap-2 w-full"> <ButtonGroup
fullWidth
isDisabled={editing}
radius='sm'
size='sm'
variant='flat'
>
<Button <Button
fullWidth color='warning'
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"
startContent={<FiEdit3 size={16} />} startContent={<FiEdit3 size={16} />}
onPress={onEdit} onPress={onEdit}
isDisabled={editing}
> >
</Button> </Button>
<Button <Button
fullWidth color={debug ? 'secondary' : 'success'}
radius='full'
size='sm'
variant='flat' variant='flat'
className={clsx( startContent={
"flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors", <CgDebug
debug style={{
? "hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary" width: '16px',
: "hover:bg-success/20 hover:text-success data-[hover=true]:text-success" height: '16px',
)} minWidth: '16px',
startContent={<CgDebug size={16} />} minHeight: '16px',
}}
/>
}
onPress={handleEnableDebug} onPress={handleEnableDebug}
isDisabled={editing}
> >
{debug ? '关闭调试' : '开启调试'} {debug ? '关闭调试' : '开启调试'}
</Button> </Button>
<Button <Button
fullWidth className='bg-danger/20 text-danger hover:bg-danger/30 transition-colors'
radius='full'
size='sm'
variant='flat' 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} />} startContent={<MdDeleteForever size={16} />}
onPress={handleDelete} onPress={handleDelete}
isDisabled={editing}
> >
</Button> </Button>
</div> </ButtonGroup>
} }
enableSwitch={ enableSwitch={
<Switch <Switch
isDisabled={editing} isDisabled={editing}
isSelected={enable} isSelected={enable}
onChange={handleEnable} 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'> <div className='grid grid-cols-2 gap-1'>
{(() => { {fields.map((field, index) => (
const targetFullField = fields.find(f => isFullWidthField(f.label)); <div
key={index}
if (targetFullField) { className={`flex items-center gap-2 ${
// 模式1存在全宽字段如URL布局为 field.label === 'URL' ? 'col-span-2' : ''
// Row 1: 名称 (全宽) }`}
// Row 2: 全宽字段 (全宽) >
return ( <span className='text-default-400'>{field.label}</span>
<> {field.render
<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' field.render(field.value)
> )
<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"> <span>{field.value}</span>
{name} )}
</div> </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> </div>
</DisplayCardContainer> </DisplayCardContainer>
); );

View File

@ -1,24 +1,22 @@
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card'; import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import key from '@/const/key';
import { title } from '../primitives';
export interface ContainerProps { export interface ContainerProps {
title: string; title: string
tag?: React.ReactNode; tag?: React.ReactNode
action: React.ReactNode; action: React.ReactNode
enableSwitch: React.ReactNode; enableSwitch: React.ReactNode
children: React.ReactNode; children: React.ReactNode
className?: string; // Add className prop
} }
export interface DisplayCardProps { export interface DisplayCardProps {
showType?: boolean; showType?: boolean
onEdit: () => void; onEdit: () => void
onEnable: () => Promise<void>; onEnable: () => Promise<void>
onDelete: () => Promise<void>; onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>; onEnableDebug: () => Promise<void>
} }
const DisplayCardContainer: React.FC<ContainerProps> = ({ const DisplayCardContainer: React.FC<ContainerProps> = ({
@ -27,35 +25,31 @@ const DisplayCardContainer: React.FC<ContainerProps> = ({
tag, tag,
enableSwitch, enableSwitch,
children, children,
className,
}) => { }) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card className={clsx( <Card className='bg-opacity-50 backdrop-blur-sm'>
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm rounded-2xl overflow-hidden transition-all', <CardHeader className='pb-0 flex items-center'>
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'>
{tag && ( {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} {tag}
</div> </div>
)} )}
<div className='flex-1 min-w-0 mr-2'> <h2
<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'> className={clsx(
<span className='font-bold text-default-600 dark:text-white/90 text-sm truncate select-text'> title({
{_title} color: 'foreground',
</span> size: 'xs',
</div> shadow: true,
</div> }),
<div className='flex-shrink-0'>{enableSwitch}</div> 'truncate'
)}
>
{_title}
</h2>
<div className='ml-auto'>{enableSwitch}</div>
</CardHeader> </CardHeader>
<CardBody className='px-4 py-2 text-sm text-default-600'>{children}</CardBody> <CardBody className='text-sm'>{children}</CardBody>
<CardFooter className='px-4 pb-4 pt-2'>{action}</CardFooter> <CardFooter>{action}</CardFooter>
</Card> </Card>
); );
}; };

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card'; import type { NetworkDisplayCardFields } from './common_card';
interface HTTPClientDisplayCardProps { interface HTTPClientDisplayCardProps {
data: OneBotConfig['network']['httpClients'][0]; data: OneBotConfig['network']['httpClients'][0]
showType?: boolean; showType?: boolean
onEdit: () => void; onEdit: () => void
onEnable: () => Promise<void>; onEnable: () => Promise<void>
onDelete: () => Promise<void>; onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>; onEnableDebug: () => Promise<void>
} }
const HTTPClientDisplayCard: React.FC<HTTPClientDisplayCardProps> = (props) => { const HTTPClientDisplayCard: React.FC<HTTPClientDisplayCardProps> = (props) => {

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card'; import type { NetworkDisplayCardFields } from './common_card';
interface HTTPServerDisplayCardProps { interface HTTPServerDisplayCardProps {
data: OneBotConfig['network']['httpServers'][0]; data: OneBotConfig['network']['httpServers'][0]
showType?: boolean; showType?: boolean
onEdit: () => void; onEdit: () => void
onEnable: () => Promise<void>; onEnable: () => Promise<void>
onDelete: () => Promise<void>; onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>; onEnableDebug: () => Promise<void>
} }
const HTTPServerDisplayCard: React.FC<HTTPServerDisplayCardProps> = (props) => { const HTTPServerDisplayCard: React.FC<HTTPServerDisplayCardProps> = (props) => {

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card'; import type { NetworkDisplayCardFields } from './common_card';
interface HTTPSSEServerDisplayCardProps { interface HTTPSSEServerDisplayCardProps {
data: OneBotConfig['network']['httpSseServers'][0]; data: OneBotConfig['network']['httpSseServers'][0]
showType?: boolean; showType?: boolean
onEdit: () => void; onEdit: () => void
onEnable: () => Promise<void>; onEnable: () => Promise<void>
onDelete: () => Promise<void>; onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>; onEnableDebug: () => Promise<void>
} }
const HTTPSSEServerDisplayCard: React.FC<HTTPSSEServerDisplayCardProps> = ( const HTTPSSEServerDisplayCard: React.FC<HTTPSSEServerDisplayCardProps> = (

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card'; import type { NetworkDisplayCardFields } from './common_card';
interface WebsocketClientDisplayCardProps { interface WebsocketClientDisplayCardProps {
data: OneBotConfig['network']['websocketClients'][0]; data: OneBotConfig['network']['websocketClients'][0]
showType?: boolean; showType?: boolean
onEdit: () => void; onEdit: () => void
onEnable: () => Promise<void>; onEnable: () => Promise<void>
onDelete: () => Promise<void>; onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>; onEnableDebug: () => Promise<void>
} }
const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = ( const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = (

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card'; import type { NetworkDisplayCardFields } from './common_card';
interface WebsocketServerDisplayCardProps { interface WebsocketServerDisplayCardProps {
data: OneBotConfig['network']['websocketServers'][0]; data: OneBotConfig['network']['websocketServers'][0]
showType?: boolean; showType?: boolean
onEdit: () => void; onEdit: () => void
onEnable: () => Promise<void>; onEnable: () => Promise<void>
onDelete: () => Promise<void>; onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>; onEnableDebug: () => Promise<void>
} }
const WebsocketServerDisplayCard: React.FC<WebsocketServerDisplayCardProps> = ( const WebsocketServerDisplayCard: React.FC<WebsocketServerDisplayCardProps> = (

View File

@ -1,14 +1,12 @@
import { Card, CardBody } from '@heroui/card'; import { Card, CardBody } from '@heroui/card';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import key from '@/const/key';
import { title } from '@/components/primitives';
export interface NetworkItemDisplayProps { export interface NetworkItemDisplayProps {
count: number; count: number
label: string; label: string
size?: 'sm' | 'md'; size?: 'sm' | 'md'
} }
const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
@ -16,37 +14,38 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
label, label,
size = 'md', size = 'md',
}) => { }) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card <Card
className={clsx( className={clsx(
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all', 'bg-opacity-60 shadow-sm md:rounded-3xl',
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',
size === 'md' size === 'md'
? 'col-span-8 md:col-span-2' ? 'col-span-8 md:col-span-2 bg-primary-50 shadow-primary-100'
: 'col-span-2 md:col-span-1' : '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'> <CardBody className='items-center md:gap-1 p-1 md:p-2'>
<div <div
className={clsx( className={clsx(
'flex-1 font-mono font-bold', 'flex-1',
size === 'md' ? 'text-4xl md:text-5xl' : 'text-2xl md:text-3xl', size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200' title({
color: size === 'md' ? 'pink' : 'yellow',
size,
})
)} )}
> >
{count} {count}
</div> </div>
<div <div
className={clsx( className={clsx(
'whitespace-nowrap text-nowrap flex-shrink-0 font-medium', 'whitespace-nowrap text-nowrap flex-shrink-0',
size === 'md' ? 'text-sm' : 'text-xs', size === 'md' ? 'text-sm md:text-base' : 'text-xs md:text-sm',
hasBackground ? 'text-white/80' : 'text-default-500' title({
color: size === 'md' ? 'pink' : 'yellow',
shadow: true,
size: 'xxs',
})
)} )}
> >
{label} {label}

View File

@ -94,7 +94,7 @@ const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
ref={lightRef} ref={lightRef}
className={clsx( className={clsx(
isShowLight ? 'opacity-100' : 'opacity-0', 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 lightClassName
)} )}
style={{ style={{

View File

@ -11,11 +11,11 @@ import {
import CodeEditor from '@/components/code_editor'; import CodeEditor from '@/components/code_editor';
interface FileEditModalProps { interface FileEditModalProps {
isOpen: boolean; isOpen: boolean
file: { path: string; content: string; } | null; file: { path: string; content: string } | null
onClose: () => void; onClose: () => void
onSave: () => void; onSave: () => void
onContentChange: (newContent?: string) => void; onContentChange: (newContent?: string) => void
} }
export default function FileEditModal ({ export default function FileEditModal ({
@ -65,20 +65,12 @@ export default function FileEditModal ({
return ( return (
<Modal size='full' isOpen={isOpen} onClose={onClose}> <Modal size='full' isOpen={isOpen} onClose={onClose}>
<ModalContent> <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> <span></span>
<Code className='text-xs'>{file?.path}</Code> <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> </ModalHeader>
<ModalBody className='p-4 bg-content2/50'> <ModalBody className='p-0'>
<div className='h-full' onKeyDown={(e) => { <div className='h-full'>
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
onSave();
}
}}>
<CodeEditor <CodeEditor
height='100%' height='100%'
value={file?.content || ''} value={file?.content || ''}
@ -88,7 +80,7 @@ export default function FileEditModal ({
/> />
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter className="border-t border-default-200/50"> <ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}> <Button color='primary' variant='flat' onPress={onClose}>
</Button> </Button>

View File

@ -25,21 +25,21 @@ import { supportedPreviewExts } from './file_preview_modal';
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button'; import ImageNameButton, { PreviewImage, imageExts } from './image_name_button';
export interface FileTableProps { export interface FileTableProps {
files: FileInfo[]; files: FileInfo[]
currentPath: string; currentPath: string
loading: boolean; loading: boolean
sortDescriptor: SortDescriptor; sortDescriptor: SortDescriptor
onSortChange: (descriptor: SortDescriptor) => void; onSortChange: (descriptor: SortDescriptor) => void
selectedFiles: Selection; selectedFiles: Selection
onSelectionChange: (selected: Selection) => void; onSelectionChange: (selected: Selection) => void
onDirectoryClick: (dirPath: string) => void; onDirectoryClick: (dirPath: string) => void
onEdit: (filePath: string) => void; onEdit: (filePath: string) => void
onPreview: (filePath: string) => void; onPreview: (filePath: string) => void
onRenameRequest: (name: string) => void; onRenameRequest: (name: string) => void
onMoveRequest: (name: string) => void; onMoveRequest: (name: string) => void
onCopyPath: (fileName: string) => void; onCopyPath: (fileName: string) => void
onDelete: (filePath: string) => void; onDelete: (filePath: string) => void
onDownload: (filePath: string) => void; onDownload: (filePath: string) => void
} }
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@ -112,7 +112,7 @@ export default function FileTable ({
selectedKeys={selectedFiles} selectedKeys={selectedFiles}
selectionMode='multiple' selectionMode='multiple'
bottomContent={ bottomContent={
<div className='flex w-full justify-center p-2 border-t border-white/10'> <div className='flex w-full justify-center'>
<Pagination <Pagination
isCompact isCompact
showControls showControls
@ -121,29 +121,21 @@ export default function FileTable ({
page={page} page={page}
total={pages} total={pages}
onChange={(page) => setPage(page)} onChange={(page) => setPage(page)}
classNames={{
cursor: 'bg-primary shadow-lg',
}}
/> />
</div> </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> <TableHeader>
<TableColumn key='name' allowsSorting> <TableColumn key='name' allowsSorting>
</TableColumn> </TableColumn>
<TableColumn key='type' allowsSorting className='hidden md:table-cell'> <TableColumn key='type' allowsSorting>
</TableColumn> </TableColumn>
<TableColumn key='size' allowsSorting className='hidden md:table-cell'> <TableColumn key='size' allowsSorting>
</TableColumn> </TableColumn>
<TableColumn key='mtime' allowsSorting className='hidden md:table-cell'> <TableColumn key='mtime' allowsSorting>
</TableColumn> </TableColumn>
<TableColumn key='actions'></TableColumn> <TableColumn key='actions'></TableColumn>
@ -188,57 +180,57 @@ export default function FileTable ({
name={file.name} name={file.name}
isDirectory={file.isDirectory} isDirectory={file.isDirectory}
/> />
} }
> >
{file.name} {file.name}
</Button> </Button>
)} )}
</TableCell> </TableCell>
<TableCell className='hidden md:table-cell'>{file.isDirectory ? '目录' : '文件'}</TableCell> <TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
<TableCell className='hidden md:table-cell'> <TableCell>
{isNaN(file.size) || file.isDirectory {isNaN(file.size) || file.isDirectory
? '-' ? '-'
: `${file.size} 字节`} : `${file.size} 字节`}
</TableCell> </TableCell>
<TableCell className='hidden md:table-cell'>{new Date(file.mtime).toLocaleString()}</TableCell> <TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
<TableCell> <TableCell>
<ButtonGroup size='sm' variant='light'> <ButtonGroup size='sm'>
<Button <Button
isIconOnly isIconOnly
color='default' color='primary'
className='text-default-500 hover:text-primary' variant='flat'
onPress={() => onRenameRequest(file.name)} onPress={() => onRenameRequest(file.name)}
> >
<BiRename /> <BiRename />
</Button> </Button>
<Button <Button
isIconOnly isIconOnly
color='default' color='primary'
className='text-default-500 hover:text-primary' variant='flat'
onPress={() => onMoveRequest(file.name)} onPress={() => onMoveRequest(file.name)}
> >
<FiMove /> <FiMove />
</Button> </Button>
<Button <Button
isIconOnly isIconOnly
color='default' color='primary'
className='text-default-500 hover:text-primary' variant='flat'
onPress={() => onCopyPath(file.name)} onPress={() => onCopyPath(file.name)}
> >
<FiCopy /> <FiCopy />
</Button> </Button>
<Button <Button
isIconOnly isIconOnly
color='default' color='primary'
className='text-default-500 hover:text-primary' variant='flat'
onPress={() => onDownload(filePath)} onPress={() => onDownload(filePath)}
> >
<FiDownload /> <FiDownload />
</Button> </Button>
<Button <Button
isIconOnly isIconOnly
color='danger' color='primary'
className='text-danger hover:bg-danger/10' variant='flat'
onPress={() => onDelete(filePath)} onPress={() => onDelete(filePath)}
> >
<FiTrash2 /> <FiTrash2 />

View File

@ -1,13 +1,9 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { Tooltip } from '@heroui/tooltip'; import { Tooltip } from '@heroui/tooltip';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import clsx from 'clsx';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { IoMdQuote } from 'react-icons/io';
import { IoCopy, IoRefresh } from 'react-icons/io5'; import { IoCopy, IoRefresh } from 'react-icons/io5';
import key from '@/const/key';
import { request } from '@/utils/request'; import { request } from '@/utils/request';
import PageLoading from './page_loading'; import PageLoading from './page_loading';
@ -19,17 +15,10 @@ export default function Hitokoto () {
loading, loading,
run, run,
} = useRequest(() => request.get<IHitokoto>('https://hitokoto.152710.xyz/'), { } = useRequest(() => request.get<IHitokoto>('https://hitokoto.152710.xyz/'), {
pollingInterval: 10000,
throttleWait: 1000, throttleWait: 1000,
}); });
const backupData = { const data = dataOri?.data;
hitokoto: '凡是过往,皆为序章。',
from: '暴风雨',
from_who: '莎士比亚',
};
const data = dataOri?.data || (error ? backupData : undefined);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const onCopy = () => { const onCopy = () => {
try { try {
const text = `${data?.hitokoto} —— ${data?.from} ${data?.from_who}`; const text = `${data?.hitokoto} —— ${data?.from} ${data?.from_who}`;
@ -41,61 +30,44 @@ export default function Hitokoto () {
}; };
return ( return (
<div> <div>
<div className='relative flex flex-col items-center justify-center p-6 min-h-[120px]'> <div className='relative'>
{loading && !data && <PageLoading />} {loading && <PageLoading />}
{data && ( {error
<> ? (
<IoMdQuote className={clsx( <div className='text-primary-400'>{error.message}</div>
"text-4xl mb-4", )
hasBackground ? "text-white/30" : "text-primary/20" : (
)} /> <>
<div className={clsx( <div>{data?.hitokoto}</div>
"text-xl font-medium tracking-wide leading-relaxed italic", <div className='text-right'>
hasBackground ? "text-white drop-shadow-sm" : "text-default-700 dark:text-gray-200" <span className='text-default-400'>{data?.from}</span>{' '}
)}> {data?.from_who}
" {data?.hitokoto} " </div>
</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> </div>
<div className='flex gap-2'> <div className='flex gap-2'>
<Tooltip content='刷新' placement='top'> <Tooltip content='刷新' placement='top'>
<Button <Button
className={clsx(
"transition-colors",
hasBackground ? "text-white/60 hover:text-white" : "text-default-400 hover:text-primary"
)}
onPress={run} onPress={run}
size='sm' size='sm'
isLoading={loading} isLoading={loading}
isIconOnly isIconOnly
radius='full' radius='full'
variant='light' color='primary'
variant='flat'
> >
<IoRefresh /> <IoRefresh />
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip content='复制' placement='top'> <Tooltip content='复制' placement='top'>
<Button <Button
className={clsx(
"transition-colors",
hasBackground ? "text-white/60 hover:text-white" : "text-default-400 hover:text-success"
)}
onPress={onCopy} onPress={onCopy}
size='sm' size='sm'
isIconOnly isIconOnly
radius='full' radius='full'
variant='light' color='success'
variant='flat'
> >
<IoCopy /> <IoCopy />
</Button> </Button>

View File

@ -7,7 +7,6 @@ export interface FileInputProps {
onDelete?: () => Promise<void> | void; onDelete?: () => Promise<void> | void;
label?: string; label?: string;
accept?: string; accept?: string;
placeholder?: string;
} }
const FileInput: React.FC<FileInputProps> = ({ const FileInput: React.FC<FileInputProps> = ({
@ -15,7 +14,6 @@ const FileInput: React.FC<FileInputProps> = ({
onDelete, onDelete,
label, label,
accept, accept,
placeholder,
}) => { }) => {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -27,13 +25,8 @@ const FileInput: React.FC<FileInputProps> = ({
ref={inputRef} ref={inputRef}
label={label} label={label}
type='file' type='file'
placeholder={placeholder || '选择文件'} placeholder='选择文件'
accept={accept} 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) => { onChange={async (e) => {
try { try {
setIsLoading(true); setIsLoading(true);

View File

@ -4,9 +4,9 @@ import { Input } from '@heroui/input';
import { useRef } from 'react'; import { useRef } from 'react';
export interface ImageInputProps { export interface ImageInputProps {
onChange: (base64: string) => void; onChange: (base64: string) => void
value: string; value: string
label?: string; label?: string
} }
const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => { const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
@ -26,11 +26,6 @@ const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
type='file' type='file'
placeholder='选择图片' placeholder='选择图片'
accept='image/*' 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) => { onChange={async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {

View File

@ -2,11 +2,8 @@ import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card'; import { Card, CardBody, CardHeader } from '@heroui/card';
import { Select, SelectItem } from '@heroui/select'; import { Select, SelectItem } from '@heroui/select';
import type { Selection } from '@react-types/shared'; import type { Selection } from '@react-types/shared';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import key from '@/const/key';
import { colorizeLogLevel } from '@/utils/terminal'; import { colorizeLogLevel } from '@/utils/terminal';
import PageLoading from '../page_loading'; import PageLoading from '../page_loading';
@ -15,15 +12,15 @@ import type { XTermRef } from '../xterm';
import LogLevelSelect from './log_level_select'; import LogLevelSelect from './log_level_select';
export interface HistoryLogsProps { export interface HistoryLogsProps {
list: string[]; list: string[]
onSelect: (name: string) => void; onSelect: (name: string) => void
selectedLog?: string; selectedLog?: string
refreshList: () => void; refreshList: () => void
refreshLog: () => void; refreshLog: () => void
listLoading?: boolean; listLoading?: boolean
logLoading?: boolean; logLoading?: boolean
listError?: Error; listError?: Error
logContent?: string; logContent?: string
} }
const HistoryLogs: React.FC<HistoryLogsProps> = (props) => { const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
const { const {
@ -42,8 +39,6 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
const [logLevel, setLogLevel] = useState<Selection>( const [logLevel, setLogLevel] = useState<Selection>(
new Set(['info', 'warn', 'error']) new Set(['info', 'warn', 'error'])
); );
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const logToColored = (log: string) => { const logToColored = (log: string) => {
const logs = log const logs = log
@ -88,10 +83,7 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
return ( return (
<> <>
<title> - NapCat WebUI</title> <title> - NapCat WebUI</title>
<Card className={clsx( <Card className='max-w-full h-full bg-opacity-50 backdrop-blur-sm'>
'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'
)}>
<CardHeader className='flex-row justify-start gap-3'> <CardHeader className='flex-row justify-start gap-3'>
<Select <Select
label='选择日志' label='选择日志'
@ -100,7 +92,7 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
errorMessage={listError?.message} errorMessage={listError?.message}
classNames={{ classNames={{
trigger: 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='选择日志' placeholder='选择日志'
onChange={(e) => { onChange={(e) => {
@ -126,13 +118,11 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
selectedKeys={logLevel} selectedKeys={logLevel}
onSelectionChange={setLogLevel} onSelectionChange={setLogLevel}
/> />
<div className='flex gap-2 ml-auto'> <Button className='flex-shrink-0' onPress={onDownloadLog}>
<Button className='flex-shrink-0' onPress={onDownloadLog} size='sm' variant='flat' color='primary'>
</Button>
</Button> <Button onPress={refreshList}></Button>
<Button onPress={refreshList} size='sm' variant='flat'></Button> <Button onPress={refreshLog}></Button>
<Button onPress={refreshLog} size='sm' variant='flat'></Button>
</div>
</CardHeader> </CardHeader>
<CardBody className='relative'> <CardBody className='relative'>
<PageLoading loading={logLoading} /> <PageLoading loading={logLoading} />

View File

@ -6,17 +6,17 @@ import type { Selection } from '@react-types/shared';
import { LogLevel } from '@/const/enum'; import { LogLevel } from '@/const/enum';
export interface LogLevelSelectProps { export interface LogLevelSelectProps {
selectedKeys: Selection; selectedKeys: Selection
onSelectionChange: (keys: SharedSelection) => void; onSelectionChange: (keys: SharedSelection) => void
} }
const logLevelColor: { const logLevelColor: {
[key in LogLevel]: [key in LogLevel]:
| 'default' | 'default'
| 'primary' | 'primary'
| 'secondary' | 'secondary'
| 'success' | 'success'
| 'warning' | 'warning'
| 'primary' | 'primary'
} = { } = {
[LogLevel.DEBUG]: 'default', [LogLevel.DEBUG]: 'default',
[LogLevel.INFO]: 'primary', [LogLevel.INFO]: 'primary',
@ -40,7 +40,7 @@ const LogLevelSelect = (props: LogLevelSelectProps) => {
aria-label='Log Level' aria-label='Log Level'
classNames={{ classNames={{
label: 'mb-2', 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', popoverContent: 'bg-opacity-50 backdrop-blur-sm',
}} }}
size='sm' size='sm'

View File

@ -1,12 +1,9 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import type { Selection } from '@react-types/shared'; import type { Selection } from '@react-types/shared';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { IoDownloadOutline } from 'react-icons/io5'; import { IoDownloadOutline } from 'react-icons/io5';
import key from '@/const/key';
import { colorizeLogLevelWithTag } from '@/utils/terminal'; import { colorizeLogLevelWithTag } from '@/utils/terminal';
import WebUIManager, { Log } from '@/controllers/webui_manager'; import WebUIManager, { Log } from '@/controllers/webui_manager';
@ -21,8 +18,6 @@ const RealTimeLogs = () => {
new Set(['info', 'warn', 'error']) new Set(['info', 'warn', 'error'])
); );
const [dataArr, setDataArr] = useState<Log[]>([]); const [dataArr, setDataArr] = useState<Log[]>([]);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const onDownloadLog = () => { const onDownloadLog = () => {
const logContent = dataArr const logContent = dataArr
@ -96,10 +91,7 @@ const RealTimeLogs = () => {
return ( return (
<> <>
<title> - NapCat WebUI</title> <title> - NapCat WebUI</title>
<div className={clsx( <div className='flex items-center gap-2'>
'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'
)}>
<LogLevelSelect <LogLevelSelect
selectedKeys={logLevel} selectedKeys={logLevel}
onSelectionChange={setLogLevel} onSelectionChange={setLogLevel}
@ -108,8 +100,6 @@ const RealTimeLogs = () => {
className='flex-shrink-0' className='flex-shrink-0'
onPress={onDownloadLog} onPress={onDownloadLog}
startContent={<IoDownloadOutline className='text-lg' />} startContent={<IoDownloadOutline className='text-lg' />}
color='primary'
variant='flat'
> >
</Button> </Button>

View File

@ -109,11 +109,6 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
isDisabled={field.isDisabled} isDisabled={field.isDisabled}
label={field.label} label={field.label}
placeholder={field.placeholder} 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': case 'select':
@ -126,10 +121,6 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
placeholder={field.placeholder} placeholder={field.placeholder}
selectedKeys={[controllerField.value as string]} selectedKeys={[controllerField.value as string]}
value={controllerField.value.toString()} 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) => ( {field.options?.map((option) => (
<SelectItem key={option.key} value={option.value}> <SelectItem key={option.key} value={option.value}>

View File

@ -1,15 +1,13 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card';
import { Input } from '@heroui/input'; import { Input } from '@heroui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'; import { Snippet } from '@heroui/snippet';
import { Tooltip } from '@heroui/tooltip';
import { Tab, Tabs } from '@heroui/tabs';
import { Chip } from '@heroui/chip';
import { useLocalStorage } from '@uidotdev/usehooks'; import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import { motion } from 'motion/react';
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useRef, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { IoChevronDown, IoSend, IoSettingsSharp, IoCopy } from 'react-icons/io5'; import { IoLink, IoSend } from 'react-icons/io5';
import { TbCode, TbMessageCode } from 'react-icons/tb'; import { PiCatDuotone } from 'react-icons/pi';
import key from '@/const/key'; import key from '@/const/key';
import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api'; import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api';
@ -19,7 +17,7 @@ import CodeEditor from '@/components/code_editor';
import PageLoading from '@/components/page_loading'; import PageLoading from '@/components/page_loading';
import { request } from '@/utils/request'; import { request } from '@/utils/request';
import { parseAxiosResponse } from '@/utils/url';
import { generateDefaultJson, parse } from '@/utils/zod'; import { generateDefaultJson, parse } from '@/utils/zod';
import DisplayStruct from './display_struct'; import DisplayStruct from './display_struct';
@ -27,11 +25,10 @@ import DisplayStruct from './display_struct';
export interface OneBotApiDebugProps { export interface OneBotApiDebugProps {
path: OneBotHttpApiPath; path: OneBotHttpApiPath;
data: OneBotHttpApiContent; data: OneBotHttpApiContent;
adapterName?: string;
} }
const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => { const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
const { path, data, adapterName } = props; const { path, data } = props;
const currentURL = new URL(window.location.origin); const currentURL = new URL(window.location.origin);
currentURL.port = '3000'; currentURL.port = '3000';
const defaultHttpUrl = currentURL.href; const defaultHttpUrl = currentURL.href;
@ -39,61 +36,21 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
url: defaultHttpUrl, url: defaultHttpUrl,
token: '', token: '',
}); });
const [requestBody, setRequestBody] = useState('{}'); const [requestBody, setRequestBody] = useState('{}');
const [responseContent, setResponseContent] = useState(''); const [responseContent, setResponseContent] = useState('');
const [isCodeEditorOpen, setIsCodeEditorOpen] = useState(false);
const [isResponseOpen, setIsResponseOpen] = useState(false);
const [isFetching, setIsFetching] = useState(false); const [isFetching, setIsFetching] = useState(false);
const [activeTab, setActiveTab] = useState<any>('request'); const responseRef = useRef<HTMLDivElement>(null);
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 parsedRequest = parse(data.request); const parsedRequest = parse(data.request);
const parsedResponse = parse(data.response); const parsedResponse = parse(data.response);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const sendRequest = async () => { const sendRequest = async () => {
if (isFetching) return; if (isFetching) return;
setIsFetching(true); setIsFetching(true);
setResponseStatus(null);
const r = toast.loading('正在发送请求...'); const r = toast.loading('正在发送请求...');
try { try {
const parsedRequestBody = JSON.parse(requestBody); 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); const requestURL = new URL(httpConfig.url);
requestURL.pathname = path; requestURL.pathname = path;
request request
@ -101,23 +58,23 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
headers: { headers: {
Authorization: `Bearer ${httpConfig.token}`, Authorization: `Bearer ${httpConfig.token}`,
}, },
}) // 移除 responseType: 'text',以便 axios 自动解析 JSON responseType: 'text',
})
.then((res) => { .then((res) => {
setResponseContent(JSON.stringify(res.data, null, 2)); setResponseContent(parseAxiosResponse(res));
setResponseStatus({ code: res.status, text: res.statusText }); toast.success('请求发送完成,请查看响应');
setResponseExpanded(true);
toast.success('请求成功');
}) })
.catch((err) => { .catch((err) => {
toast.error('请求失败:' + err.message); toast.error('请求发送失败:' + err.message);
setResponseContent(JSON.stringify(err.response?.data || { error: err.message }, null, 2)); setResponseContent(parseAxiosResponse(err.response));
if (err.response) {
setResponseStatus({ code: err.response.status, text: err.response.statusText });
}
setResponseExpanded(true);
}) })
.finally(() => { .finally(() => {
setIsFetching(false); setIsFetching(false);
setIsResponseOpen(true);
responseRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
toast.dismiss(r); toast.dismiss(r);
}); });
} catch (_error) { } catch (_error) {
@ -130,249 +87,150 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
useEffect(() => { useEffect(() => {
setRequestBody(generateDefaultJson(data.request)); setRequestBody(generateDefaultJson(data.request));
setResponseContent(''); setResponseContent('');
setResponseStatus(null);
}, [path]); }, [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 ( return (
<section className='h-full flex flex-col overflow-hidden bg-transparent'> <section className='p-4 pt-14 rounded-lg shadow-md'>
{/* URL Bar */} <h1 className='text-2xl font-bold mb-4 flex items-center gap-1 text-primary-400'>
<div className='flex flex-wrap md:flex-nowrap items-center gap-2 p-2 md:p-4 pb-2 flex-shrink-0'> <PiCatDuotone />
<div className={clsx( {data.description}
'flex-grow flex items-center gap-2 px-3 md:px-4 h-10 rounded-xl transition-all w-full md:w-auto', </h1>
hasBackground ? 'bg-white/5' : 'bg-black/5 dark:bg-white/5' <h1 className='text-lg font-bold mb-4'>
)}> <Snippet
<Chip size="sm" variant="shadow" color="primary" className="font-bold text-[10px] h-5 min-w-[40px]">POST</Chip> className='bg-default-50 bg-opacity-50 backdrop-blur-md'
<span className={clsx( symbol={<IoLink size={18} className='inline-block mr-1' />}
'text-xs font-mono truncate select-all flex-1 opacity-50', tooltipProps={{
hasBackground ? 'text-white' : 'text-default-600' content: '点击复制地址',
)}>{path}</span> }}
</div> >
{path}
<div className='flex items-center gap-2 flex-shrink-0 ml-auto'> </Snippet>
<Popover placement='bottom-end' backdrop='blur'> </h1>
<PopoverTrigger> <div className='flex gap-2 items-center'>
<Button size='sm' variant='light' radius='full' isIconOnly className='h-10 w-10 opacity-40 hover:opacity-100'> <Input
<IoSettingsSharp className="text-lg" /> label='HTTP URL'
</Button> placeholder='输入 HTTP URL'
</PopoverTrigger> value={httpConfig.url}
<PopoverContent className='w-[260px] p-3 rounded-xl border border-white/10 shadow-2xl bg-white/80 dark:bg-black/80 backdrop-blur-xl'> onChange={(e) =>
<div className='flex flex-col gap-2'> setHttpConfig({ ...httpConfig, url: e.target.value })}
<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
<Input label='Token' value={httpConfig.token} onChange={(e) => setHttpConfig({ ...httpConfig, token: e.target.value })} size='sm' variant='flat' /> label='Token'
</div> placeholder='输入 Token'
</PopoverContent> value={httpConfig.token}
</Popover> onChange={(e) =>
setHttpConfig({ ...httpConfig, token: e.target.value })}
<Button />
onPress={sendRequest} <Button
color='primary' onPress={sendRequest}
radius='full' color='primary'
size='sm' size='lg'
className='h-10 px-6 font-bold shadow-md shadow-primary/20 hover:scale-[1.02] active:scale-[0.98]' radius='full'
isLoading={isFetching} isIconOnly
startContent={!isFetching && <IoSend className="text-xs" />} isDisabled={isFetching}
> >
<IoSend />
</Button> </Button>
</div>
</div> </div>
<Card
<div className='flex-1 flex flex-col min-h-0 bg-transparent'> shadow='sm'
<div className='px-4 flex flex-wrap items-center justify-between flex-shrink-0 min-h-[36px] gap-2 py-1'> className='my-4 bg-opacity-50 backdrop-blur-md overflow-visible'
<Tabs >
size="sm" <CardHeader className='font-bold text-lg gap-1 pb-0'>
variant="underlined" <span className='mr-2'></span>
selectedKey={activeTab} <Button
onSelectionChange={setActiveTab} color='warning'
classNames={{ variant='flat'
cursor: 'bg-primary h-0.5', onPress={() => setIsCodeEditorOpen(!isCodeEditorOpen)}
tab: 'px-0 mr-5 h-8', size='sm'
tabList: 'p-0 border-none', radius='full'
tabContent: 'text-[11px] font-bold opacity-30 group-data-[selected=true]:opacity-80 transition-opacity' >
{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="请求参数" /> <CodeEditor
<Tab key="docs" title="接口定义" /> value={requestBody}
</Tabs> onChange={(value) => setRequestBody(value ?? '')}
<div className='flex items-center gap-1 ml-auto'> language='json'
<ChatInputModal> height='400px'
{(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>
<Tooltip content="生成示例参数" closeDelay={0}> <div className='flex justify-end gap-1'>
<ChatInputModal />
<Button <Button
isIconOnly color='primary'
size='sm' variant='flat'
variant='light' onPress={() =>
radius='full' setRequestBody(generateDefaultJson(data.request))}
className='h-7 w-7 text-default-400 hover:text-primary hover:bg-default-100/50'
onPress={() => setRequestBody(generateDefaultJson(data.request))}
> >
<TbCode size={16} />
</Button> </Button>
</Tooltip> </div>
</div> </motion.div>
</div> </CardBody>
</Card>
<div className='flex-1 min-h-0 relative px-3 pb-2 mt-1'> <Card
<div className={clsx( shadow='sm'
'h-full transition-all', className='my-4 relative bg-opacity-50 backdrop-blur-md'
activeTab !== 'request' && 'rounded-xl overflow-y-auto no-scrollbar', >
hasBackground ? 'bg-transparent' : (activeTab !== 'request' && 'bg-white/10 dark:bg-black/10') <PageLoading loading={isFetching} />
)}> <CardHeader className='font-bold text-lg gap-1 pb-0'>
{activeTab === 'request' ? ( <span className='mr-2'></span>
<CodeEditor <Button
value={requestBody} color='warning'
onChange={(value) => setRequestBody(value ?? '')} variant='flat'
language='json' onPress={() => setIsResponseOpen(!isResponseOpen)}
options={{ size='sm'
minimap: { enabled: false }, radius='full'
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)}
> >
{/* Invisble Resize Area that becomes visible/active */} {isResponseOpen ? '收起' : '展开'}
{responseExpanded && ( </Button>
<div <Button
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" color='success'
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e); }} variant='flat'
onTouchStart={(e) => { e.stopPropagation(); handleTouchStart(e); }} onPress={() => {
onClick={(e) => e.stopPropagation()} navigator.clipboard.writeText(responseContent);
> toast.success('响应内容已复制到剪贴板');
<div className="w-12 h-1 bg-white/20 rounded-full" /> }}
</div> size='sm'
)} radius='full'
>
<div className='flex items-center gap-2'>
<IoChevronDown className={clsx('text-[10px] transition-transform duration-300 opacity-20', !responseExpanded && '-rotate-90')} /> </Button>
<span className='text-[10px] font-semibold tracking-wide opacity-30 uppercase'>Response</span> </CardHeader>
</div> <CardBody>
<div className='flex items-center gap-2'> <motion.div
{responseStatus && ( className='overflow-y-auto text-sm'
<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"> initial={{ opacity: 0, height: 0 }}
{responseStatus.code} animate={{
</Chip> opacity: isResponseOpen ? 1 : 0,
)} height: isResponseOpen ? 300 : 0,
<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> <pre>
</div> <code>
</div> {responseContent || (
<div className='text-gray-400'></div>
{/* Response Content - Code Editor */} )}
{responseExpanded && ( </code>
<div style={{ height: responseHeight }} className="relative bg-transparent"> </pre>
<PageLoading loading={isFetching} /> </motion.div>
<CodeEditor </CardBody>
value={responseContent || '// Waiting for response...'} </Card>
language='json' <div className='p-2 md:p-4 border border-default-50 dark:border-default-200 rounded-lg backdrop-blur-sm'>
options={{ <h2 className='text-xl font-semibold mb-2'></h2>
minimap: { enabled: false }, <DisplayStruct schema={parsedRequest} />
fontSize: 11, <h2 className='text-xl font-semibold mt-4 mb-2'></h2>
lineNumbers: 'off', <DisplayStruct schema={parsedResponse} />
scrollBeyondLastLine: false,
wordWrap: 'on',
readOnly: true,
folding: true,
padding: { top: 8, bottom: 8 },
renderLineHighlight: 'none',
automaticLayout: true
}}
/>
</div>
)}
</div>
</div> </div>
</section> </section>
); );

View File

@ -8,15 +8,15 @@ import { TbSquareRoundedChevronRightFilled } from 'react-icons/tb';
import type { LiteralValue, ParsedSchema } from '@/utils/zod'; import type { LiteralValue, ParsedSchema } from '@/utils/zod';
interface DisplayStructProps { interface DisplayStructProps {
schema: ParsedSchema | ParsedSchema[]; schema: ParsedSchema | ParsedSchema[]
} }
const SchemaType = ({ const SchemaType = ({
type, type,
value, value,
}: { }: {
type: string; type: string
value?: LiteralValue; value?: LiteralValue
}) => { }) => {
let name = type; let name = type;
switch (type) { switch (type) {
@ -57,7 +57,7 @@ const SchemaType = ({
}; };
const SchemaLabel: React.FC<{ const SchemaLabel: React.FC<{
schema: ParsedSchema; schema: ParsedSchema
}> = ({ schema }) => ( }> = ({ schema }) => (
<> <>
{Array.isArray(schema.type) {Array.isArray(schema.type)
@ -81,8 +81,8 @@ const SchemaLabel: React.FC<{
); );
const SchemaContainer: React.FC<{ const SchemaContainer: React.FC<{
schema: ParsedSchema; schema: ParsedSchema
children: React.ReactNode; children: React.ReactNode
}> = ({ schema, children }) => { }> = ({ schema, children }) => {
const [expanded, setExpanded] = useState(false); 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') { if (schema.type === 'object') {
return ( return (
<SchemaContainer schema={schema}> <SchemaContainer schema={schema}>
@ -193,7 +193,7 @@ const RenderSchema: React.FC<{ schema: ParsedSchema; }> = ({ schema }) => {
const DisplayStruct: React.FC<DisplayStructProps> = ({ schema }) => { const DisplayStruct: React.FC<DisplayStructProps> = ({ schema }) => {
return ( return (
<div className=''> <div className='p-4 bg-content2 rounded-lg bg-opacity-50'>
{Array.isArray(schema) {Array.isArray(schema)
? ( ? (
schema.map((s, i) => <RenderSchema key={s.name || i} schema={s} />) 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 { Input } from '@heroui/input';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { AnimatePresence, motion } from 'motion/react'; import { motion } from 'motion/react';
import { useMemo, useState } from 'react'; import { useState } from 'react';
import { TbChevronRight, TbFolder, TbSearch } from 'react-icons/tb';
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'; import type { OneBotHttpApi, OneBotHttpApiPath } from '@/const/ob_api';
export interface OneBotApiNavListProps { export interface OneBotApiNavListProps {
data: OneBotHttpApi; data: OneBotHttpApi
selectedApi: OneBotHttpApiPath; selectedApi: OneBotHttpApiPath
onSelect: (apiName: OneBotHttpApiPath) => void; onSelect: (apiName: OneBotHttpApiPath) => void
openSideBar: boolean; openSideBar: boolean
onToggle?: (isOpen: boolean) => void;
} }
const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => { const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
const { data, selectedApi, onSelect, openSideBar, onToggle } = props; const { data, selectedApi, onSelect, openSideBar } = props;
const [searchValue, setSearchValue] = useState(''); 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 ( return (
<> <motion.div
{/* Mobile backdrop overlay - below header (z-40) */} className={clsx(
<AnimatePresence> '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 && ( openSideBar && 'bg-background bg-opacity-20 backdrop-blur-md'
<motion.div )}
initial={{ opacity: 0 }} initial={{ width: 0 }}
animate={{ opacity: 1 }} transition={{
exit={{ opacity: 0 }} type: openSideBar ? 'spring' : 'tween',
className="absolute inset-0 bg-black/50 backdrop-blur-[2px] z-30 md:hidden" stiffness: 150,
onClick={() => onToggle?.(false)} damping: 15,
/> }}
)} animate={{ width: openSideBar ? '16rem' : '0rem' }}
</AnimatePresence> style={{ overflowY: openSideBar ? 'auto' : 'hidden' }}
>
<motion.div <div className='w-64 h-full overflow-y-auto px-2 pt-2 pb-10 md:pb-0'>
className={clsx( <Input
'h-full z-40 flex-shrink-0 border-r border-white/10 dark:border-white/5 overflow-hidden transition-all', className='sticky top-0 z-10 text-primary-600'
// Mobile: absolute position, drawer style classNames={{
// Desktop: relative position, pushing content inputWrapper:
'absolute md:relative left-0 top-0', 'bg-opacity-30 bg-primary-50 backdrop-blur-sm border border-primary-300 mb-2',
hasBackground input: 'bg-transparent !text-primary-400 !placeholder-primary-400',
? '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' radius='full'
)} placeholder='搜索 API'
initial={false} value={searchValue}
animate={{ onChange={(e) => setSearchValue(e.target.value)}
width: openSideBar ? 260 : 0, isClearable
opacity: openSideBar ? 1 : 0, onClear={() => setSearchValue('')}
x: (window.innerWidth < 768 && !openSideBar) ? -260 : 0 // Optional: slide out completely on mobile />
}} {Object.entries(data).map(([apiName, api]) => (
transition={{ type: 'spring', stiffness: 300, damping: 30 }} <Card
> key={apiName}
<div className='w-[260px] h-full flex flex-col'> shadow='none'
<div className='p-3'> className={clsx(
<Input 'w-full border border-primary-100 rounded-lg mb-1 bg-opacity-30 backdrop-blur-sm text-primary-400',
classNames={{ {
inputWrapper: hidden: !(
'bg-white/5 dark:bg-white/5 border border-white/10 hover:bg-white/10 transition-all shadow-none', apiName.includes(searchValue) ||
input: 'bg-transparent text-xs placeholder:opacity-30', api.description?.includes(searchValue)
}} ),
isClearable },
radius='lg' {
placeholder='搜索接口...' '!bg-opacity-40 border border-primary-400 bg-primary-50 text-primary-600':
startContent={<TbSearch size={14} className="opacity-30" />} apiName === selectedApi,
value={searchValue} }
onChange={(e) => setSearchValue(e.target.value)} )}
onClear={() => setSearchValue('')} isPressable
size="sm" onPress={() => onSelect(apiName as OneBotHttpApiPath)}
/> >
</div> <CardBody>
<h2 className='font-bold'>{api.description}</h2>
<div className='flex-1 px-2 pb-4 flex flex-col gap-1 overflow-y-auto no-scrollbar'> <div
{groups.map((group) => { className={clsx('text-sm text-primary-200', {
const isOpen = expandedGroups.includes(group.id) || searchValue.length > 0; '!text-primary-400': apiName === selectedApi,
return ( })}
<div key={group.id} className="flex flex-col"> >
{/* Group Header */} {apiName}
<div </div>
className="flex items-center gap-2 px-2 py-2 rounded-lg cursor-pointer hover:bg-white/5 transition-all group/header" </CardBody>
onClick={() => toggleGroup(group.id)} </Card>
> ))}
<TbChevronRight </div>
size={12} </motion.div>
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>
</>
); );
}; };

View File

@ -30,14 +30,14 @@ const itemVariants = {
}, },
}; };
function RequestComponent ({ data: _ }: { data: OB11Request; }) { function RequestComponent ({ data: _ }: { data: OB11Request }) {
return <div>Request消息</div>; return <div>Request消息</div>;
} }
export interface OneBotItemRenderProps { export interface OneBotItemRenderProps {
data: AllOB11WsResponse[]; data: AllOB11WsResponse[]
index: number; index: number
style: React.CSSProperties; style: React.CSSProperties
} }
export const getItemSize = (event: OB11AllEvent['post_type']) => { export const getItemSize = (event: OB11AllEvent['post_type']) => {
@ -90,7 +90,7 @@ const OneBotItemRender = ({ data, index, style }: OneBotItemRenderProps) => {
animate='visible' animate='visible'
className='h-full px-2' 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'> <CardHeader className='py-0 text-default-500 flex-row gap-2'>
<div className='font-bold'> <div className='font-bold'>
{isEvent ? getEventName(msg.post_type) : '请求响应'} {isEvent ? getEventName(msg.post_type) : '请求响应'}

View File

@ -3,8 +3,8 @@ import { SharedSelection } from '@heroui/system';
import type { Selection } from '@react-types/shared'; import type { Selection } from '@react-types/shared';
export interface FilterMessageTypeProps { export interface FilterMessageTypeProps {
filterTypes: Selection; filterTypes: Selection
onSelectionChange: (keys: SharedSelection) => void; onSelectionChange: (keys: SharedSelection) => void
} }
const items = [ const items = [
{ label: '元事件', value: 'meta_event' }, { label: '元事件', value: 'meta_event' },
@ -26,7 +26,6 @@ const FilterMessageType: React.FC<FilterMessageTypeProps> = (props) => {
}} }}
label='筛选消息类型' label='筛选消息类型'
selectionMode='multiple' selectionMode='multiple'
className='w-full'
items={items} items={items}
renderValue={(value) => { renderValue={(value) => {
if (value.length === items.length) { if (value.length === items.length) {

View File

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

View File

@ -1,18 +1,23 @@
import { Image } from '@heroui/image';
import bkg_color from '@/assets/images/bkg-color.png';
const PageBackground = () => { const PageBackground = () => {
return ( 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='fixed w-full h-full -z-[0] flex justify-end opacity-80'>
<div <Image
className='absolute top-[-10%] left-[-10%] w-[500px] h-[500px] rounded-full bg-primary-200/40 blur-[100px]' 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 />
className='absolute top-[20%] right-[-10%] w-[400px] h-[400px] rounded-full bg-secondary-200/40 blur-[90px]' </div>
/> <div className='fixed w-full h-full overflow-hidden -z-[0] hue-rotate-90 flex justify-start opacity-80'>
<div <Image
className='absolute bottom-[-10%] left-[20%] w-[600px] h-[600px] rounded-full bg-pink-200/30 blur-[110px]' className='relative -top-92 h-[180%] object-contain pointer-events-none rotate-90 select-none -z-10 top-44'
/> src={bkg_color}
</div> />
</div>
</>
); );
}; };

View File

@ -2,13 +2,13 @@ import { Spinner } from '@heroui/spinner';
import clsx from 'clsx'; import clsx from 'clsx';
export interface PageLoadingProps { export interface PageLoadingProps {
loading?: boolean; loading?: boolean
} }
const PageLoading: React.FC<PageLoadingProps> = ({ loading }) => { const PageLoading: React.FC<PageLoadingProps> = ({ loading }) => {
return ( return (
<div <div
className={clsx( 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, hidden: !loading,
} }

View File

@ -1,29 +1,22 @@
import { Card, CardBody } from '@heroui/card'; import { Card, CardBody } from '@heroui/card';
import { Image } from '@heroui/image'; import { Image } from '@heroui/image';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { BsTencentQq } from 'react-icons/bs'; import { BsTencentQq } from 'react-icons/bs';
import key from '@/const/key';
import { SelfInfo } from '@/types/user'; import { SelfInfo } from '@/types/user';
import PageLoading from './page_loading'; import PageLoading from './page_loading';
export interface QQInfoCardProps { export interface QQInfoCardProps {
data?: SelfInfo; data?: SelfInfo
error?: Error; error?: Error
loading?: boolean; loading?: boolean
} }
const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => { const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card <Card
className={clsx( className='relative bg-primary-100 bg-opacity-60 overflow-hidden flex-shrink-0 shadow-md shadow-primary-300 dark:shadow-primary-50'
'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'
)}
shadow='none' shadow='none'
radius='lg' radius='lg'
> >
@ -38,40 +31,28 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
</CardBody> </CardBody>
) )
: ( : (
<CardBody className='flex-row items-center gap-4 overflow-hidden relative p-4'> <CardBody className='flex-row items-center gap-2 overflow-hidden relative'>
{!hasBackground && ( <div className='absolute right-0 bottom-0 text-5xl text-primary-400'>
<div className='absolute right-[-10px] bottom-[-10px] text-7xl text-default-400/10 rotate-12 pointer-events-none dark:hidden'> <BsTencentQq />
<BsTencentQq /> </div>
</div>
)}
<div className='relative flex-shrink-0 z-10'> <div className='relative flex-shrink-0 z-10'>
<Image <Image
src={ src={
data?.avatarUrl ?? data?.avatarUrl ??
`https://q1.qlogo.cn/g?b=qq&nk=${data?.uin}&s=1` `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' className='shadow-md rounded-full w-12 aspect-square'
/> />
<div <div
className={clsx( 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', 'w-4 h-4 rounded-full absolute right-0.5 bottom-0 border-2 border-primary-100 z-10',
data?.online ? 'bg-success-500' : 'bg-default-400' data?.online ? 'bg-green-500' : 'bg-gray-500'
)} )}
/> />
</div> </div>
<div className='flex-col justify-center z-10'> <div className='flex-col justify-center'>
<div className={clsx( <div className='text-lg truncate'>{data?.nick}</div>
'text-xl font-bold truncate mb-0.5', <div className='text-primary-500 text-sm'>{data?.uin}</div>
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> </div>
</CardBody> </CardBody>
)} )}

View File

@ -1,23 +1,24 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { useLocalStorage } from '@uidotdev/usehooks'; import { Image } from '@heroui/image';
import clsx from 'clsx'; import clsx from 'clsx';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import React from 'react'; import React from 'react';
import { IoMdLogOut } from 'react-icons/io'; import { IoMdLogOut } from 'react-icons/io';
import { MdDarkMode, MdLightMode } from 'react-icons/md'; import { MdDarkMode, MdLightMode } from 'react-icons/md';
import key from '@/const/key';
import useAuth from '@/hooks/auth'; import useAuth from '@/hooks/auth';
import useDialog from '@/hooks/use-dialog'; import useDialog from '@/hooks/use-dialog';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/hooks/use-theme';
import logo from '@/assets/images/logo.png';
import type { MenuItem } from '@/config/site'; import type { MenuItem } from '@/config/site';
import Menus from './menus'; import Menus from './menus';
interface SideBarProps { interface SideBarProps {
open: boolean; open: boolean
items: MenuItem[]; items: MenuItem[]
onClose?: () => void; onClose?: () => void
} }
const SideBar: React.FC<SideBarProps> = (props) => { const SideBar: React.FC<SideBarProps> = (props) => {
@ -25,9 +26,6 @@ const SideBar: React.FC<SideBarProps> = (props) => {
const { toggleTheme, isDark } = useTheme(); const { toggleTheme, isDark } = useTheme();
const { revokeAuth } = useAuth(); const { revokeAuth } = useAuth();
const dialog = useDialog(); const dialog = useDialog();
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const onRevokeAuth = () => { const onRevokeAuth = () => {
dialog.confirm({ dialog.confirm({
title: '退出登录', title: '退出登录',
@ -52,11 +50,7 @@ const SideBar: React.FC<SideBarProps> = (props) => {
</AnimatePresence> </AnimatePresence>
<motion.div <motion.div
className={clsx( className={clsx(
'overflow-hidden fixed top-0 left-0 h-full z-50 md:static md:shadow-none rounded-r-2xl md:rounded-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'
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'
)} )}
initial={{ width: 0 }} initial={{ width: 0 }}
animate={{ width: open ? '16rem' : 0 }} animate={{ width: open ? '16rem' : 0 }}
@ -67,33 +61,40 @@ const SideBar: React.FC<SideBarProps> = (props) => {
}} }}
style={{ overflow: 'hidden' }} 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'> <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 items-center justify-start gap-3 px-2 my-8 ml-2'> <div className='flex justify-center items-center my-2 gap-2'>
<div className="h-5 w-1 bg-primary rounded-full shadow-sm" /> <Image radius='none' height={40} src={logo} className='mb-2' />
<div className="text-xl font-bold text-default-900 dark:text-white tracking-wide select-none"> <div
className={clsx(
'flex items-center font-bold',
'!text-2xl shiny-text'
)}
>
NapCat NapCat
</div> </div>
</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} /> <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 <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' radius='full'
variant='flat' variant='light'
onPress={toggleTheme} onPress={toggleTheme}
startContent={ startContent={
!isDark ? <MdLightMode size={18} /> : <MdDarkMode size={18} /> !isDark ? <MdLightMode size={16} /> : <MdDarkMode size={16} />
} }
> >
</Button> </Button>
<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' radius='full'
variant='flat' variant='light'
onPress={onRevokeAuth} onPress={onRevokeAuth}
startContent={<IoMdLogOut size={18} />} startContent={<IoMdLogOut size={16} />}
> >
退 退
</Button> </Button>

View File

@ -50,13 +50,12 @@ const renderItems = (items: MenuItem[], children = false) => {
<div key={item.href + item.label}> <div key={item.href + item.label}>
<Button <Button
className={clsx( className={clsx(
'flex items-center w-full text-left justify-start dark:text-white transition-all duration-300', 'flex items-center w-full text-left justify-start dark:text-white',
isActive // children && 'rounded-l-lg',
? 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-400 shadow-none font-semibold translate-x-1' isActive && 'bg-opacity-60',
: 'hover:bg-default-100 hover:translate-x-1',
b64img && 'backdrop-blur-md text-white' b64img && 'backdrop-blur-md text-white'
)} )}
color={isActive ? 'primary' : 'default'} color='primary'
endContent={ endContent={
canOpen canOpen
? ( ? (
@ -97,15 +96,15 @@ const renderItems = (items: MenuItem[], children = false) => {
: ( : (
<div <div
className={clsx( className={clsx(
'w-3 h-1.5 rounded-full ml-auto', 'w-3 h-1.5 rounded-full ml-auto shadow-lg',
isActive isActive
? 'bg-primary-500 animate-nav-spin' ? 'bg-primary-500 animate-spinner-ease-spin'
: 'bg-primary-200 dark:bg-white shadow-lg' : 'bg-primary-200 dark:bg-white'
)} )}
aria-hidden="true"
/> />
) )
} }
radius='full'
startContent={ startContent={
customIcons[item.label] customIcons[item.label]
? ( ? (
@ -148,7 +147,7 @@ const renderItems = (items: MenuItem[], children = false) => {
}; };
interface MenusProps { interface MenusProps {
items: MenuItem[]; items: MenuItem[]
} }
const Menus: React.FC<MenusProps> = (props) => { const Menus: React.FC<MenusProps> = (props) => {
const { items } = props; const { items } = props;

View File

@ -3,14 +3,14 @@ import clsx from 'clsx';
import React, { forwardRef } from 'react'; import React, { forwardRef } from 'react';
export interface SwitchCardProps { export interface SwitchCardProps {
label?: string; label?: string
description?: string; description?: string
value?: boolean; value?: boolean
onValueChange?: (value: boolean) => void; onValueChange?: (value: boolean) => void
name?: string; name?: string
onBlur?: React.FocusEventHandler; onBlur?: React.FocusEventHandler
disabled?: boolean; disabled?: boolean
onChange?: React.ChangeEventHandler<HTMLInputElement>; onChange?: React.ChangeEventHandler<HTMLInputElement>
} }
const SwitchCard = forwardRef<HTMLInputElement, SwitchCardProps>( const SwitchCard = forwardRef<HTMLInputElement, SwitchCardProps>(
@ -22,9 +22,9 @@ const SwitchCard = forwardRef<HTMLInputElement, SwitchCardProps>(
<Switch <Switch
classNames={{ classNames={{
base: clsx( 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', 'inline-flex flex-row-reverse w-full max-w-md bg-content1 hover:bg-content2 items-center',
'justify-between cursor-pointer rounded-xl gap-2 p-4 border border-transparent transition-all duration-200', 'justify-between cursor-pointer rounded-lg gap-2 p-3 border-2 border-transparent',
'data-[selected=true]:border-primary/50 data-[selected=true]:bg-primary/5 backdrop-blur-md' 'data-[selected=true]:border-primary bg-opacity-50 backdrop-blur-sm'
), ),
}} }}
{...props} {...props}

View File

@ -1,17 +1,17 @@
import { Card, CardBody, CardHeader } from '@heroui/card'; import { Card, CardBody, CardHeader } from '@heroui/card';
import { Button } from '@heroui/button';
import { Chip } from '@heroui/chip'; import { Chip } from '@heroui/chip';
import { Spinner } from '@heroui/spinner'; import { Spinner } from '@heroui/spinner';
import { Tooltip } from '@heroui/tooltip'; import { Tooltip } from '@heroui/tooltip';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import clsx from 'clsx'; import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6';
import { FaCircleInfo, FaQq } from 'react-icons/fa6';
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io'; import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io';
import { RiMacFill } from 'react-icons/ri'; import { RiMacFill } from 'react-icons/ri';
import { useState } from 'react'; import { useState } from 'react';
import toast from 'react-hot-toast';
import key from '@/const/key';
import WebUIManager from '@/controllers/webui_manager'; import WebUIManager from '@/controllers/webui_manager';
import useDialog from '@/hooks/use-dialog'; import useDialog from '@/hooks/use-dialog';
@ -21,7 +21,6 @@ export interface SystemInfoItemProps {
icon?: React.ReactNode; icon?: React.ReactNode;
value?: React.ReactNode; value?: React.ReactNode;
endContent?: React.ReactNode; endContent?: React.ReactNode;
hasBackground?: boolean;
} }
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
@ -29,22 +28,13 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
value = '--', value = '--',
icon, icon,
endContent, endContent,
hasBackground = false,
}) => { }) => {
return ( return (
<div className={clsx( <div className='flex text-sm gap-1 p-2 items-center shadow-sm shadow-primary-100 dark:shadow-primary-100 rounded text-primary-400'>
'flex text-sm gap-3 py-2 items-center transition-colors', {icon}
hasBackground <div className='w-24'>{title}</div>
? 'text-white/90' <div className='text-primary-200'>{value}</div>
: 'text-default-600 dark:text-gray-300' <div className='ml-auto'>{endContent}</div>
)}>
<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> </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 NewVersionTip = (props: NewVersionTipProps) => {
const { currentVersion } = props; const { currentVersion } = props;
const dialog = useDialog(); const dialog = useDialog();
const { data: latestVersion, error } = useRequest(WebUIManager.getLatestTag, { const { data: latestVersion, error } = useRequest(WebUIManager.getLatestTag);
cacheKey: 'napcat-latest-tag', const [updating, setUpdating] = useState(false);
staleTime: 10 * 60 * 1000,
cacheTime: 30 * 60 * 1000,
});
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
if (error || !latestVersion || !currentVersion || latestVersion === currentVersion) { if (error || !latestVersion || !currentVersion || latestVersion === currentVersion) {
return null; 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 ( return (
<Tooltip content='有新版本可用'> <Tooltip content='有新版本可用'>
<div className="cursor-pointer" onClick={updateStatus === 'updating' ? undefined : showUpdateDialog}> <Button
<Chip isIconOnly
size="sm" radius='full'
color="danger" color='primary'
variant="flat" variant='shadow'
classNames={{ className='!w-5 !h-5 !min-w-0 text-small shadow-md'
content: "font-bold text-[10px] px-1", onPress={() => {
base: "h-5 min-h-5" dialog.confirm({
}} title: '有新版本可用',
> content: (
{updateStatus === 'updating' ? <Spinner size="sm" color="danger" /> : 'New'} <div className='space-y-2'>
</Chip> <div className='text-sm space-x-2'>
</div> <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> </Tooltip>
); );
}; };
interface NapCatVersionProps { const NapCatVersion = () => {
hasBackground?: boolean;
}
const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false }) => {
const { const {
data: packageData, data: packageData,
loading: packageLoading, loading: packageLoading,
error: packageError, error: packageError,
} = useRequest(WebUIManager.GetNapCatVersion, { } = useRequest(WebUIManager.GetNapCatVersion);
cacheKey: 'napcat-version',
staleTime: 60 * 60 * 1000,
cacheTime: 24 * 60 * 60 * 1000,
});
const currentVersion = packageData?.version; const currentVersion = packageData?.version;
@ -401,7 +274,6 @@ const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false })
<SystemInfoItem <SystemInfoItem
title='NapCat 版本' title='NapCat 版本'
icon={<IoLogoOctocat className='text-xl' />} icon={<IoLogoOctocat className='text-xl' />}
hasBackground={hasBackground}
value={ value={
packageError packageError
? ( ? (
@ -429,33 +301,19 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
data: qqVersionData, data: qqVersionData,
loading: qqVersionLoading, loading: qqVersionLoading,
error: qqVersionError, error: qqVersionError,
} = useRequest(WebUIManager.getQQVersion, { } = useRequest(WebUIManager.getQQVersion);
cacheKey: 'qq-version',
staleTime: 60 * 60 * 1000,
cacheTime: 24 * 60 * 60 * 1000,
});
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card className={clsx( <Card className='bg-opacity-60 shadow-sm shadow-primary-100 dark:shadow-primary-100 overflow-visible flex-1'>
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm overflow-visible flex-1', <CardHeader className='pb-0 items-center gap-1 text-primary-500 font-extrabold'>
hasBackground ? 'bg-white/10 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40' <FaCircleInfo className='text-lg' />
)}>
<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' />
<span></span> <span></span>
</CardHeader> </CardHeader>
<CardBody className='flex-1'> <CardBody className='flex-1'>
<div className='flex flex-col gap-2 justify-between h-full'> <div className='flex flex-col justify-between h-full'>
<NapCatVersion hasBackground={hasBackground} /> <NapCatVersion />
<SystemInfoItem <SystemInfoItem
title='QQ 版本' title='QQ 版本'
icon={<FaQq className='text-lg' />} icon={<FaQq className='text-lg' />}
hasBackground={hasBackground}
value={ value={
qqVersionError qqVersionError
? ( ? (
@ -474,13 +332,11 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
title='WebUI 版本' title='WebUI 版本'
icon={<IoLogoChrome className='text-xl' />} icon={<IoLogoChrome className='text-xl' />}
value='Next' value='Next'
hasBackground={hasBackground}
/> />
<SystemInfoItem <SystemInfoItem
title='系统版本' title='系统版本'
icon={<RiMacFill className='text-xl' />} icon={<RiMacFill className='text-xl' />}
value={archInfo} value={archInfo}
hasBackground={hasBackground}
/> />
</div> </div>
</CardBody> </CardBody>

View File

@ -1,21 +1,18 @@
import { Card, CardBody } from '@heroui/card'; import { Card, CardBody } from '@heroui/card';
import { Image } from '@heroui/image'; import { Image } from '@heroui/image';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { BiSolidMemoryCard } from 'react-icons/bi'; import { BiSolidMemoryCard } from 'react-icons/bi';
import { GiCpu } from 'react-icons/gi'; import { GiCpu } from 'react-icons/gi';
import bkg from '@/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png'; import bkg from '@/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png';
import key from '@/const/key';
import UsagePie from './usage_pie'; import UsagePie from './usage_pie';
export interface SystemStatusItemProps { export interface SystemStatusItemProps {
title: string; title: string
value?: string | number; value?: string | number
size?: 'md' | 'lg'; size?: 'md' | 'lg'
unit?: string; unit?: string
hasBackground?: boolean;
} }
const SystemStatusItem: React.FC<SystemStatusItemProps> = ({ const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
@ -23,32 +20,25 @@ const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
value = '-', value = '-',
size = 'md', size = 'md',
unit, unit,
hasBackground = false,
}) => { }) => {
return ( return (
<div <div
className={clsx( className={clsx(
'py-1.5 text-sm transition-colors', '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', size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between'
)} )}
> >
<div className={clsx( <div className='w-24'>{title}</div>
'w-24 font-medium', <div className='text-default-400'>
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'
)}>
{value} {value}
{unit && <span className="ml-0.5 opacity-70">{unit}</span>} {unit}
</div> </div>
</div> </div>
); );
}; };
export interface SystemStatusDisplayProps { export interface SystemStatusDisplayProps {
data?: SystemStatus; data?: SystemStatus
} }
const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => { const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
@ -63,14 +53,9 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
memoryUsage.system = (systemUsage / system) * 100; memoryUsage.system = (systemUsage / system) * 100;
memoryUsage.qq = (qqUsage / system) * 100; memoryUsage.qq = (qqUsage / system) * 100;
} }
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card className={clsx( <Card className='bg-opacity-60 shadow-sm shadow-primary-100 col-span-1 lg:col-span-2 relative overflow-hidden'>
'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'
)}>
<div className='absolute h-full right-0 top-0'> <div className='absolute h-full right-0 top-0'>
<Image <Image
src={bkg} src={bkg}
@ -84,35 +69,27 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
</div> </div>
<CardBody className='overflow-visible md:flex-row gap-4 items-center justify-stretch z-10'> <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'> <div className='flex-1 w-full md:max-w-96'>
<h2 className={clsx( <h2 className='text-lg font-semibold flex items-center gap-1 text-primary-400'>
'text-lg font-semibold flex items-center gap-2 mb-2', <GiCpu className='text-xl' />
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
)}>
<GiCpu className='text-xl opacity-80' />
<span>CPU</span> <span>CPU</span>
</h2> </h2>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
<SystemStatusItem title='型号' value={data?.cpu.model} size='lg' hasBackground={hasBackground} /> <SystemStatusItem title='型号' value={data?.cpu.model} size='lg' />
<SystemStatusItem title='内核数' value={data?.cpu.core} hasBackground={hasBackground} /> <SystemStatusItem title='内核数' value={data?.cpu.core} />
<SystemStatusItem title='主频' value={data?.cpu.speed} unit='GHz' hasBackground={hasBackground} /> <SystemStatusItem title='主频' value={data?.cpu.speed} unit='GHz' />
<SystemStatusItem <SystemStatusItem
title='使用率' title='使用率'
value={data?.cpu.usage.system} value={data?.cpu.usage.system}
unit='%' unit='%'
hasBackground={hasBackground}
/> />
<SystemStatusItem <SystemStatusItem
title='QQ主线程' title='QQ主线程'
value={data?.cpu.usage.qq} value={data?.cpu.usage.qq}
unit='%' unit='%'
hasBackground={hasBackground}
/> />
</div> </div>
<h2 className={clsx( <h2 className='text-lg font-semibold flex items-center gap-1 text-primary-400 mt-2'>
'text-lg font-semibold flex items-center gap-2 mb-2 mt-4', <BiSolidMemoryCard className='text-xl' />
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
)}>
<BiSolidMemoryCard className='text-xl opacity-80' />
<span></span> <span></span>
</h2> </h2>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
@ -121,19 +98,16 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
value={data?.memory.total} value={data?.memory.total}
size='lg' size='lg'
unit='MB' unit='MB'
hasBackground={hasBackground}
/> />
<SystemStatusItem <SystemStatusItem
title='使用量' title='使用量'
value={data?.memory.usage.system} value={data?.memory.usage.system}
unit='MB' unit='MB'
hasBackground={hasBackground}
/> />
<SystemStatusItem <SystemStatusItem
title='QQ主线程' title='QQ主线程'
value={data?.memory.usage.qq} value={data?.memory.usage.qq}
unit='MB' unit='MB'
hasBackground={hasBackground}
/> />
</div> </div>
</div> </div>
@ -142,13 +116,11 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
systemUsage={Number(data?.cpu.usage.system) || 0} systemUsage={Number(data?.cpu.usage.system) || 0}
processUsage={Number(data?.cpu.usage.qq) || 0} processUsage={Number(data?.cpu.usage.qq) || 0}
title='CPU占用' title='CPU占用'
hasBackground={hasBackground}
/> />
<UsagePie <UsagePie
systemUsage={memoryUsage.system} systemUsage={memoryUsage.system}
processUsage={memoryUsage.qq} processUsage={memoryUsage.qq}
title='内存占用' title='内存占用'
hasBackground={hasBackground}
/> />
</div> </div>
</CardBody> </CardBody>

View File

@ -1,121 +1,143 @@
import React, { useMemo } from 'react'; import * as echarts from 'echarts';
import clsx from 'clsx'; import React, { useEffect, useRef } from 'react';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/hooks/use-theme';
interface UsagePieProps { interface UsagePieProps {
systemUsage: number; systemUsage: number
processUsage: number; processUsage: number
title?: string; title?: string
hasBackground?: boolean;
} }
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> = ({ const UsagePie: React.FC<UsagePieProps> = ({
systemUsage, systemUsage,
processUsage, processUsage,
title, title,
hasBackground,
}) => { }) => {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const { theme } = useTheme(); const { theme } = useTheme();
// Ensure values are clean useEffect(() => {
const cleanSystem = Math.min(Math.max(systemUsage || 0, 0), 100); if (chartRef.current) {
const cleanProcess = Math.min(Math.max(processUsage || 0, 0), cleanSystem); 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 useEffect(() => {
const size = 100; if (chartInstance.current) {
const strokeWidth = 10; chartInstance.current.setOption({
const radius = (size - strokeWidth) / 2; series: [
const circumference = 2 * Math.PI * radius; {
const center = size / 2; label: {
formatter: title,
},
},
],
});
}
}, [title]);
// Colors useEffect(() => {
const colors = { if (chartInstance.current) {
qq: '#D33FF0', chartInstance.current.setOption({
other: theme === 'dark' ? '#EF8664' : '#EA7D9B', darkMode: theme === 'dark',
track: theme === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)', 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 useEffect(() => {
// 1. Total System Usage (QQ + Others) if (chartInstance.current) {
const systemDash = useMemo(() => { chartInstance.current.setOption({
return `${(cleanSystem / 100) * circumference} ${circumference}`; series: [
}, [cleanSystem, circumference]); {
data: [
{
value: processUsage,
name: 'QQ占用',
},
{
value: systemUsage - processUsage,
name: '其他进程占用',
},
{
value: 100 - systemUsage,
name: '剩余系统总量',
},
],
},
],
});
}
}, [systemUsage, processUsage]);
// 2. QQ Usage (Subset of System) return <div ref={chartRef} className='w-36 h-36 flex-shrink-0' />;
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>
);
}; };
export default UsagePie; export default UsagePie;

View File

@ -12,21 +12,21 @@ import { useTheme } from '@/hooks/use-theme';
export type XTermRef = { export type XTermRef = {
write: ( write: (
...args: Parameters<Terminal['write']> ...args: Parameters<Terminal['write']>
) => ReturnType<Terminal['write']>; ) => ReturnType<Terminal['write']>
writeAsync: (data: Parameters<Terminal['write']>[0]) => Promise<void>; writeAsync: (data: Parameters<Terminal['write']>[0]) => Promise<void>
writeln: ( writeln: (
...args: Parameters<Terminal['writeln']> ...args: Parameters<Terminal['writeln']>
) => ReturnType<Terminal['writeln']>; ) => ReturnType<Terminal['writeln']>
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>; writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
clear: () => void; clear: () => void
terminalRef: React.RefObject<Terminal | null>; terminalRef: React.RefObject<Terminal | null>
}; };
export interface XTermProps export interface XTermProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput' | 'onResize'> { extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput' | 'onResize'> {
onInput?: (data: string) => void; onInput?: (data: string) => void
onKey?: (key: string, event: KeyboardEvent) => void; onKey?: (key: string, event: KeyboardEvent) => void
onResize?: (cols: number, rows: number) => void; // 新增属性 onResize?: (cols: number, rows: number) => void // 新增属性
} }
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => { 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 { className, onInput, onKey, onResize, ...rest } = props;
const { theme } = useTheme(); const { theme } = useTheme();
useEffect(() => { 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({ const terminal = new Terminal({
allowTransparency: true, allowTransparency: true,
fontFamily: fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", "JetBrains Mono", monospace', '"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace',
cursorInactiveStyle: 'outline', cursorInactiveStyle: 'outline',
drawBoldTextInBrightColors: false, drawBoldTextInBrightColors: false,
fontSize: fontSize, fontSize: 14,
lineHeight: 1.2, lineHeight: 1.2,
}); });
terminalRef.current = terminal; terminalRef.current = terminal;
@ -70,7 +56,6 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
terminal.loadAddon(fitAddon); terminal.loadAddon(fitAddon);
terminal.open(domRef.current!); terminal.open(domRef.current!);
// 所有端都使用 Canvas 渲染器(包括手机端)
terminal.loadAddon(new CanvasAddon()); terminal.loadAddon(new CanvasAddon());
terminal.onData((data) => { terminal.onData((data) => {
if (onInput) { if (onInput) {

View File

@ -1,72 +1,107 @@
import { import {
LuActivity, BugIcon2,
LuFileText, FileIcon,
LuFolderOpen, InfoIcon,
LuInfo, LogIcon,
LuLayoutDashboard, RouteIcon,
LuSettings, SettingsIcon,
LuSignal, SignalTowerIcon,
LuTerminal, TerminalIcon,
LuZap, } from '@/components/icons';
} from 'react-icons/lu';
export type SiteConfig = typeof siteConfig; export type SiteConfig = typeof siteConfig;
export interface MenuItem { export interface MenuItem {
label: string; label: string
icon?: React.ReactNode; icon?: React.ReactNode
autoOpen?: boolean; autoOpen?: boolean
href?: string; href?: string
items?: MenuItem[]; items?: MenuItem[]
customIcon?: string; customIcon?: string
} }
export const siteConfig = { export const siteConfig = {
name: 'NapCat', name: 'NapCat WebUI',
description: 'NapCat WebUI.', description: 'NapCat WebUI.',
navItems: [ navItems: [
{ {
label: '基础信息', label: '基础信息',
icon: <LuLayoutDashboard className='w-5 h-5' />, icon: (
<div className='w-5 h-5'>
<RouteIcon />
</div>
),
href: '/', href: '/',
}, },
{ {
label: '网络配置', label: '网络配置',
icon: <LuSignal className='w-5 h-5' />, icon: (
<div className='w-5 h-5'>
<SignalTowerIcon />
</div>
),
href: '/network', href: '/network',
}, },
{ {
label: '其他配置', label: '其他配置',
icon: <LuSettings className='w-5 h-5' />, icon: (
<div className='w-5 h-5'>
<SettingsIcon />
</div>
),
href: '/config', href: '/config',
}, },
{ {
label: '猫猫日志', label: '猫猫日志',
icon: <LuFileText className='w-5 h-5' />, icon: (
<div className='w-5 h-5'>
<LogIcon />
</div>
),
href: '/logs', href: '/logs',
}, },
{ {
label: '接口调试', label: '接口调试',
icon: <LuActivity className='w-5 h-5' />, icon: (
href: '/debug/http', <div className='w-5 h-5'>
}, <BugIcon2 />
{ </div>
label: '实时调试', ),
icon: <LuZap className='w-5 h-5' />, items: [
href: '/debug/ws', {
label: 'HTTP',
href: '/debug/http',
},
{
label: 'Websocket',
href: '/debug/ws',
},
],
}, },
{ {
label: '文件管理', label: '文件管理',
icon: <LuFolderOpen className='w-5 h-5' />, icon: (
<div className='w-5 h-5'>
<FileIcon />
</div>
),
href: '/file_manager', href: '/file_manager',
}, },
{ {
label: '系统终端', label: '系统终端',
icon: <LuTerminal className='w-5 h-5' />, icon: (
<div className='w-5 h-5'>
<TerminalIcon />
</div>
),
href: '/terminal', href: '/terminal',
}, },
{ {
label: '关于我们', label: '关于我们',
icon: <LuInfo className='w-5 h-5' />, icon: (
<div className='w-5 h-5'>
<InfoIcon />
</div>
),
href: '/about', href: '/about',
}, },
] as MenuItem[], ] 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 type { Selection } from '@react-types/shared';
import { useReactive } from 'ahooks';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useWebSocket, { ReadyState } from 'react-use-websocket'; import useWebSocket, { ReadyState } from 'react-use-websocket';
@ -10,8 +11,8 @@ import { isOB11Event, isOB11RequestResponse } from '@/utils/onebot';
import type { AllOB11WsResponse } from '@/types/onebot'; import type { AllOB11WsResponse } from '@/types/onebot';
export { ReadyState } from 'react-use-websocket'; export { ReadyState } from 'react-use-websocket';
export function useWebSocketDebug (url: string, token: string, connectOnMount: boolean = true) { export function useWebSocketDebug (url: string, token: string) {
const [messageHistory, setMessageHistory] = useState<AllOB11WsResponse[]>([]); const messageHistory = useReactive<AllOB11WsResponse[]>([]);
const [filterTypes, setFilterTypes] = useState<Selection>('all'); const [filterTypes, setFilterTypes] = useState<Selection>('all');
const filteredMessages = messageHistory.filter((msg) => { const filteredMessages = messageHistory.filter((msg) => {
@ -21,18 +22,11 @@ export function useWebSocketDebug (url: string, token: string, connectOnMount: b
return false; return false;
}); });
const { sendMessage, readyState } = useWebSocket(connectOnMount ? url : null, { const { sendMessage, readyState } = useWebSocket(url, {
share: false,
onMessage: useCallback((event: WebSocketEventMap['message']) => { onMessage: useCallback((event: WebSocketEventMap['message']) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
setMessageHistory((prev) => { messageHistory.unshift(data);
const newHistory = [data, ...prev];
if (newHistory.length > 500) {
return newHistory.slice(0, 500);
}
return newHistory;
});
} catch (_error) { } catch (_error) {
toast.error('WebSocket 消息解析失败'); toast.error('WebSocket 消息解析失败');
} }
@ -45,7 +39,7 @@ export function useWebSocketDebug (url: string, token: string, connectOnMount: b
console.error('WebSocket error:', event); console.error('WebSocket error:', event);
}, },
onOpen: () => { onOpen: () => {
setMessageHistory([]); messageHistory.splice(0, messageHistory.length);
}, },
}); });
@ -56,10 +50,6 @@ export function useWebSocketDebug (url: string, token: string, connectOnMount: b
sendMessage(msg); sendMessage(msg);
}; };
const clearMessages = useCallback(() => {
setMessageHistory([]);
}, []);
const FilterMessagesType = renderFilterMessageType( const FilterMessagesType = renderFilterMessageType(
filterTypes, filterTypes,
setFilterTypes setFilterTypes
@ -73,6 +63,5 @@ export function useWebSocketDebug (url: string, token: string, connectOnMount: b
filterTypes, filterTypes,
setFilterTypes, setFilterTypes,
FilterMessagesType, FilterMessagesType,
clearMessages,
}; };
} }

View File

@ -79,11 +79,10 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
}, [location.pathname]); }, [location.pathname]);
return ( return (
<div <div
className='h-screen relative flex items-stretch overflow-hidden' className='h-screen relative flex bg-primary-50 dark:bg-black items-stretch'
style={{ style={{
backgroundImage: b64img ? `url(${b64img})` : undefined, backgroundImage: `url(${b64img})`,
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center',
}} }}
> >
<SideBar <SideBar
@ -91,17 +90,14 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
open={openSideBar} open={openSideBar}
onClose={() => setOpenSideBar(false)} onClose={() => setOpenSideBar(false)}
/> />
<motion.div <div
layout
ref={contentRef} ref={contentRef}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
className={clsx( className={clsx(
'flex-1 overflow-y-auto', 'overflow-y-auto flex-1 rounded-md m-1 bg-content1 pb-10 md:pb-0',
'transition-all duration-300 ease-in-out', openSideBar ? 'ml-0' : 'ml-1',
openSideBar ? 'ml-0' : 'ml-0', !b64img && 'shadow-inner',
'pb-10 md:pb-0' b64img && '!bg-opacity-50 backdrop-blur-none dark:bg-background',
'overflow-x-hidden'
)} )}
> >
<div <div
@ -113,12 +109,15 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
'z-30 m-2 mb-0 sticky top-2 left-0' 'z-30 m-2 mb-0 sticky top-2 left-0'
)} )}
> >
<div <motion.div
className={clsx( className={clsx(
'mr-1 ease-in-out ml-0 md:relative z-50 md:z-auto', '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' 'md:!ml-0 md:pl-0'
)} )}
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
initial={{ marginLeft: 0 }}
animate={{ marginLeft: openSideBar ? '15rem' : 0 }}
> >
<Button <Button
isIconOnly isIconOnly
@ -128,7 +127,7 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
> >
{openSideBar ? <MdMenuOpen size={24} /> : <MdMenu size={24} />} {openSideBar ? <MdMenuOpen size={24} /> : <MdMenu size={24} />}
</Button> </Button>
</div> </motion.div>
<Breadcrumbs isDisabled size='lg'> <Breadcrumbs isDisabled size='lg'>
{title?.map((item, index) => ( {title?.map((item, index) => (
<BreadcrumbItem key={index}> <BreadcrumbItem key={index}>
@ -150,7 +149,7 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
<ErrorBoundary fallbackRender={errorFallbackRender}> <ErrorBoundary fallbackRender={errorFallbackRender}>
{children} {children}
</ErrorBoundary> </ErrorBoundary>
</motion.div> </div>
</div> </div>
); );
}; };

View File

@ -8,7 +8,7 @@ import '@/styles/globals.css';
import key from './const/key'; import key from './const/key';
import WebUIManager from './controllers/webui_manager'; import WebUIManager from './controllers/webui_manager';
import { initFont, loadTheme } from './utils/theme'; import { loadTheme } from './utils/theme';
WebUIManager.checkWebUiLogined(); WebUIManager.checkWebUiLogined();
@ -24,7 +24,6 @@ if (theme && !theme.startsWith('"')) {
} }
loadTheme(); loadTheme();
initFont();
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
// <React.StrictMode> // <React.StrictMode>
@ -35,19 +34,3 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</BrowserRouter> </BrowserRouter>
// </React.StrictMode> // </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 { Card, CardBody } from '@heroui/card';
import { Chip } from '@heroui/chip';
import { Divider } from '@heroui/divider';
import { Image } from '@heroui/image'; import { Image } from '@heroui/image';
import { Link } from '@heroui/link'; import { Link } from '@heroui/link';
import { Skeleton } from '@heroui/skeleton';
import { Spinner } from '@heroui/spinner'; import { Spinner } from '@heroui/spinner';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import { import { useMemo } from 'react';
BsCodeSlash, import { BsTelegram, BsTencentQq } from 'react-icons/bs';
BsCpu, import { IoDocument } from 'react-icons/io5';
BsGithub,
BsGlobe, import HoverTiltedCard from '@/components/hover_titled_card';
BsPlugin, import NapCatRepoInfo from '@/components/napcat_repo_info';
BsTelegram, import RotatingText from '@/components/rotating_text';
BsTencentQq
} from 'react-icons/bs'; import { usePreloadImages } from '@/hooks/use-preload-images';
import { IoDocument, IoRocketSharp } from 'react-icons/io5'; import { useTheme } from '@/hooks/use-theme';
import logo from '@/assets/images/logo.png'; import logo from '@/assets/images/logo.png';
import WebUIManager from '@/controllers/webui_manager'; import WebUIManager from '@/controllers/webui_manager';
@ -23,168 +22,184 @@ function VersionInfo () {
const { data, loading, error } = useRequest(WebUIManager.GetNapCatVersion); const { data, loading, error } = useRequest(WebUIManager.GetNapCatVersion);
return ( return (
<div className='flex items-center gap-2'> <div className='flex items-center gap-4'>
{error ? ( <div className='flex items-center gap-2 text-2xl font-bold'>
<Chip color="danger" variant="flat" size="sm">{error.message}</Chip> <div className='text-primary-500 drop-shadow-md'>NapCat</div>
) : loading ? ( {error
<Spinner size='sm' color="default" /> ? (
) : ( error.message
<div className="flex items-center gap-2"> )
<Chip size="sm" color="default" variant="flat" className="text-default-500">WebUI v0.0.6</Chip> : loading
<Chip size="sm" color="primary" variant="flat">Core {data?.version}</Chip> ? (
</div> <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> </div>
); );
} }
export default function AboutPage () { export default function AboutPage () {
const features = [ const { isDark } = useTheme();
{
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 links = [ const imageUrls = useMemo(
{ icon: <BsGithub />, name: 'GitHub', href: 'https://github.com/NapNeko/NapCatQQ' }, () => [
{ icon: <BsTelegram />, name: 'Telegram', href: 'https://t.me/napcatqq' }, 'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
{ icon: <BsTencentQq />, name: 'QQ 群 1', href: 'https://qm.qq.com/q/F9cgs1N3Mc' }, 'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=dark',
{ icon: <BsTencentQq />, name: 'QQ 群 2', href: 'https://qm.qq.com/q/hSt0u9PVn' }, 'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
{ icon: <IoDocument />, name: '文档', href: 'https://napcat.napneko.icu/' }, '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 ( return (
<div className='flex flex-col h-full w-full gap-6 p-2 md:p-6'> <>
<title> - NapCat WebUI</title> <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 gap-2"> <div className='flex flex-col md:flex-row items-center'>
<h1 className="text-2xl font-bold flex items-center gap-3 text-default-900"> <HoverTiltedCard imageSrc={logo} overlayContent='' />
<Image src={logo} alt="NapCat Logo" width={32} height={32} /> </div>
NapCat <div className='flex-1 flex flex-col gap-2 py-2'>
</h1> <VersionInfo />
<div className="flex items-center gap-4 text-small text-default-500"> <div className='space-y-1'>
<p> QQ </p> <p className='font-bold text-primary-400'>NapCat ?</p>
<Divider orientation="vertical" className="h-4" /> <p className='text-default-800'>
<VersionInfo /> TypeScript构建的Bot框架,,QQ
</div> Node模块提供给客户端的接口,Bot的功能.
</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
</p> </p>
<p> <p className='font-bold text-primary-400'></p>
NapCat OneBot 11 <p className='text-default-800'>
QQ
便使 OneBot HTTP /
WebSocket
QQ发送接口之类的接口
</p> </p>
</CardBody> </div>
</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>
<div className='flex flex-row gap-2 flex-wrap justify-around'>
{/* 右侧:信息与链接 */} <Card
<div className="space-y-6"> as={Link}
<Card shadow="sm" className={cardStyle}> shadow='sm'
<CardHeader className="pb-0 pt-4 px-4"> isPressable
<h2 className="text-lg font-bold"></h2> isExternal
</CardHeader> href='https://qm.qq.com/q/F9cgs1N3Mc'
<CardBody className="py-4"> >
<div className="flex flex-col gap-2"> <CardBody className='flex-row items-center gap-2'>
{links.map((link, idx) => ( <span className='p-2 rounded-small bg-primary-50 text-primary-500'>
<Link <BsTencentQq size={16} />
key={idx} </span>
isExternal <span>1</span>
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>
</CardBody> </CardBody>
</Card> </Card>
<Card shadow="sm" className={cardStyle}> <Card
<CardHeader className="pb-0 pt-4 px-4"> as={Link}
<h2 className="text-lg font-bold flex items-center gap-2"> shadow='sm'
<BsCpu /> isPressable
</h2> isExternal
</CardHeader> href='https://qm.qq.com/q/hSt0u9PVn'
<CardBody className="py-4"> >
<div className="flex flex-wrap gap-2"> <CardBody className='flex-row items-center gap-2'>
{['TypeScript', 'React', 'Vite', 'Node.js', 'Electron', 'HeroUI'].map((tech) => ( <span className='p-2 rounded-small bg-primary-50 text-primary-500'>
<Chip key={tech} size="sm" variant="flat" className="bg-default-100/50 text-default-600"> <BsTencentQq size={16} />
{tech} </span>
</Chip> <span>2</span>
))} </CardBody>
</div> </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> </CardBody>
</Card> </Card>
</div> </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 布局 */} <NapCatRepoInfo />
<div className="w-full text-center text-tiny text-default-400 py-4 mt-auto flex flex-col items-center gap-1"> </div>
<p className="flex items-center justify-center gap-1"> </section>
Made with <span className="text-danger"></span> by NapCat Team </>
</p>
<p>MIT License © {new Date().getFullYear()}</p>
</div>
</div>
); );
} }

View File

@ -1,11 +1,9 @@
import { Card, CardBody } from '@heroui/card'; import { Card, CardBody } from '@heroui/card';
import { Tab, Tabs } from '@heroui/tabs'; import { Tab, Tabs } from '@heroui/tabs';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { useMediaQuery } from 'react-responsive';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import key from '@/const/key';
import ChangePasswordCard from './change_password'; import ChangePasswordCard from './change_password';
import LoginConfigCard from './login'; import LoginConfigCard from './login';
import OneBotConfigCard from './onebot'; import OneBotConfigCard from './onebot';
@ -14,29 +12,24 @@ import ThemeConfigCard from './theme';
import WebUIConfigCard from './webui'; import WebUIConfigCard from './webui';
export interface ConfigPageProps { export interface ConfigPageProps {
children?: React.ReactNode; children?: React.ReactNode
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg'
} }
const ConfigPageItem: React.FC<ConfigPageProps> = ({ const ConfingPageItem: React.FC<ConfigPageProps> = ({
children, children,
size = 'md', size = 'md',
}) => { }) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card className={clsx( <Card className='bg-opacity-50 backdrop-blur-sm'>
'w-full mx-auto backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm rounded-2xl transition-all', <CardBody className='items-center py-5'>
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40', <div
{ className={clsx('max-w-full flex flex-col gap-2', {
'max-w-xl': size === 'sm', 'w-72': size === 'sm',
'max-w-3xl': size === 'md', 'w-96': size === 'md',
'max-w-6xl': size === 'lg', 'w-[32rem]': size === 'lg',
} })}
)}> >
<CardBody className='py-6 px-4 md:py-8 md:px-12'>
<div className='w-full flex flex-col gap-5'>
{children} {children}
</div> </div>
</CardBody> </CardBody>
@ -45,6 +38,7 @@ const ConfigPageItem: React.FC<ConfigPageProps> = ({
}; };
export default function ConfigPage () { export default function ConfigPage () {
const isMediumUp = useMediaQuery({ minWidth: 768 });
const navigate = useNavigate(); const navigate = useNavigate();
const search = useSearchParams({ const search = useSearchParams({
tab: 'onebot', tab: 'onebot',
@ -52,55 +46,53 @@ export default function ConfigPage () {
const tab = search.get('tab') ?? 'onebot'; const tab = search.get('tab') ?? 'onebot';
return ( return (
<section className='w-full max-w-[1200px] mx-auto py-4 md:py-8 px-2 md:px-6 relative'> <section className='w-[1000px] max-w-full md:mx-auto gap-4 py-8 px-2 md:py-10'>
<title> - NapCat WebUI</title>
<Tabs <Tabs
aria-label='config tab' aria-label='config tab'
fullWidth={false} fullWidth
className='w-full' className='w-full'
isVertical={isMediumUp}
selectedKey={tab} selectedKey={tab}
onSelectionChange={(key) => { onSelectionChange={(key) => {
navigate(`/config?tab=${key}`); navigate(`/config?tab=${key}`);
}} }}
classNames={{ classNames={{
base: 'w-full flex-col items-center', tabList: 'sticky flex top-14 bg-opacity-50 backdrop-blur-sm',
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', panel: 'w-full relative',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm rounded-xl', base: 'md:!w-auto flex-grow-0 flex-shrink-0 mr-0',
tab: 'h-9 px-4 md:px-6', cursor: 'bg-opacity-60 backdrop-blur-sm',
tabContent: 'text-default-600 dark:text-default-300 font-medium group-data-[selected=true]:text-primary',
panel: 'w-full relative p-0',
}} }}
> >
<Tab title='OneBot配置' key='onebot'> <Tab title='OneBot配置' key='onebot'>
<ConfigPageItem> <ConfingPageItem>
<OneBotConfigCard /> <OneBotConfigCard />
</ConfigPageItem> </ConfingPageItem>
</Tab> </Tab>
<Tab title='服务器配置' key='server'> <Tab title='服务器配置' key='server'>
<ConfigPageItem> <ConfingPageItem>
<ServerConfigCard /> <ServerConfigCard />
</ConfigPageItem> </ConfingPageItem>
</Tab> </Tab>
<Tab title='WebUI配置' key='webui'> <Tab title='WebUI配置' key='webui'>
<ConfigPageItem> <ConfingPageItem>
<WebUIConfigCard /> <WebUIConfigCard />
</ConfigPageItem> </ConfingPageItem>
</Tab> </Tab>
<Tab title='登录配置' key='login'> <Tab title='登录配置' key='login'>
<ConfigPageItem> <ConfingPageItem>
<LoginConfigCard /> <LoginConfigCard />
</ConfigPageItem> </ConfingPageItem>
</Tab> </Tab>
<Tab title='修改密码' key='token'> <Tab title='修改密码' key='token'>
<ConfigPageItem size='sm'> <ConfingPageItem>
<ChangePasswordCard /> <ChangePasswordCard />
</ConfigPageItem> </ConfingPageItem>
</Tab> </Tab>
<Tab title='主题配置' key='theme'> <Tab title='主题配置' key='theme'>
<ConfigPageItem size='lg'> <ConfingPageItem size='lg'>
<ThemeConfigCard /> <ThemeConfigCard />
</ConfigPageItem> </ConfingPageItem>
</Tab> </Tab>
</Tabs> </Tabs>
</section> </section>

View File

@ -74,11 +74,6 @@ const OneBotConfigCard = () => {
{...field} {...field}
label='音乐签名地址' label='音乐签名地址'
placeholder='请输入音乐签名地址' 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 { Input } from '@heroui/input';
import { Switch } from '@heroui/switch';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form'; 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 SaveButtons from '@/components/button/save_buttons';
import PageLoading from '@/components/page_loading'; import PageLoading from '@/components/page_loading';
import SwitchCard from '@/components/switch_card';
import WebUIManager from '@/controllers/webui_manager'; import WebUIManager from '@/controllers/webui_manager';
@ -79,8 +79,8 @@ const ServerConfigCard = () => {
<> <>
<title> - NapCat WebUI</title> <title> - NapCat WebUI</title>
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<div className='flex flex-col gap-3'> <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 <Controller
control={control} control={control}
name='host' name='host'
@ -92,11 +92,6 @@ const ServerConfigCard = () => {
description='服务器监听的IP地址0.0.0.0表示监听所有网卡' description='服务器监听的IP地址0.0.0.0表示监听所有网卡'
isDisabled={!!configError} isDisabled={!!configError}
errorMessage={configError ? '获取配置失败' : undefined} 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} isDisabled={!!configError}
errorMessage={configError ? '获取配置失败' : undefined} errorMessage={configError ? '获取配置失败' : undefined}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)} 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} isDisabled={!!configError}
errorMessage={configError ? '获取配置失败' : undefined} errorMessage={configError ? '获取配置失败' : undefined}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)} 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>
<div className='flex flex-col gap-3'> <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 <Controller
control={control} control={control}
name='disableWebUI' name='disableWebUI'
render={({ field }) => ( render={({ field }) => (
<SwitchCard <Switch
value={field.value} isSelected={field.value}
onValueChange={(value: boolean) => field.onChange(value)} onValueChange={(value) => field.onChange(value)}
disabled={!!configError} isDisabled={!!configError}
label='禁用WebUI' >
description='启用后将完全禁用WebUI服务需要重启生效' <div className='flex flex-col'>
/> <span>WebUI</span>
<span className='text-sm text-default-400'>
WebUI服务
</span>
</div>
</Switch>
)} )}
/> />
<Controller <Controller
control={control} control={control}
name='disableNonLANAccess' name='disableNonLANAccess'
render={({ field }) => ( render={({ field }) => (
<SwitchCard <Switch
value={field.value} isSelected={field.value}
onValueChange={(value: boolean) => field.onChange(value)} onValueChange={(value) => field.onChange(value)}
disabled={!!configError} isDisabled={!!configError}
label='禁用非局域网访问' >
description='启用后只允许局域网内的设备访问WebUI提高安全性' <div className='flex flex-col'>
/> <span>访</span>
<span className='text-sm text-default-400'>
访WebUI
</span>
</div>
</Switch>
)} )}
/> />
</div> </div>

View File

@ -1,34 +1,28 @@
import { Accordion, AccordionItem } from '@heroui/accordion'; import { Accordion, AccordionItem } from '@heroui/accordion';
import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card'; import { Card, CardBody, CardHeader } from '@heroui/card';
import { Select, SelectItem } from '@heroui/select';
import { Chip } from '@heroui/chip';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import clsx from 'clsx'; 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 { Controller, useForm, useWatch } from 'react-hook-form';
import toast from 'react-hot-toast'; 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 { FaPaintbrush } from 'react-icons/fa6';
import { IoIosColorPalette } from 'react-icons/io'; import { IoIosColorPalette } from 'react-icons/io';
import { MdDarkMode, MdLightMode } from 'react-icons/md'; import { MdDarkMode, MdLightMode } from 'react-icons/md';
import { IoMdRefresh } from 'react-icons/io';
import themes from '@/const/themes'; import themes from '@/const/themes';
import ColorPicker from '@/components/ColorPicker'; 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 PageLoading from '@/components/page_loading';
import FileManager from '@/controllers/file_manager'; import { colorKeys, generateTheme, loadTheme } from '@/utils/theme';
import { applyFont, colorKeys, generateTheme, loadTheme, updateFontCache } from '@/utils/theme';
import WebUIManager from '@/controllers/webui_manager'; import WebUIManager from '@/controllers/webui_manager';
export type PreviewThemeCardProps = { export type PreviewThemeCardProps = {
theme: ThemeInfo; theme: ThemeInfo;
onPreview: () => void; onPreview: () => void;
isSelected?: boolean;
}; };
const values = [ const values = [
@ -53,7 +47,7 @@ const colors = [
'default', 'default',
]; ];
function PreviewThemeCard ({ theme, onPreview, isSelected }: PreviewThemeCardProps) { function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
const style = document.createElement('style'); const style = document.createElement('style');
style.innerHTML = generateTheme(theme.theme, theme.name); style.innerHTML = generateTheme(theme.theme, theme.name);
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
@ -70,19 +64,8 @@ function PreviewThemeCard ({ theme, onPreview, isSelected }: PreviewThemeCardPro
radius='sm' radius='sm'
isPressable isPressable
onPress={onPreview} onPress={onPreview}
className={clsx( className={clsx('text-primary bg-primary-50', theme.name)}
'text-primary bg-primary-50 relative transition-all',
theme.name,
isSelected && 'ring-2 ring-primary ring-offset-2'
)}
> >
{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'> <CardHeader className='pb-0 flex flex-col items-start gap-1'>
<div className='px-1 rounded-md bg-primary text-primary-foreground'> <div className='px-1 rounded-md bg-primary text-primary-foreground'>
{theme.name} {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 ThemeConfigCard = () => {
const { data, loading, error, refreshAsync } = useRequest( const { data, loading, error, refreshAsync } = useRequest(
WebUIManager.getThemeConfig WebUIManager.getThemeConfig
@ -156,17 +116,12 @@ const ThemeConfigCard = () => {
theme: { theme: {
dark: {}, dark: {},
light: {}, light: {},
fontMode: 'aacute',
}, },
}, },
}); });
const [dataLoaded, setDataLoaded] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// 使用 useRef 存储 style 标签引用 // 使用 useRef 存储 style 标签引用
const styleTagRef = useRef<HTMLStyleElement | null>(null); const styleTagRef = useRef<HTMLStyleElement | null>(null);
const originalDataRef = useRef<ThemeConfig | null>(null);
// 在组件挂载时创建 style 标签,并在卸载时清理 // 在组件挂载时创建 style 标签,并在卸载时清理
useEffect(() => { useEffect(() => {
@ -182,45 +137,13 @@ const ThemeConfigCard = () => {
const theme = useWatch({ control, name: 'theme' }); const theme = useWatch({ control, name: 'theme' });
// 检测是否有未保存的更改 const reset = () => {
useEffect(() => { if (data) setOnebotValue('theme', data);
if (originalDataRef.current && dataLoaded) { };
const colorsChanged = !isThemeColorsEqual(theme, originalDataRef.current);
const fontChanged = theme.fontMode !== originalDataRef.current.fontMode;
setHasUnsavedChanges(colorsChanged || fontChanged);
}
}, [theme, dataLoaded]);
const reset = useCallback(() => { const onSubmit = handleOnebotSubmit(async (data) => {
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) => {
try { try {
await WebUIManager.setThemeConfig(formData.theme); await WebUIManager.setThemeConfig(data.theme);
// 更新原始数据引用
originalDataRef.current = formData.theme;
// 更新字体缓存
if (formData.theme.fontMode) {
updateFontCache(formData.theme.fontMode);
}
setHasUnsavedChanges(false);
toast.success('保存成功'); toast.success('保存成功');
loadTheme(); loadTheme();
} catch (error) { } catch (error) {
@ -241,7 +164,7 @@ const ThemeConfigCard = () => {
useEffect(() => { useEffect(() => {
reset(); reset();
}, [data, reset]); }, [data]);
useEffect(() => { useEffect(() => {
if (theme && styleTagRef.current) { if (theme && styleTagRef.current) {
@ -250,25 +173,6 @@ const ThemeConfigCard = () => {
} }
}, [theme]); }, [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 (loading) return <PageLoading loading />;
if (error) { if (error) {
@ -281,209 +185,96 @@ const ThemeConfigCard = () => {
<> <>
<title> - NapCat WebUI</title> <title> - NapCat WebUI</title>
{/* 顶部操作栏 */} <SaveButtons
<div className="sticky top-0 z-20 bg-background/80 backdrop-blur-md border-b border-divider"> onSubmit={onSubmit}
<div className="flex items-center justify-between p-4"> reset={reset}
<div className="flex items-center gap-3 flex-wrap"> isSubmitting={isSubmitting}
<div className="flex items-center gap-2 text-sm"> refresh={onRefresh}
<span className="text-default-400">:</span> className='items-end w-full p-4'
<Chip size="sm" color="primary" variant="flat"> />
{savedThemeName || '加载中...'} <div className='px-4 text-sm text-default-600'></div>
</Chip> <Accordion variant='splitted' defaultExpandedKeys={['select']}>
</div> <AccordionItem
<div className="flex items-center gap-2 text-sm"> key='select'
<span className="text-default-400">:</span> aria-label='Pick Color'
<Chip size="sm" color="secondary" variant="flat"> title='选择主题'
{savedFontModeDisplayName} subtitle='可以切换夜间/白昼模式查看对应颜色'
</Chip> className='shadow-small'
</div> startContent={<IoIosColorPalette />}
{hasUnsavedChanges && ( >
<Chip size="sm" color="warning" variant="solid"> <div className='flex flex-wrap gap-2'>
{themes.map((theme) => (
</Chip> <PreviewThemeCard
)} key={theme.name}
</div> theme={theme}
<div className="flex items-center gap-2"> onPreview={() => {
<Button setOnebotValue('theme', theme.theme);
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>
)}
/> />
<div className='p-3 rounded-lg bg-default-100 dark:bg-default-50/30'> ))}
<div className='text-sm text-default-500 mb-2'> </div>
"自定义字体" </AccordionItem>
</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>
<AccordionItem <AccordionItem
key='select' key='pick'
aria-label='Pick Color' aria-label='Pick Color'
title='选择主题' title='自定义配色'
subtitle='点击主题卡片即可预览,记得保存' className='shadow-small'
className='shadow-small' startContent={<FaPaintbrush />}
startContent={<IoIosColorPalette />} >
> <div className='space-y-2'>
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3'> {(['dark', 'light'] as const).map((mode) => (
{themes.map((t) => ( <div
<PreviewThemeCard key={mode}
key={t.name} className={clsx(
theme={t} 'p-2 rounded-md',
isSelected={selectedThemeName === t.name} mode === 'dark' ? 'text-white' : 'text-black',
onPreview={() => { mode === 'dark'
setOnebotValue('theme', { ...t.theme, fontMode: theme.fontMode }); ? 'bg-content1-foreground dark:bg-content1'
}} : 'bg-content1 dark:bg-content1-foreground'
/> )}
))} >
</div> <h3 className='text-center p-2 rounded-md bg-content2 mb-2 text-default-800 flex items-center justify-center'>
</AccordionItem> {mode === 'dark'
? (
<AccordionItem <MdDarkMode size={24} />
key='pick' )
aria-label='Pick Color' : (
title='自定义配色' <MdLightMode size={24} />
subtitle='精细调整每个颜色变量' )}
className='shadow-small' {mode === 'dark' ? '夜间模式主题' : '白昼模式主题'}
startContent={<FaPaintbrush />} </h3>
> {colorKeys.map((key) => (
<div className='space-y-4'> <div
{(['light', 'dark'] as const).map((mode) => ( key={key}
<div className='grid grid-cols-2 items-center mb-2 gap-2'
key={mode} >
className={clsx( <label className='text-right'>{key}</label>
'p-4 rounded-lg', <Controller
mode === 'dark' ? 'bg-zinc-900 text-white' : 'bg-zinc-100 text-black' control={control}
)} name={`theme.${mode}.${key}`}
> render={({ field: { value, onChange } }) => {
<h3 className='flex items-center justify-center gap-2 p-2 rounded-md bg-opacity-20 mb-4 font-medium'> const hslArray = value?.split(' ') ?? [0, 0, 0];
{mode === 'dark' ? <MdDarkMode size={20} /> : <MdLightMode size={20} />} const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
{mode === 'dark' ? '深色模式' : '浅色模式'} return (
</h3> <ColorPicker
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'> color={color}
{colorKeys.map((colorKey) => ( onChange={(result) => {
<div onChange(
key={colorKey} `${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%`
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>
))}
</div> </div>
</div> ))}
))} </div>
</div> ))}
</AccordionItem> </div>
</Accordion> </AccordionItem>
</div> </Accordion>
</> </>
); );
}; };

View File

@ -1,3 +1,4 @@
import { Input } from '@heroui/input';
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { useLocalStorage } from '@uidotdev/usehooks'; import { useLocalStorage } from '@uidotdev/usehooks';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@ -7,9 +8,13 @@ import toast from 'react-hot-toast';
import key from '@/const/key'; import key from '@/const/key';
import SaveButtons from '@/components/button/save_buttons'; import SaveButtons from '@/components/button/save_buttons';
import FileInput from '@/components/input/file_input';
import ImageInput from '@/components/input/image_input'; import ImageInput from '@/components/input/image_input';
import useMusic from '@/hooks/use-music';
import { siteConfig } from '@/config/site'; import { siteConfig } from '@/config/site';
import FileManager from '@/controllers/file_manager';
import WebUIManager from '@/controllers/webui_manager'; import WebUIManager from '@/controllers/webui_manager';
// Base64URL to Uint8Array converter // Base64URL to Uint8Array converter
@ -35,10 +40,11 @@ const WebUIConfigCard = () => {
handleSubmit: handleWebuiSubmit, handleSubmit: handleWebuiSubmit,
formState: { isSubmitting }, formState: { isSubmitting },
setValue: setWebuiValue, setValue: setWebuiValue,
} = useForm({ } = useForm<IConfig['webui']>({
defaultValues: { defaultValues: {
background: '', background: '',
customIcons: {} as Record<string, string>, musicListID: '',
customIcons: {},
}, },
}); });
@ -47,6 +53,7 @@ const WebUIConfigCard = () => {
key.customIcons, key.customIcons,
{} {}
); );
const { setListId, listId } = useMusic();
const [registrationOptions, setRegistrationOptions] = useState<any>(null); const [registrationOptions, setRegistrationOptions] = useState<any>(null);
const [isLoadingOptions, setIsLoadingOptions] = useState(false); const [isLoadingOptions, setIsLoadingOptions] = useState(false);
@ -68,12 +75,14 @@ const WebUIConfigCard = () => {
}; };
const reset = () => { const reset = () => {
setWebuiValue('musicListID', listId);
setWebuiValue('customIcons', customIcons); setWebuiValue('customIcons', customIcons);
setWebuiValue('background', b64img); setWebuiValue('background', b64img);
}; };
const onSubmit = handleWebuiSubmit((data) => { const onSubmit = handleWebuiSubmit((data) => {
try { try {
setListId(data.musicListID);
setCustomIcons(data.customIcons); setCustomIcons(data.customIcons);
setB64img(data.background); setB64img(data.background);
toast.success('保存成功'); toast.success('保存成功');
@ -85,41 +94,77 @@ const WebUIConfigCard = () => {
useEffect(() => { useEffect(() => {
reset(); reset();
}, [customIcons, b64img]); }, [listId, customIcons, b64img]);
return ( return (
<> <>
<title>WebUI配置 - NapCat WebUI</title> <title>WebUI配置 - NapCat WebUI</title>
<div className='flex flex-col gap-2'> <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 <Controller
control={control} control={control}
name='background' name='musicListID'
render={({ field }) => ( render={({ field }) => (
<ImageInput <Input
{...field} {...field}
label='网易云音乐歌单ID网页内音乐播放器'
placeholder='请输入歌单ID'
/> />
)} )}
/> />
</div> </div>
<div className='flex flex-col gap-2'> <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) => ( {siteConfig.navItems.map((item) => (
<Controller <Controller
key={item.label} key={item.label}
control={control} control={control}
name={`customIcons.${item.label}`} name={`customIcons.${item.label}`}
render={({ field }) => ( render={({ field }) => <ImageInput {...field} label={item.label} />}
<ImageInput
{...field}
label={item.label}
/>
)}
/> />
))} ))}
</div> </div>
<div className='flex flex-col gap-2'> <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'> <div className='text-sm text-default-400 mb-2'>
Passkey后便WebUItoken Passkey后便WebUItoken
</div> </div>

View File

@ -1,198 +1,62 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { useEffect, useState } from 'react'; import { motion } from 'motion/react';
import { IoClose } from 'react-icons/io5'; import { useEffect, useRef, useState } from 'react';
import { TbSquareRoundedChevronLeftFilled } from 'react-icons/tb'; import { TbSquareRoundedChevronLeftFilled } from 'react-icons/tb';
import key from '@/const/key';
import oneBotHttpApi from '@/const/ob_api'; 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 OneBotApiDebug from '@/components/onebot/api/debug';
import OneBotApiNavList from '@/components/onebot/api/nav_list'; import OneBotApiNavList from '@/components/onebot/api/nav_list';
export default function HttpDebug () { export default function HttpDebug () {
const [activeApi, setActiveApi] = useState<OneBotHttpApiPath | null>('/set_qq_profile'); const [selectedApi, setSelectedApi] =
const [openApis, setOpenApis] = useState<OneBotHttpApiPath[]>(['/set_qq_profile']); useState<keyof OneBotHttpApi>('/set_qq_profile');
const data = oneBotHttpApi[selectedApi];
const contentRef = useRef<HTMLDivElement>(null);
const [openSideBar, setOpenSideBar] = useState(true); 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(() => { useEffect(() => {
if (window.innerWidth < 768) { contentRef?.current?.scrollTo?.({
setOpenSideBar(false); top: 0,
} behavior: 'smooth',
}, []); });
}, [selectedApi]);
// 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);
}
}
};
return ( return (
<> <>
<title>HTTP调试 - NapCat WebUI</title> <title>HTTP调试 - NapCat WebUI</title>
<div className='h-[calc(100vh-3.5rem)] p-0 md:p-4'> <OneBotApiNavList
<div className={clsx( data={oneBotHttpApi}
'h-full flex flex-col overflow-hidden transition-all relative', selectedApi={selectedApi}
'rounded-none md:rounded-2xl', onSelect={setSelectedApi}
hasBackground openSideBar={openSideBar}
? 'bg-white/5 dark:bg-black/5 backdrop-blur-sm' />
: 'bg-white/20 dark:bg-black/10 backdrop-blur-sm shadow-sm' <div ref={contentRef} className='flex-1 h-full overflow-x-hidden'>
)}> <motion.div
{/* Unifed Header */} className='absolute top-16 z-30 md:!ml-4'
<div className='h-12 border-b border-white/10 flex items-center justify-between px-4 z-50 bg-white/5 flex-shrink-0'> animate={{ marginLeft: openSideBar ? '16rem' : '1rem' }}
<div className='flex items-center gap-3'> transition={{ type: 'spring', stiffness: 150, damping: 15 }}
<Button >
isIconOnly <Button
size="sm" isIconOnly
variant="light" color='primary'
className={clsx( radius='md'
"opacity-50 hover:opacity-100 transition-all", variant='shadow'
openSideBar && "text-primary opacity-100" size='sm'
)} onPress={() => setOpenSideBar(!openSideBar)}
onPress={() => setOpenSideBar(!openSideBar)} >
> <TbSquareRoundedChevronLeftFilled
<TbSquareRoundedChevronLeftFilled className={clsx("text-lg transform transition-transform", !openSideBar && "rotate-180")} /> size={24}
</Button> className={clsx(
<h1 className={clsx( 'transition-transform',
'text-sm font-bold tracking-tight', openSideBar ? '' : 'transform rotate-180'
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}
/> />
</Button>
<div </motion.div>
className='flex-1 h-full overflow-hidden flex flex-col relative' <OneBotApiDebug path={selectedApi} data={data} />
>
{/* 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>
</div> </div>
</> </>
); );

View File

@ -2,10 +2,8 @@ import { Button } from '@heroui/button';
import { Card, CardBody } from '@heroui/card'; import { Card, CardBody } from '@heroui/card';
import { Input } from '@heroui/input'; import { Input } from '@heroui/input';
import { useLocalStorage } from '@uidotdev/usehooks'; import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { IoFlash, IoFlashOff, IoRefresh } from 'react-icons/io5';
import key from '@/const/key'; import key from '@/const/key';
@ -26,206 +24,69 @@ export default function WSDebug () {
}); });
const [inputUrl, setInputUrl] = useState(socketConfig.url); const [inputUrl, setInputUrl] = useState(socketConfig.url);
const [inputToken, setInputToken] = useState(socketConfig.token); 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 } = const { sendMessage, readyState, FilterMessagesType, filteredMessages } =
useWebSocketDebug(socketConfig.url, socketConfig.token, shouldConnect); useWebSocketDebug(socketConfig.url, socketConfig.token);
// 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 handleConnect = useCallback(() => { const handleConnect = useCallback(() => {
// 允许以 / 开头的相对路径(如代理情况),以及标准的 ws/wss if (!inputUrl.startsWith('ws://') && !inputUrl.startsWith('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://')) {
toast.error('WebSocket URL 不合法'); toast.error('WebSocket URL 不合法');
return; return;
} }
setSocketConfig({ setSocketConfig({
url: finalUrl, url: inputUrl,
token: inputToken, token: inputToken,
}); });
setShouldConnect(true); }, [inputUrl, inputToken]);
}, [inputUrl, inputToken, setSocketConfig]);
const handleDisconnect = useCallback(() => {
setShouldConnect(false);
}, []);
const handleResetConfig = useCallback(() => {
setSocketConfig({ url: '', token: '' });
// 刷新页面以重新触发初始逻辑
window.location.reload();
}, [setSocketConfig]);
return ( return (
<> <>
<title>Websocket调试 - NapCat WebUI</title> <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'> <div className='h-[calc(100vh-4rem)] overflow-hidden flex flex-col'>
{/* Config Card */} <Card className='mx-2 mt-2 flex-shrink-0 bg-opacity-50 backdrop-blur-sm'>
<Card className={clsx( <CardBody className='gap-2'>
'flex-shrink-0 backdrop-blur-xl border shadow-sm', <div className='grid gap-2 items-center md:grid-cols-5'>
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]'>
<Input <Input
className='col-span-2'
label='WebSocket URL' label='WebSocket URL'
type='text' type='text'
value={inputUrl} value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)} onChange={(e) => setInputUrl(e.target.value)}
placeholder='输入 WebSocket URL' 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 <Input
className='col-span-2'
label='Token' label='Token'
type='text' type='text'
value={inputToken} value={inputToken}
onChange={(e) => setInputToken(e.target.value)} onChange={(e) => setInputToken(e.target.value)}
placeholder='输入 Token (可选)' 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' : '',
}}
/> />
<div className="flex gap-2"> <div className='flex-shrink-0 flex gap-2 col-span-2 md:col-span-1'>
<Button <Button
isIconOnly color='primary'
size="md" onPress={handleConnect}
radius="full" size='lg'
color="warning"
variant="flat"
onPress={handleResetConfig}
title="重置配置"
>
<IoRefresh className="text-xl" />
</Button>
<Button
onPress={shouldConnect ? handleDisconnect : handleConnect}
size='md'
radius='full' radius='full'
color={shouldConnect ? 'danger' : 'primary'} className='w-full md:w-auto'
className='font-bold shadow-lg min-w-[100px] flex-1'
startContent={shouldConnect ? <IoFlashOff /> : <IoFlash />}
> >
{shouldConnect ? '断开' : '连接'}
</Button> </Button>
</div> </div>
</div> </div>
<div className='p-2 border border-default-100 bg-content1 bg-opacity-50 rounded-md dark:bg-[rgb(30,30,30)]'>
{/* Status Bar */} <div className='grid gap-2 md:grid-cols-5 items-center md:w-fit'>
<div className={clsx( <WSStatus state={readyState} />
'p-2.5 rounded-xl border transition-colors flex flex-col md:flex-row gap-3 md:items-center md:justify-between', <div className='md:w-64 max-w-full col-span-2'>
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'>
{FilterMessagesType} {FilterMessagesType}
</div> </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} /> <OneBotSendModal sendMessage={sendMessage} />
</div> </div>
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
{/* Message List */} <div className='flex-1 overflow-hidden'>
<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'
)}>
<OneBotMessageList messages={filteredMessages} /> <OneBotMessageList messages={filteredMessages} />
</div> </div>
</div> </div>

View File

@ -2,7 +2,6 @@ import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs';
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { Input } from '@heroui/input'; import { Input } from '@heroui/input';
import type { Selection, SortDescriptor } from '@react-types/shared'; import type { Selection, SortDescriptor } from '@react-types/shared';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import path from 'path-browserify'; import path from 'path-browserify';
@ -15,7 +14,6 @@ import { TbTrash } from 'react-icons/tb';
import { TiArrowBack } from 'react-icons/ti'; import { TiArrowBack } from 'react-icons/ti';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import key from '@/const/key';
import CreateFileModal from '@/components/file_manage/create_file_modal'; import CreateFileModal from '@/components/file_manage/create_file_modal';
import FileEditModal from '@/components/file_manage/file_edit_modal'; import FileEditModal from '@/components/file_manage/file_edit_modal';
import FilePreviewModal from '@/components/file_manage/file_preview_modal'; import FilePreviewModal from '@/components/file_manage/file_preview_modal';
@ -330,139 +328,123 @@ export default function FileManagerPage () {
useFsAccessApi: false, // 添加此选项以避免某些浏览器的文件系统API问题 useFsAccessApi: false, // 添加此选项以避免某些浏览器的文件系统API问题
}); });
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<div className='h-full flex flex-col relative gap-4 w-full p-2 md:p-4'> <div className='p-4'>
<div className={clsx( <div className='mb-4 flex items-center gap-4 sticky top-14 z-10 bg-content1 py-1'>
'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', <Button
hasBackground color='primary'
? 'bg-white/20 dark:bg-black/10 border border-white/40 dark:border-white/10' size='sm'
: 'bg-white/60 dark:bg-black/40 border border-white/40 dark:border-white/10' isIconOnly
)}> variant='flat'
<div className='flex items-center gap-2 overflow-x-auto hide-scrollbar pb-1 md:pb-0'> onPress={() => handleDirectoryClick('..')}
<Button className='text-lg'
color='primary' >
size='sm' <TiArrowBack />
isIconOnly </Button>
variant='flat'
onPress={() => handleDirectoryClick('..')}
className='text-lg min-w-8'
>
<TiArrowBack />
</Button>
<Button <Button
color='primary' color='primary'
size='sm' size='sm'
isIconOnly isIconOnly
variant='flat' variant='flat'
onPress={() => setIsCreateModalOpen(true)} onPress={() => setIsCreateModalOpen(true)}
className='text-lg min-w-8' className='text-lg'
> >
<FiPlus /> <FiPlus />
</Button> </Button>
<Button <Button
color='primary' color='primary'
isLoading={loading} isLoading={loading}
size='sm' size='sm'
isIconOnly isIconOnly
variant='flat' variant='flat'
onPress={loadFiles} onPress={loadFiles}
className='text-lg min-w-8' className='text-lg'
> >
<MdRefresh /> <MdRefresh />
</Button> </Button>
<Button <Button
color='primary' color='primary'
size='sm' size='sm'
isIconOnly isIconOnly
variant='flat' variant='flat'
onPress={() => setShowUpload((prev) => !prev)} onPress={() => setShowUpload((prev) => !prev)}
className='text-lg min-w-8' className='text-lg'
> >
<FiUpload /> <FiUpload />
</Button> </Button>
{((selectedFiles instanceof Set && selectedFiles.size > 0) || {((selectedFiles instanceof Set && selectedFiles.size > 0) ||
selectedFiles === 'all') && ( selectedFiles === 'all') && (
<> <>
<Button <Button
color='primary' color='primary'
size='sm' size='sm'
variant='flat' variant='flat'
onPress={handleBatchDelete} onPress={handleBatchDelete}
className='text-sm px-2 min-w-fit' className='text-sm'
startContent={<TbTrash className='text-lg' />} 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)}`);
}}
> >
{part} (
</BreadcrumbItem> {selectedFiles instanceof Set ? selectedFiles.size : files.length}
))} )
</Breadcrumbs> </Button>
<Input <Button
type='text' color='primary'
placeholder='输入跳转路径' size='sm'
value={jumpPath} variant='flat'
onChange={(e) => setJumpPath(e.target.value)} onPress={() => {
onKeyDown={(e) => { setMoveTargetPath('');
if (e.key === 'Enter' && jumpPath.trim() !== '') { setIsMoveModalOpen(true);
navigate(`/file_manager#${encodeURIComponent(jumpPath.trim())}`); }}
} className='text-sm'
}} startContent={<FiMove className='text-lg' />}
className='w-full md:w-64' >
classNames={{ (
inputWrapper: 'bg-white/40 dark:bg-black/20 backdrop-blur-md', {selectedFiles instanceof Set ? selectedFiles.size : files.length}
}} )
/> </Button>
</div> <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> </div>
<motion.div <motion.div

View File

@ -1,9 +1,6 @@
import { Card, CardBody } from '@heroui/card'; import { Card, CardBody } from '@heroui/card';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import clsx from 'clsx'; import { useCallback, useEffect, useState, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import key from '@/const/key';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@ -65,7 +62,7 @@ export interface SystemStatusCardProps {
setArchInfo: (arch: string | undefined) => void; setArchInfo: (arch: string | undefined) => void;
} }
const SystemStatusCard: React.FC<SystemStatusCardProps> = ({ setArchInfo }) => { 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 isSetted = useRef(false);
const getStatus = useCallback(() => { const getStatus = useCallback(() => {
try { try {
@ -94,10 +91,7 @@ const SystemStatusCard: React.FC<SystemStatusCardProps> = ({ setArchInfo }) => {
}; };
const DashboardIndexPage: React.FC = () => { const DashboardIndexPage: React.FC = () => {
const [archInfo, setArchInfo] = useLocalStorage<string | undefined>('napcat_arch_info_cache', undefined); const [archInfo, setArchInfo] = useState<string>();
// @ts-ignore
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<> <>
@ -111,10 +105,7 @@ const DashboardIndexPage: React.FC = () => {
<SystemStatusCard setArchInfo={setArchInfo} /> <SystemStatusCard setArchInfo={setArchInfo} />
</div> </div>
<Networks /> <Networks />
<Card className={clsx( <Card className='bg-opacity-60 shadow-sm shadow-primary-100'>
'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'
)}>
<CardBody> <CardBody>
<Hitokoto /> <Hitokoto />
</CardBody> </CardBody>

View File

@ -53,8 +53,8 @@ export default function LogsPage () {
classNames={{ classNames={{
panel: 'w-full flex-1 h-full py-0 flex flex-col gap-4', panel: 'w-full flex-1 h-full py-0 flex flex-col gap-4',
base: 'flex-shrink-0 !h-fit', base: 'flex-shrink-0 !h-fit',
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md', tabList: 'bg-opacity-50 backdrop-blur-sm',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm', cursor: 'bg-opacity-60 backdrop-blur-sm',
}} }}
> >
<Tab title='实时日志'> <Tab title='实时日志'>

View File

@ -375,8 +375,9 @@ export default function NetworkPage () {
<AddButton onOpen={handleClickCreate} /> <AddButton onOpen={handleClickCreate} />
<Button <Button
isIconOnly isIconOnly
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md" color='primary'
radius='full' radius='full'
variant='flat'
onPress={refresh} onPress={refresh}
> >
<IoMdRefresh size={24} /> <IoMdRefresh size={24} />
@ -387,8 +388,8 @@ export default function NetworkPage () {
className='max-w-full' className='max-w-full'
items={tabs} items={tabs}
classNames={{ classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md', tabList: 'bg-opacity-50 backdrop-blur-sm',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm', cursor: 'bg-opacity-60 backdrop-blur-sm',
}} }}
> >
{(item) => ( {(item) => (

View File

@ -12,13 +12,10 @@ import {
horizontalListSortingStrategy, horizontalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { IoAdd, IoClose } from 'react-icons/io5'; import { IoAdd, IoClose } from 'react-icons/io5';
import key from '@/const/key';
import { TabList, TabPanel, Tabs } from '@/components/tabs'; import { TabList, TabPanel, Tabs } from '@/components/tabs';
import { SortableTab } from '@/components/tabs/sortable_tab.tsx'; import { SortableTab } from '@/components/tabs/sortable_tab.tsx';
import { TerminalInstance } from '@/components/terminal/terminal-instance'; import { TerminalInstance } from '@/components/terminal/terminal-instance';
@ -33,8 +30,6 @@ interface TerminalTab {
export default function TerminalPage () { export default function TerminalPage () {
const [tabs, setTabs] = useState<TerminalTab[]>([]); const [tabs, setTabs] = useState<TerminalTab[]>([]);
const [selectedTab, setSelectedTab] = useState<string>(''); const [selectedTab, setSelectedTab] = useState<string>('');
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
useEffect(() => { useEffect(() => {
// 获取已存在的终端列表 // 获取已存在的终端列表
@ -117,40 +112,35 @@ export default function TerminalPage () {
className='h-full overflow-hidden' className='h-full overflow-hidden'
> >
<div className='flex items-center gap-2 flex-shrink-0 flex-grow-0'> <div className='flex items-center gap-2 flex-shrink-0 flex-grow-0'>
{tabs.length > 0 && ( <TabList className='flex-1 !overflow-x-auto w-full hide-scrollbar'>
<TabList className={clsx( <SortableContext
'flex-1 !overflow-x-auto w-full hide-scrollbar backdrop-blur-sm p-1 rounded-lg border border-white/20', items={tabs}
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/40 dark:bg-black/20' strategy={horizontalListSortingStrategy}
)}> >
<SortableContext {tabs.map((tab) => (
items={tabs} <SortableTab
strategy={horizontalListSortingStrategy} key={tab.id}
> id={tab.id}
{tabs.map((tab) => ( value={tab.id}
<SortableTab isSelected={selectedTab === tab.id}
key={tab.id} className='flex gap-2 items-center flex-shrink-0'
id={tab.id} >
value={tab.id} {tab.title}
isSelected={selectedTab === tab.id} <Button
className='flex gap-2 items-center flex-shrink-0' 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} <IoClose />
<Button </Button>
isIconOnly </SortableTab>
radius='full' ))}
variant='flat' </SortableContext>
size='sm' </TabList>
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>
)}
<Button <Button
isIconOnly isIconOnly
color='primary' color='primary'

View File

@ -6,8 +6,6 @@ import { useEffect, useRef, useState } from 'react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import logo from '@/assets/images/logo.png';
import HoverEffectCard from '@/components/effect_card'; import HoverEffectCard from '@/components/effect_card';
import { title } from '@/components/primitives'; import { title } from '@/components/primitives';
import QrCodeLogin from '@/components/qr_code_login'; 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 type { QQItem } from '@/components/quick_login';
import { ThemeSwitch } from '@/components/theme-switch'; import { ThemeSwitch } from '@/components/theme-switch';
import logo from '@/assets/images/logo.png';
import QQManager from '@/controllers/qq_manager'; import QQManager from '@/controllers/qq_manager';
import PureLayout from '@/layouts/pure'; import PureLayout from '@/layouts/pure';
import { motion } from 'motion/react';
export default function QQLoginPage () { export default function QQLoginPage () {
const navigate = useNavigate(); const navigate = useNavigate();
@ -114,12 +112,7 @@ export default function QQLoginPage () {
<> <>
<title>QQ登录 - NapCat WebUI</title> <title>QQ登录 - NapCat WebUI</title>
<PureLayout> <PureLayout>
<motion.div <div className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'>
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'
>
<HoverEffectCard <HoverEffectCard
className='items-center gap-4 pt-0 pb-6 bg-default-50' className='items-center gap-4 pt-0 pb-6 bg-default-50'
maxXRotation={3} maxXRotation={3}
@ -176,7 +169,7 @@ export default function QQLoginPage () {
</Button> </Button>
</CardBody> </CardBody>
</HoverEffectCard> </HoverEffectCard>
</motion.div> </div>
</PureLayout> </PureLayout>
</> </>
); );

View File

@ -8,17 +8,15 @@ import { toast } from 'react-hot-toast';
import { IoKeyOutline } from 'react-icons/io5'; import { IoKeyOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import logo from '@/assets/images/logo.png';
import key from '@/const/key'; import key from '@/const/key';
import HoverEffectCard from '@/components/effect_card'; import HoverEffectCard from '@/components/effect_card';
import { title } from '@/components/primitives'; import { title } from '@/components/primitives';
import { ThemeSwitch } from '@/components/theme-switch'; import { ThemeSwitch } from '@/components/theme-switch';
import logo from '@/assets/images/logo.png';
import WebUIManager from '@/controllers/webui_manager'; import WebUIManager from '@/controllers/webui_manager';
import PureLayout from '@/layouts/pure'; import PureLayout from '@/layouts/pure';
import { motion } from 'motion/react';
export default function WebLoginPage () { export default function WebLoginPage () {
const urlSearchParams = new URLSearchParams(window.location.search); const urlSearchParams = new URLSearchParams(window.location.search);
@ -152,12 +150,7 @@ export default function WebLoginPage () {
<> <>
<title>WebUI登录 - NapCat WebUI</title> <title>WebUI登录 - NapCat WebUI</title>
<PureLayout> <PureLayout>
<motion.div <div className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'>
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'
>
<HoverEffectCard <HoverEffectCard
className='items-center gap-4 pt-0 pb-6 bg-default-50' className='items-center gap-4 pt-0 pb-6 bg-default-50'
maxXRotation={3} maxXRotation={3}
@ -264,7 +257,7 @@ export default function WebLoginPage () {
</Button> </Button>
</CardBody> </CardBody>
</HoverEffectCard> </HoverEffectCard>
</motion.div> </div>
</PureLayout> </PureLayout>
</> </>
); );

View File

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

View File

@ -5,48 +5,16 @@
@tailwind utilities; @tailwind utilities;
body { 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; font-family:
-webkit-font-smoothing: antialiased; 'Aa偷吃可爱长大的',
-moz-osx-font-smoothing: grayscale; PingFang SC,
text-rendering: optimizeLegibility; Helvetica Neue,
font-smooth: always; Microsoft YaHei,
letter-spacing: 0.02em; sans-serif !important;
} -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
:root { text-rendering: optimizeLegibility;
/* 字体变量:可被 JS 动态覆盖 */ font-smooth: always;
--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%;
} }
@layer components { @layer components {
@ -54,13 +22,11 @@ h6 {
width: 0 !important; width: 0 !important;
height: 0 !important; height: 0 !important;
} }
.hide-scrollbar::-webkit-scrollbar-thumb { .hide-scrollbar::-webkit-scrollbar-thumb {
width: 0 !important; width: 0 !important;
height: 0 !important; height: 0 !important;
background-color: transparent !important; background-color: transparent !important;
} }
.hide-scrollbar::-webkit-scrollbar-track { .hide-scrollbar::-webkit-scrollbar-track {
width: 0 !important; width: 0 !important;
height: 0 !important; height: 0 !important;
@ -68,30 +34,23 @@ h6 {
} }
} }
::selection {
background-color: #ffcdba;
color: #fff;
}
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 8px;
height: 6px; height: 8px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background-color: transparent; background-color: transparent;
border-radius: 3px; -webkit-border-radius: 2em;
-moz-border-radius: 2em;
border-radius: 2em;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background-color: rgba(255, 182, 193, 0.4); background-color: rgb(147, 147, 153, 0.5);
/* 浅粉色滚动条 */ -webkit-border-radius: 2em;
border-radius: 3px; -moz-border-radius: 2em;
transition: all 0.3s; border-radius: 2em;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 127, 172, 0.6);
} }
.monaco-editor { .monaco-editor {
@ -128,18 +87,16 @@ h6 {
.context-view.monaco-menu-container * { .context-view.monaco-menu-container * {
font-family: font-family:
-apple-system, PingFang SC,
BlinkMacSystemFont, 'Aa偷吃可爱长大的',
'Segoe UI', Helvetica Neue,
'PingFang SC', Microsoft YaHei,
'Microsoft YaHei',
sans-serif !important; sans-serif !important;
} }
.ql-hidden { .ql-hidden {
@apply hidden; @apply hidden;
} }
.ql-editor img { .ql-editor img {
@apply inline-block; @apply inline-block;
} }

View File

@ -1,192 +1,191 @@
interface ServerResponse<T> { interface ServerResponse<T> {
code: number; code: number
data: T; data: T
message: string; message: string
} }
interface AuthResponse { interface AuthResponse {
Credential: string; Credential: string
} }
interface LoginListItem { interface LoginListItem {
uin: string; uin: string
uid: string; uid: string
nickName: string; nickName: string
faceUrl: string; faceUrl: string
facePath: string; facePath: string
loginType: 1; // 1是二维码登录 loginType: 1 // 1是二维码登录
isQuickLogin: boolean; // 是否可以快速登录 isQuickLogin: boolean // 是否可以快速登录
isAutoLogin: boolean; // 是否可以自动登录 isAutoLogin: boolean // 是否可以自动登录
} }
interface PackageInfo { interface PackageInfo {
name: string; name: string
version: string; version: string
private: boolean; private: boolean
type: string; type: string
scripts: Record<string, string>; scripts: Record<string, string>
dependencies: Record<string, string>; dependencies: Record<string, string>
devDependencies: Record<string, string>; devDependencies: Record<string, string>
} }
interface SystemStatus { interface SystemStatus {
cpu: { cpu: {
core: number; core: number
model: string; model: string
speed: string; speed: string
usage: { usage: {
system: string; system: string
qq: string; qq: string
}; }
}; }
memory: { memory: {
total: string; total: string
usage: { usage: {
system: string; system: string
qq: string; qq: string
}; }
}; }
arch: string; arch: string
} }
interface ThemeConfigItem { interface ThemeConfigItem {
'--heroui-background': string; '--heroui-background': string
'--heroui-foreground-50': string; '--heroui-foreground-50': string
'--heroui-foreground-100': string; '--heroui-foreground-100': string
'--heroui-foreground-200': string; '--heroui-foreground-200': string
'--heroui-foreground-300': string; '--heroui-foreground-300': string
'--heroui-foreground-400': string; '--heroui-foreground-400': string
'--heroui-foreground-500': string; '--heroui-foreground-500': string
'--heroui-foreground-600': string; '--heroui-foreground-600': string
'--heroui-foreground-700': string; '--heroui-foreground-700': string
'--heroui-foreground-800': string; '--heroui-foreground-800': string
'--heroui-foreground-900': string; '--heroui-foreground-900': string
'--heroui-foreground': string; '--heroui-foreground': string
'--heroui-focus': string; '--heroui-focus': string
'--heroui-overlay': string; '--heroui-overlay': string
'--heroui-divider': string; '--heroui-divider': string
'--heroui-divider-opacity': string; '--heroui-divider-opacity': string
'--heroui-content1': string; '--heroui-content1': string
'--heroui-content1-foreground': string; '--heroui-content1-foreground': string
'--heroui-content2': string; '--heroui-content2': string
'--heroui-content2-foreground': string; '--heroui-content2-foreground': string
'--heroui-content3': string; '--heroui-content3': string
'--heroui-content3-foreground': string; '--heroui-content3-foreground': string
'--heroui-content4': string; '--heroui-content4': string
'--heroui-content4-foreground': string; '--heroui-content4-foreground': string
'--heroui-default-50': string; '--heroui-default-50': string
'--heroui-default-100': string; '--heroui-default-100': string
'--heroui-default-200': string; '--heroui-default-200': string
'--heroui-default-300': string; '--heroui-default-300': string
'--heroui-default-400': string; '--heroui-default-400': string
'--heroui-default-500': string; '--heroui-default-500': string
'--heroui-default-600': string; '--heroui-default-600': string
'--heroui-default-700': string; '--heroui-default-700': string
'--heroui-default-800': string; '--heroui-default-800': string
'--heroui-default-900': string; '--heroui-default-900': string
'--heroui-default-foreground': string; '--heroui-default-foreground': string
'--heroui-default': string; '--heroui-default': string
// 新增 danger // 新增 danger
'--heroui-danger-50': string; '--heroui-danger-50': string
'--heroui-danger-100': string; '--heroui-danger-100': string
'--heroui-danger-200': string; '--heroui-danger-200': string
'--heroui-danger-300': string; '--heroui-danger-300': string
'--heroui-danger-400': string; '--heroui-danger-400': string
'--heroui-danger-500': string; '--heroui-danger-500': string
'--heroui-danger-600': string; '--heroui-danger-600': string
'--heroui-danger-700': string; '--heroui-danger-700': string
'--heroui-danger-800': string; '--heroui-danger-800': string
'--heroui-danger-900': string; '--heroui-danger-900': string
'--heroui-danger-foreground': string; '--heroui-danger-foreground': string
'--heroui-danger': string; '--heroui-danger': string
// 新增 primary // 新增 primary
'--heroui-primary-50': string; '--heroui-primary-50': string
'--heroui-primary-100': string; '--heroui-primary-100': string
'--heroui-primary-200': string; '--heroui-primary-200': string
'--heroui-primary-300': string; '--heroui-primary-300': string
'--heroui-primary-400': string; '--heroui-primary-400': string
'--heroui-primary-500': string; '--heroui-primary-500': string
'--heroui-primary-600': string; '--heroui-primary-600': string
'--heroui-primary-700': string; '--heroui-primary-700': string
'--heroui-primary-800': string; '--heroui-primary-800': string
'--heroui-primary-900': string; '--heroui-primary-900': string
'--heroui-primary-foreground': string; '--heroui-primary-foreground': string
'--heroui-primary': string; '--heroui-primary': string
// 新增 secondary // 新增 secondary
'--heroui-secondary-50': string; '--heroui-secondary-50': string
'--heroui-secondary-100': string; '--heroui-secondary-100': string
'--heroui-secondary-200': string; '--heroui-secondary-200': string
'--heroui-secondary-300': string; '--heroui-secondary-300': string
'--heroui-secondary-400': string; '--heroui-secondary-400': string
'--heroui-secondary-500': string; '--heroui-secondary-500': string
'--heroui-secondary-600': string; '--heroui-secondary-600': string
'--heroui-secondary-700': string; '--heroui-secondary-700': string
'--heroui-secondary-800': string; '--heroui-secondary-800': string
'--heroui-secondary-900': string; '--heroui-secondary-900': string
'--heroui-secondary-foreground': string; '--heroui-secondary-foreground': string
'--heroui-secondary': string; '--heroui-secondary': string
// 新增 success // 新增 success
'--heroui-success-50': string; '--heroui-success-50': string
'--heroui-success-100': string; '--heroui-success-100': string
'--heroui-success-200': string; '--heroui-success-200': string
'--heroui-success-300': string; '--heroui-success-300': string
'--heroui-success-400': string; '--heroui-success-400': string
'--heroui-success-500': string; '--heroui-success-500': string
'--heroui-success-600': string; '--heroui-success-600': string
'--heroui-success-700': string; '--heroui-success-700': string
'--heroui-success-800': string; '--heroui-success-800': string
'--heroui-success-900': string; '--heroui-success-900': string
'--heroui-success-foreground': string; '--heroui-success-foreground': string
'--heroui-success': string; '--heroui-success': string
// 新增 warning // 新增 warning
'--heroui-warning-50': string; '--heroui-warning-50': string
'--heroui-warning-100': string; '--heroui-warning-100': string
'--heroui-warning-200': string; '--heroui-warning-200': string
'--heroui-warning-300': string; '--heroui-warning-300': string
'--heroui-warning-400': string; '--heroui-warning-400': string
'--heroui-warning-500': string; '--heroui-warning-500': string
'--heroui-warning-600': string; '--heroui-warning-600': string
'--heroui-warning-700': string; '--heroui-warning-700': string
'--heroui-warning-800': string; '--heroui-warning-800': string
'--heroui-warning-900': string; '--heroui-warning-900': string
'--heroui-warning-foreground': string; '--heroui-warning-foreground': string
'--heroui-warning': string; '--heroui-warning': string
// 其它配置 // 其它配置
'--heroui-code-background': string; '--heroui-code-background': string
'--heroui-strong': string; '--heroui-strong': string
'--heroui-code-mdx': string; '--heroui-code-mdx': string
'--heroui-divider-weight': string; '--heroui-divider-weight': string
'--heroui-disabled-opacity': string; '--heroui-disabled-opacity': string
'--heroui-font-size-tiny': string; '--heroui-font-size-tiny': string
'--heroui-font-size-small': string; '--heroui-font-size-small': string
'--heroui-font-size-medium': string; '--heroui-font-size-medium': string
'--heroui-font-size-large': string; '--heroui-font-size-large': string
'--heroui-line-height-tiny': string; '--heroui-line-height-tiny': string
'--heroui-line-height-small': string; '--heroui-line-height-small': string
'--heroui-line-height-medium': string; '--heroui-line-height-medium': string
'--heroui-line-height-large': string; '--heroui-line-height-large': string
'--heroui-radius-small': string; '--heroui-radius-small': string
'--heroui-radius-medium': string; '--heroui-radius-medium': string
'--heroui-radius-large': string; '--heroui-radius-large': string
'--heroui-border-width-small': string; '--heroui-border-width-small': string
'--heroui-border-width-medium': string; '--heroui-border-width-medium': string
'--heroui-border-width-large': string; '--heroui-border-width-large': string
'--heroui-box-shadow-small': string; '--heroui-box-shadow-small': string
'--heroui-box-shadow-medium': string; '--heroui-box-shadow-medium': string
'--heroui-box-shadow-large': string; '--heroui-box-shadow-large': string
'--heroui-hover-opacity': string; '--heroui-hover-opacity': string
} }
interface ThemeConfig { interface ThemeConfig {
dark: ThemeConfigItem; dark: ThemeConfigItem
light: ThemeConfigItem; light: ThemeConfigItem
fontMode?: string;
} }
interface WebUIConfig { interface WebUIConfig {
host: string; host: string
port: number; port: number
loginRate: number; loginRate: number
disableWebUI: boolean; disableWebUI: boolean
disableNonLANAccess: 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'); const style = document.createElement('style');
document.head.appendChild(style); document.head.appendChild(style);
// 字体样式标签
const fontStyle = document.createElement('style');
fontStyle.id = 'dynamic-font-style';
document.head.appendChild(fontStyle);
export function loadTheme () { export function loadTheme () {
request('/files/theme.css?_t=' + Date.now()) request('/files/theme.css?_t=' + Date.now())
.then((res) => res.data) .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 = [ export const colorKeys = [
'--heroui-background', '--heroui-background',
@ -167,53 +139,3 @@ export const generateTheme = (theme: ThemeConfig, validField?: string) => {
css += '}'; css += '}';
return 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: { theme: {
extend: { extend: {},
fontFamily: {
mono: [
'ui-monospace',
'SFMono-Regular',
'SF Mono',
'Menlo',
'Consolas',
'Liberation Mono',
'JetBrains Mono',
'monospace',
],
},
},
}, },
darkMode: 'class', darkMode: 'class',
plugins: [ plugins: [
@ -38,32 +25,18 @@ export default {
light: { light: {
colors: { colors: {
primary: { primary: {
DEFAULT: '#FF7FAC', // 樱花粉 DEFAULT: '#f31260',
foreground: '#fff', foreground: '#fff',
50: '#FFF0F5', 50: '#fee7ef',
100: '#FFE4E9', 100: '#fdd0df',
200: '#FFCDD9', 200: '#faa0bf',
300: '#FF9EB5', 300: '#f871a0',
400: '#FF7FAC', 400: '#f54180',
500: '#F33B7C', 500: '#f31260',
600: '#C92462', 600: '#c20e4d',
700: '#991B4B', 700: '#920b3a',
800: '#691233', 800: '#610726',
900: '#380A1B', 900: '#310413',
},
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',
}, },
danger: { danger: {
DEFAULT: '#DB3694', DEFAULT: '#DB3694',

View File

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

File diff suppressed because it is too large Load Diff