feat: webui体验优化

This commit is contained in:
手瓜一十雪
2025-02-07 13:56:48 +08:00
parent aa3a575cbe
commit f2fdcc9289
2 changed files with 131 additions and 168 deletions

View File

@@ -26,12 +26,25 @@ const server = createServer(app);
*/ */
export let WebUiConfig: WebUiConfigWrapper; export let WebUiConfig: WebUiConfigWrapper;
export let webUiPathWrapper: NapCatPathWrapper; export let webUiPathWrapper: NapCatPathWrapper;
const MAX_PORT_TRY = 100;
import * as net from 'node:net';
export async function InitPort(parsedConfig: WebUiConfigType): Promise<[string, number, string]> {
try {
await tryUseHost(parsedConfig.host);
const port = await tryUsePort(parsedConfig.port, parsedConfig.host);
return [parsedConfig.host, port, parsedConfig.token];
} catch (error) {
console.log('host或port不可用', error);
return ['', 0, ''];
}
}
export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapper) { export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapper) {
webUiPathWrapper = pathWrapper; webUiPathWrapper = pathWrapper;
WebUiConfig = new WebUiConfigWrapper(); WebUiConfig = new WebUiConfigWrapper();
const config = await WebUiConfig.GetWebUIConfig(); const [host, port, token] = await InitPort(await WebUiConfig.GetWebUIConfig());
if (config.port == 0) { if (port == 0) {
logger.log('[NapCat] [WebUi] Current WebUi is not run.'); logger.log('[NapCat] [WebUi] Current WebUi is not run.');
return; return;
} }
@@ -74,7 +87,7 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
// 初始服务(先放个首页) // 初始服务(先放个首页)
app.all('/', (_req, res) => { app.all('/', (_req, res) => {
sendSuccess(res, null, 'NapCat WebAPI is now running!'); res.status(301).header('Location', '/webui').send();
}); });
// 错误处理中间件捕获multer的错误 // 错误处理中间件捕获multer的错误
@@ -91,16 +104,72 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
}); });
// ------------启动服务------------ // ------------启动服务------------
server.listen(config.port, config.host, async () => { server.listen(port, host, async () => {
// 启动后打印出相关地址 // 启动后打印出相关地址
const port = config.port.toString(), let searchParams = { token: token };
searchParams = { token: config.token }; if (host !== '' && host !== '0.0.0.0') {
if (config.host !== '' && config.host !== '0.0.0.0') {
logger.log( logger.log(
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl(config.host, port, '/webui', searchParams)}` `[NapCat] [WebUi] WebUi User Panel Url: ${createUrl(host, port.toString(), '/webui', searchParams)}`
); );
} }
logger.log(`[NapCat] [WebUi] WebUi Local Panel Url: ${createUrl('127.0.0.1', port, '/webui', searchParams)}`); logger.log(`[NapCat] [WebUi] WebUi Local Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`);
}); });
// ------------Over------------ // ------------Over------------
} }
async function tryUseHost(host: string): Promise<string> {
return new Promise((resolve, reject) => {
try {
const server = net.createServer();
server.on('listening', () => {
server.close();
resolve(host);
});
server.on('error', (err: any) => {
if (err.code === 'EADDRNOTAVAIL') {
reject(new Error('主机地址验证失败,可能为非本机地址'));
} else {
reject(new Error(`遇到错误: ${err.code}`));
}
});
// 尝试监听 让系统随机分配一个端口
server.listen(0, host);
} catch (error) {
// 这里捕获到的错误应该是启动服务器时的同步错误
reject(new Error(`服务器启动时发生错误: ${error}`));
}
});
}
async function tryUsePort(port: number, host: string, tryCount: number = 0): Promise<number> {
return new Promise((resolve, reject) => {
try {
const server = net.createServer();
server.on('listening', () => {
server.close();
resolve(port);
});
server.on('error', (err: any) => {
if (err.code === 'EADDRINUSE') {
if (tryCount < MAX_PORT_TRY) {
// 使用循环代替递归
resolve(tryUsePort(port + 1, host, tryCount + 1));
} else {
reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`));
}
} else {
reject(new Error(`遇到错误: ${err.code}`));
}
});
// 尝试监听端口
server.listen(port, host);
} catch (error) {
// 这里捕获到的错误应该是启动服务器时的同步错误
reject(new Error(`服务器启动时发生错误: ${error}`));
}
});
}

