mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
feat: webui体验优化
This commit is contained in:
@@ -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}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
Reference in New Issue
Block a user