View File

@@ -1,167 +1,75 @@
import { webUiPathWrapper } from '@/webui'; import { webUiPathWrapper } from '@/webui';
import { Type, Static } from '@sinclair/typebox';
import Ajv from 'ajv';
import fs, { constants } from 'node:fs/promises'; import fs, { constants } from 'node:fs/promises';
import * as net from 'node:net';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
// 限制尝试端口的次数,避免死循环 // 限制尝试端口的次数,避免死循环
const MAX_PORT_TRY = 100;
async function tryUseHost(host: string): Promise<string> { // 定义配置的类型
return new Promise((resolve, reject) => { const WebUiConfigSchema = Type.Object({
try { host: Type.String({ default: '0.0.0.0' }),
const server = net.createServer(); port: Type.Number({ default: 6099 }),
server.on('listening', () => { token: Type.String({ default: 'napcat' }),
server.close(); loginRate: Type.Number({ default: 10 }),
resolve(host); });
});
server.on('error', (err: any) => { export type WebUiConfigType = Static<typeof WebUiConfigSchema>;
if (err.code === 'EADDRNOTAVAIL') {
reject(new Error('主机地址验证失败,可能为非本机地址'));
} else {
reject(new Error(`遇到错误: ${err.code}`));
}
});
// 尝试监听 让系统随机分配一个端口
server.listen(0, host);
} catch (error) {
// 这里捕获到的错误应该是启动服务器时的同步错误
reject(new Error(`服务器启动时发生错误: ${error}`));
}
});
}
async function tryUsePort(port: number, host: string, tryCount: number = 0): Promise<number> {
return new Promise((resolve, reject) => {
try {
const server = net.createServer();
server.on('listening', () => {
server.close();
resolve(port);
});
server.on('error', (err: any) => {
if (err.code === 'EADDRINUSE') {
if (tryCount < MAX_PORT_TRY) {
// 使用循环代替递归
resolve(tryUsePort(port + 1, host, tryCount + 1));
} else {
reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`));
}
} else {
reject(new Error(`遇到错误: ${err.code}`));
}
});
// 尝试监听端口
server.listen(port, host);
} catch (error) {
// 这里捕获到的错误应该是启动服务器时的同步错误
reject(new Error(`服务器启动时发生错误: ${error}`));
}
});
}
// 读取当前目录下名为 webui.json 的配置文件,如果不存在则创建初始化配置文件 // 读取当前目录下名为 webui.json 的配置文件,如果不存在则创建初始化配置文件
export class WebUiConfigWrapper { export class WebUiConfigWrapper {
WebUiConfigData: WebUiConfigType | undefined = undefined; WebUiConfigData: WebUiConfigType | undefined = undefined;
private applyDefaults<T>(obj: Partial<T>, defaults: T): T { private validateAndApplyDefaults(config: Partial<WebUiConfigType>): WebUiConfigType {
const result = { ...defaults } as T; new Ajv({ coerceTypes: true, useDefaults: true }).compile(WebUiConfigSchema)(config);
for (const key in obj) { return config as WebUiConfigType;
if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) { }
result[key] = this.applyDefaults(obj[key], defaults[key]);
} else if (obj[key] !== undefined) { private async ensureConfigFileExists(configPath: string): Promise<void> {
result[key] = obj[key] as T[Extract<keyof T, string>]; const configExists = await fs.access(configPath, constants.F_OK).then(() => true).catch(() => false);
} if (!configExists) {
await fs.writeFile(configPath, JSON.stringify(this.validateAndApplyDefaults({}), null, 4));
}
}
private async readAndValidateConfig(configPath: string): Promise<WebUiConfigType> {
const fileContent = await fs.readFile(configPath, 'utf-8');
return this.validateAndApplyDefaults(JSON.parse(fileContent));
}
private async writeConfig(configPath: string, config: WebUiConfigType): Promise<void> {
const hasWritePermission = await fs.access(configPath, constants.W_OK).then(() => true).catch(() => false);
if (hasWritePermission) {
await fs.writeFile(configPath, JSON.stringify(config, null, 4));
} else {
console.warn(`文件: ${configPath} 没有写入权限, 配置的更改部分可能会在重启后还原.`);
} }
return result;
} }
async GetWebUIConfig(): Promise<WebUiConfigType> { async GetWebUIConfig(): Promise<WebUiConfigType> {
if (this.WebUiConfigData) { if (this.WebUiConfigData) {
return this.WebUiConfigData; return this.WebUiConfigData;
} }
const defaultconfig: WebUiConfigType = {
host: '0.0.0.0',
port: 6099,
token: '', // 默认先填空,空密码无法登录
loginRate: 3,
};
try {
defaultconfig.token = Math.random().toString(36).slice(2); //生成随机密码
} catch (e) {
console.log('随机密码生成失败', e);
}
try { try {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json'); const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
await this.ensureConfigFileExists(configPath);
if ( const parsedConfig = await this.readAndValidateConfig(configPath);
!(await fs
.access(configPath, constants.F_OK)
.then(() => true)
.catch(() => false))
) {
await fs.writeFile(configPath, JSON.stringify(defaultconfig, null, 4));
}
const fileContent = await fs.readFile(configPath, 'utf-8');
const parsedConfig = this.applyDefaults(JSON.parse(fileContent) as Partial<WebUiConfigType>, defaultconfig);
if (
await fs
.access(configPath, constants.W_OK)
.then(() => true)
.catch(() => false)
) {
await fs.writeFile(configPath, JSON.stringify(parsedConfig, null, 4));
} else {
console.warn(`文件: ${configPath} 没有写入权限, 配置的更改部分可能会在重启后还原.`);
}
const [host_err, host] = await tryUseHost(parsedConfig.host)
.then((data) => [null, data])
.catch((err) => [err, null]);
if (host_err) {
console.log('host不可用', host_err);
parsedConfig.port = 0; // 设置为0禁用WebUI
} else {
parsedConfig.host = host;
const [port_err, port] = await tryUsePort(parsedConfig.port, parsedConfig.host)
.then((data) => [null, data])
.catch((err) => [err, null]);
if (port_err) {
console.log('port不可用', port_err);
parsedConfig.port = 0; // 设置为0禁用WebUI
} else {
parsedConfig.port = port;
}
}
this.WebUiConfigData = parsedConfig; this.WebUiConfigData = parsedConfig;
return this.WebUiConfigData; return this.WebUiConfigData;
} catch (e) { } catch (e) {
console.log('读取配置文件失败', e); console.log('读取配置文件失败', e);
return this.validateAndApplyDefaults({});
} }
return defaultconfig; // 理论上这行代码到不了,到了只能返回默认配置了
} }
async UpdateWebUIConfig(newConfig: Partial<WebUiConfigType>): Promise<void> { async UpdateWebUIConfig(newConfig: Partial<WebUiConfigType>): Promise<void> {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json'); const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
const currentConfig = await this.GetWebUIConfig(); const currentConfig = await this.GetWebUIConfig();
const updatedConfig = this.applyDefaults(newConfig, currentConfig); const updatedConfig = this.validateAndApplyDefaults({ ...currentConfig, ...newConfig });
await this.writeConfig(configPath, updatedConfig);
if ( this.WebUiConfigData = updatedConfig;
await fs
.access(configPath, constants.W_OK)
.then(() => true)
.catch(() => false)
) {
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 4));
this.WebUiConfigData = updatedConfig;
} else {
console.warn(`文件: ${configPath} 没有写入权限, 配置的更改部分可能会在重启后还原.`);
}
} }
async UpdateToken(oldToken: string, newToken: string): Promise<void> { async UpdateToken(oldToken: string, newToken: string): Promise<void> {
@@ -176,29 +84,22 @@ export class WebUiConfigWrapper {
public static async GetLogsPath(): Promise<string> { public static async GetLogsPath(): Promise<string> {
return resolve(webUiPathWrapper.logsPath); return resolve(webUiPathWrapper.logsPath);
} }
// 获取日志列表 // 获取日志列表
public static async GetLogsList(): Promise<string[]> { public static async GetLogsList(): Promise<string[]> {
if ( const logsPath = resolve(webUiPathWrapper.logsPath);
await fs const logsExist = await fs.access(logsPath, constants.F_OK).then(() => true).catch(() => false);
.access(webUiPathWrapper.logsPath, constants.F_OK) if (logsExist) {
.then(() => true) return (await fs.readdir(logsPath)).filter(file => file.endsWith('.log')).map(file => file.replace('.log', ''));
.catch(() => false)
) {
return (await fs.readdir(webUiPathWrapper.logsPath))
.filter((file) => file.endsWith('.log'))
.map((file) => file.replace('.log', ''));
} }
return []; return [];
} }
// 获取指定日志文件内容 // 获取指定日志文件内容
public static async GetLogContent(filename: string): Promise<string> { public static async GetLogContent(filename: string): Promise<string> {
const logPath = resolve(webUiPathWrapper.logsPath, `${filename}.log`); const logPath = resolve(webUiPathWrapper.logsPath, `${filename}.log`);
if ( const logExists = await fs.access(logPath, constants.R_OK).then(() => true).catch(() => false);
await fs if (logExists) {
.access(logPath, constants.R_OK)
.then(() => true)
.catch(() => false)
) {
return await fs.readFile(logPath, 'utf-8'); return await fs.readFile(logPath, 'utf-8');
} }
return ''; return '';
@@ -207,13 +108,9 @@ export class WebUiConfigWrapper {
// 获取字体文件夹内的字体列表 // 获取字体文件夹内的字体列表
public static async GetFontList(): Promise<string[]> { public static async GetFontList(): Promise<string[]> {
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts'); const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
if ( const fontsExist = await fs.access(fontsPath, constants.F_OK).then(() => true).catch(() => false);
await fs if (fontsExist) {
.access(fontsPath, constants.F_OK) return (await fs.readdir(fontsPath)).filter(file => file.endsWith('.ttf'));
.then(() => true)
.catch(() => false)
) {
return (await fs.readdir(fontsPath)).filter((file) => file.endsWith('.ttf'));
} }
return []; return [];
} }
@@ -221,14 +118,11 @@ export class WebUiConfigWrapper {
// 判断字体是否存在webui.woff // 判断字体是否存在webui.woff
public static async CheckWebUIFontExist(): Promise<boolean> { public static async CheckWebUIFontExist(): Promise<boolean> {
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts'); const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
return await fs return await fs.access(resolve(fontsPath, './webui.woff'), constants.F_OK).then(() => true).catch(() => false);
.access(resolve(fontsPath, './webui.woff'), constants.F_OK)
.then(() => true)
.catch(() => false);
} }
// 获取webui字体文件路径 // 获取webui字体文件路径
public static GetWebUIFontPath(): string { public static GetWebUIFontPath(): string {
return resolve(webUiPathWrapper.configPath, './fonts/webui.woff'); return resolve(webUiPathWrapper.configPath, './fonts/webui.woff');
} }
} }