mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-12-27 11:21:21 +08:00
Compare commits
No commits in common. "main" and "v4.9.78" have entirely different histories.
@ -43,7 +43,7 @@ _Modern protocol-side framework implemented based on NTQQ._
|
|||||||
|
|
||||||
**首次使用**请务必查看如下文档看使用教程
|
**首次使用**请务必查看如下文档看使用教程
|
||||||
|
|
||||||
> 项目非盈利,涉及 对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
||||||
|
|
||||||
## Link
|
## Link
|
||||||
|
|
||||||
|
|||||||
22
packages/napcat-core/external/appid.json
vendored
22
packages/napcat-core/external/appid.json
vendored
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
26
packages/napcat-core/external/napi2native.json
vendored
26
packages/napcat-core/external/napi2native.json
vendored
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
26
packages/napcat-core/external/packet.json
vendored
26
packages/napcat-core/external/packet.json
vendored
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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' },
|
||||||
|
|||||||
@ -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-9,6 是性能和压缩率的平衡点
|
|
||||||
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);
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@ -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;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
2
packages/napcat-webui-frontend/.gitignore
vendored
2
packages/napcat-webui-frontend/.gitignore
vendored
@ -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
|
||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@ -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);
|
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
425
packages/napcat-webui-frontend/src/components/audio_player.tsx
Normal file
425
packages/napcat-webui-frontend/src/components/audio_player.tsx
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
import { Button } from '@heroui/button';
|
||||||
|
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||||
|
import { Image } from '@heroui/image';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||||
|
import { Slider } from '@heroui/slider';
|
||||||
|
import { Tooltip } from '@heroui/tooltip';
|
||||||
|
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
BiSolidSkipNextCircle,
|
||||||
|
BiSolidSkipPreviousCircle,
|
||||||
|
} from 'react-icons/bi';
|
||||||
|
import {
|
||||||
|
FaPause,
|
||||||
|
FaPlay,
|
||||||
|
FaRegHandPointRight,
|
||||||
|
FaRepeat,
|
||||||
|
FaShuffle,
|
||||||
|
} from 'react-icons/fa6';
|
||||||
|
import { TbRepeatOnce } from 'react-icons/tb';
|
||||||
|
import { useMediaQuery } from 'react-responsive';
|
||||||
|
|
||||||
|
import { PlayMode } from '@/const/enum';
|
||||||
|
import key from '@/const/key';
|
||||||
|
|
||||||
|
import { VolumeHighIcon, VolumeLowIcon } from './icons';
|
||||||
|
|
||||||
|
export interface AudioPlayerProps
|
||||||
|
extends React.AudioHTMLAttributes<HTMLAudioElement> {
|
||||||
|
src: string
|
||||||
|
title?: string
|
||||||
|
artist?: string
|
||||||
|
cover?: string
|
||||||
|
pressNext?: () => void
|
||||||
|
pressPrevious?: () => void
|
||||||
|
onPlayEnd?: () => void
|
||||||
|
onChangeMode?: (mode: PlayMode) => void
|
||||||
|
mode?: PlayMode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AudioPlayer (props: AudioPlayerProps) {
|
||||||
|
const {
|
||||||
|
src,
|
||||||
|
pressNext,
|
||||||
|
pressPrevious,
|
||||||
|
cover = 'https://nextui.org/images/album-cover.png',
|
||||||
|
title = '未知',
|
||||||
|
artist = '未知',
|
||||||
|
onTimeUpdate,
|
||||||
|
onLoadedData,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onPlayEnd,
|
||||||
|
onChangeMode,
|
||||||
|
autoPlay,
|
||||||
|
mode = PlayMode.Loop,
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [volume, setVolume] = useState(100);
|
||||||
|
const [isCollapsed, setIsCollapsed] = useLocalStorage(
|
||||||
|
key.isCollapsedMusicPlayer,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const startY = useRef(0);
|
||||||
|
const startX = useRef(0);
|
||||||
|
const [translateY, setTranslateY] = useState(0);
|
||||||
|
const [translateX, setTranslateX] = useState(0);
|
||||||
|
const isSmallScreen = useMediaQuery({ maxWidth: 767 });
|
||||||
|
const isMediumUp = useMediaQuery({ minWidth: 768 });
|
||||||
|
const shouldAdd = useRef(false);
|
||||||
|
const currentProgress = (currentTime / duration) * 100;
|
||||||
|
const [storageAutoPlay, setStorageAutoPlay] = useLocalStorage(
|
||||||
|
key.autoPlay,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTimeUpdate = (event: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||||
|
const audio = event.target as HTMLAudioElement;
|
||||||
|
setCurrentTime(audio.currentTime);
|
||||||
|
onTimeUpdate?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedData = (event: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||||
|
const audio = event.target as HTMLAudioElement;
|
||||||
|
setDuration(audio.duration);
|
||||||
|
onLoadedData?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = (e: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
setStorageAutoPlay(true);
|
||||||
|
onPlay?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = (e: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
onPause?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeMode = () => {
|
||||||
|
const modes = [PlayMode.Loop, PlayMode.Random, PlayMode.Single];
|
||||||
|
const currentIndex = modes.findIndex((_mode) => _mode === mode);
|
||||||
|
const nextIndex = currentIndex + 1;
|
||||||
|
const nextMode = modes[nextIndex] || modes[0];
|
||||||
|
onChangeMode?.(nextMode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const volumeChange = (value: number) => {
|
||||||
|
setVolume(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (audio) {
|
||||||
|
audio.volume = volume / 100;
|
||||||
|
}
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
startY.current = e.touches[0].clientY;
|
||||||
|
startX.current = e.touches[0].clientX;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
|
const deltaY = e.touches[0].clientY - startY.current;
|
||||||
|
const deltaX = e.touches[0].clientX - startX.current;
|
||||||
|
const container = cardRef.current;
|
||||||
|
const header = cardRef.current?.querySelector('[data-header]');
|
||||||
|
const headerHeight = header?.clientHeight || 20;
|
||||||
|
const addHeight = (container?.clientHeight || headerHeight) - headerHeight;
|
||||||
|
const _shouldAdd = isCollapsed && deltaY < 0;
|
||||||
|
if (isSmallScreen) {
|
||||||
|
shouldAdd.current = _shouldAdd;
|
||||||
|
setTranslateY(_shouldAdd ? deltaY + addHeight : deltaY);
|
||||||
|
} else {
|
||||||
|
setTranslateX(deltaX);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (isSmallScreen) {
|
||||||
|
const container = cardRef.current;
|
||||||
|
const header = cardRef.current?.querySelector('[data-header]');
|
||||||
|
const headerHeight = header?.clientHeight || 20;
|
||||||
|
const addHeight = (container?.clientHeight || headerHeight) - headerHeight;
|
||||||
|
const _translateY = translateY - (shouldAdd.current ? addHeight : 0);
|
||||||
|
if (_translateY > 100) {
|
||||||
|
setIsCollapsed(true);
|
||||||
|
} else if (_translateY < -100) {
|
||||||
|
setIsCollapsed(false);
|
||||||
|
}
|
||||||
|
setTranslateY(0);
|
||||||
|
} else {
|
||||||
|
if (translateX > 100) {
|
||||||
|
setIsCollapsed(true);
|
||||||
|
} else if (translateX < -100) {
|
||||||
|
setIsCollapsed(false);
|
||||||
|
}
|
||||||
|
setTranslateX(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dragTranslate = isSmallScreen
|
||||||
|
? translateY
|
||||||
|
? `translateY(${translateY}px)`
|
||||||
|
: ''
|
||||||
|
: translateX
|
||||||
|
? `translateX(${translateX}px)`
|
||||||
|
: '';
|
||||||
|
const collapsedTranslate = isCollapsed
|
||||||
|
? isSmallScreen
|
||||||
|
? 'translateY(90%)'
|
||||||
|
: 'translateX(96%)'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const translateStyle = dragTranslate || collapsedTranslate;
|
||||||
|
|
||||||
|
if (!src) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'fixed right-0 bottom-0 z-[52] w-full md:w-96',
|
||||||
|
!translateX && !translateY && 'transition-transform',
|
||||||
|
isCollapsed && 'md:hover:!translate-x-80'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: translateStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<audio
|
||||||
|
src={src}
|
||||||
|
onLoadedData={handleLoadedData}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onPlay={handlePlay}
|
||||||
|
onPause={handlePause}
|
||||||
|
onEnded={onPlayEnd}
|
||||||
|
autoPlay={autoPlay ?? storageAutoPlay}
|
||||||
|
{...rest}
|
||||||
|
controls={false}
|
||||||
|
hidden
|
||||||
|
ref={audioRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
ref={cardRef}
|
||||||
|
className={clsx(
|
||||||
|
'border-none bg-background/60 dark:bg-default-300/50 w-full max-w-full transform transition-transform backdrop-blur-md duration-300 overflow-visible',
|
||||||
|
isSmallScreen ? 'rounded-t-3xl' : 'md:rounded-l-xl'
|
||||||
|
)}
|
||||||
|
classNames={{
|
||||||
|
body: 'p-0',
|
||||||
|
}}
|
||||||
|
shadow='sm'
|
||||||
|
radius='none'
|
||||||
|
>
|
||||||
|
{isMediumUp && (
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className={clsx(
|
||||||
|
'absolute data-[hover]:bg-foreground/10 text-lg z-50',
|
||||||
|
isCollapsed
|
||||||
|
? 'top-0 left-0 w-full h-full rounded-xl bg-opacity-0 hover:bg-opacity-30'
|
||||||
|
: 'top-3 -left-8 rounded-l-full bg-opacity-50 backdrop-blur-md'
|
||||||
|
)}
|
||||||
|
variant='solid'
|
||||||
|
color='primary'
|
||||||
|
size='sm'
|
||||||
|
onPress={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
>
|
||||||
|
<FaRegHandPointRight />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isSmallScreen && (
|
||||||
|
<CardHeader
|
||||||
|
data-header
|
||||||
|
className='flex-row justify-center pt-4'
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
>
|
||||||
|
<div className='w-24 h-2 rounded-full bg-content2-foreground shadow-sm' />
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardBody>
|
||||||
|
<div className='grid grid-cols-6 md:grid-cols-12 gap-6 md:gap-4 items-center justify-center overflow-hidden p-6 md:p-2 m-0'>
|
||||||
|
<div className='relative col-span-6 md:col-span-4 flex justify-center'>
|
||||||
|
<Image
|
||||||
|
alt='Album cover'
|
||||||
|
className='object-cover'
|
||||||
|
classNames={{
|
||||||
|
wrapper: 'w-36 aspect-square md:w-24 flex',
|
||||||
|
img: 'block w-full h-full',
|
||||||
|
}}
|
||||||
|
shadow='md'
|
||||||
|
src={cover}
|
||||||
|
width='100%'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col col-span-6 md:col-span-8'>
|
||||||
|
<div className='flex flex-col gap-0'>
|
||||||
|
<h1 className='font-medium truncate'>{title}</h1>
|
||||||
|
<p className='text-xs text-foreground/80 truncate'>{artist}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
<Slider
|
||||||
|
aria-label='Music progress'
|
||||||
|
classNames={{
|
||||||
|
track: 'bg-default-500/30 border-none',
|
||||||
|
thumb: 'w-2 h-2 after:w-1.5 after:h-1.5',
|
||||||
|
filler: 'rounded-full',
|
||||||
|
}}
|
||||||
|
color='foreground'
|
||||||
|
value={currentProgress || 0}
|
||||||
|
defaultValue={0}
|
||||||
|
size='sm'
|
||||||
|
onChange={(value) => {
|
||||||
|
value = Array.isArray(value) ? value[0] : value;
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (audio) {
|
||||||
|
audio.currentTime = (value / 100) * duration;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='flex justify-between h-3'>
|
||||||
|
<p className='text-xs'>
|
||||||
|
{Math.floor(currentTime / 60)}:
|
||||||
|
{Math.floor(currentTime % 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}
|
||||||
|
</p>
|
||||||
|
<p className='text-xs text-foreground/50'>
|
||||||
|
{Math.floor(duration / 60)}:
|
||||||
|
{Math.floor(duration % 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex w-full items-center justify-center'>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
mode === PlayMode.Loop
|
||||||
|
? '列表循环'
|
||||||
|
: mode === PlayMode.Random
|
||||||
|
? '随机播放'
|
||||||
|
: '单曲循环'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className='data-[hover]:bg-foreground/10 text-lg md:text-medium'
|
||||||
|
radius='full'
|
||||||
|
variant='light'
|
||||||
|
size='md'
|
||||||
|
onPress={changeMode}
|
||||||
|
>
|
||||||
|
{mode === PlayMode.Loop && (
|
||||||
|
<FaRepeat className='text-foreground/80' />
|
||||||
|
)}
|
||||||
|
{mode === PlayMode.Random && (
|
||||||
|
<FaShuffle className='text-foreground/80' />
|
||||||
|
)}
|
||||||
|
{mode === PlayMode.Single && (
|
||||||
|
<TbRepeatOnce className='text-foreground/80 text-xl' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content='上一首'>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
|
||||||
|
radius='full'
|
||||||
|
variant='light'
|
||||||
|
size='md'
|
||||||
|
onPress={pressPrevious}
|
||||||
|
>
|
||||||
|
<BiSolidSkipPreviousCircle />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content={isPlaying ? '暂停' : '播放'}>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className='data-[hover]:bg-foreground/10 text-3xl md:text-3xl'
|
||||||
|
radius='full'
|
||||||
|
variant='light'
|
||||||
|
size='lg'
|
||||||
|
onPress={() => {
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current?.pause();
|
||||||
|
setStorageAutoPlay(false);
|
||||||
|
} else {
|
||||||
|
audioRef.current?.play();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPlaying ? <FaPause /> : <FaPlay className='ml-1' />}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content='下一首'>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
|
||||||
|
radius='full'
|
||||||
|
variant='light'
|
||||||
|
size='md'
|
||||||
|
onPress={pressNext}
|
||||||
|
>
|
||||||
|
<BiSolidSkipNextCircle />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Popover
|
||||||
|
placement='top'
|
||||||
|
classNames={{
|
||||||
|
content: 'bg-opacity-30 backdrop-blur-md',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className='data-[hover]:bg-foreground/10 text-xl md:text-xl'
|
||||||
|
radius='full'
|
||||||
|
variant='light'
|
||||||
|
size='md'
|
||||||
|
>
|
||||||
|
<VolumeHighIcon />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent>
|
||||||
|
<Slider
|
||||||
|
orientation='vertical'
|
||||||
|
showTooltip
|
||||||
|
aria-label='Volume'
|
||||||
|
className='h-40'
|
||||||
|
color='primary'
|
||||||
|
defaultValue={volume}
|
||||||
|
onChange={(value) => {
|
||||||
|
value = Array.isArray(value) ? value[0] : value;
|
||||||
|
volumeChange(value);
|
||||||
|
}}
|
||||||
|
startContent={<VolumeHighIcon className='text-2xl' />}
|
||||||
|
size='sm'
|
||||||
|
endContent={<VolumeLowIcon className='text-2xl' />}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -18,7 +18,7 @@ import {
|
|||||||
} from '../icons';
|
} 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']);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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> = (
|
||||||
|
|||||||
@ -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> = (
|
||||||
|
|||||||
@ -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> = (
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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={{
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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} />)
|
||||||
|
|||||||
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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) : '请求响应'}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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[],
|
||||||
|
|||||||
91
packages/napcat-webui-frontend/src/contexts/songs.tsx
Normal file
91
packages/napcat-webui-frontend/src/contexts/songs.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
// Songs Context
|
||||||
|
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||||
|
import { createContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { PlayMode } from '@/const/enum';
|
||||||
|
import key from '@/const/key';
|
||||||
|
|
||||||
|
import AudioPlayer from '@/components/audio_player';
|
||||||
|
|
||||||
|
import { get163MusicListSongs, getNextMusic } from '@/utils/music';
|
||||||
|
|
||||||
|
import type { FinalMusic } from '@/types/music';
|
||||||
|
|
||||||
|
export interface MusicContextProps {
|
||||||
|
setListId: (id: string) => void
|
||||||
|
listId: string
|
||||||
|
onNext: () => void
|
||||||
|
onPrevious: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicProviderProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AudioContext = createContext<MusicContextProps>({
|
||||||
|
setListId: () => {},
|
||||||
|
listId: '5438670983',
|
||||||
|
onNext: () => {},
|
||||||
|
onPrevious: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const AudioProvider: React.FC<MusicProviderProps> = ({ children }) => {
|
||||||
|
const [listId, setListId] = useLocalStorage(key.musicID, '5438670983');
|
||||||
|
const [musicList, setMusicList] = useState<FinalMusic[]>([]);
|
||||||
|
const [musicId, setMusicId] = useState<number>(0);
|
||||||
|
const [playMode, setPlayMode] = useState<PlayMode>(PlayMode.Loop);
|
||||||
|
const music = musicList.find((music) => music.id === musicId);
|
||||||
|
const [token] = useLocalStorage(key.token, '');
|
||||||
|
const onNext = () => {
|
||||||
|
const nextID = getNextMusic(musicList, musicId, playMode);
|
||||||
|
setMusicId(nextID);
|
||||||
|
};
|
||||||
|
const onPrevious = () => {
|
||||||
|
const index = musicList.findIndex((music) => music.id === musicId);
|
||||||
|
if (index === 0) {
|
||||||
|
setMusicId(musicList[musicList.length - 1].id);
|
||||||
|
} else {
|
||||||
|
setMusicId(musicList[index - 1].id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onPlayEnd = () => {
|
||||||
|
const nextID = getNextMusic(musicList, musicId, playMode);
|
||||||
|
setMusicId(nextID);
|
||||||
|
};
|
||||||
|
const changeMode = (mode: PlayMode) => {
|
||||||
|
setPlayMode(mode);
|
||||||
|
};
|
||||||
|
const fetchMusicList = async (id: string) => {
|
||||||
|
const res = await get163MusicListSongs(id);
|
||||||
|
setMusicList(res);
|
||||||
|
setMusicId(res[0].id);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (listId && token) fetchMusicList(listId);
|
||||||
|
}, [listId, token]);
|
||||||
|
return (
|
||||||
|
<AudioContext.Provider
|
||||||
|
value={{
|
||||||
|
setListId,
|
||||||
|
listId,
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AudioPlayer
|
||||||
|
title={music?.title}
|
||||||
|
src={music?.url || ''}
|
||||||
|
artist={music?.artist}
|
||||||
|
cover={music?.cover}
|
||||||
|
mode={playMode}
|
||||||
|
pressNext={onNext}
|
||||||
|
pressPrevious={onPrevious}
|
||||||
|
onPlayEnd={onPlayEnd}
|
||||||
|
onChangeMode={changeMode}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</AudioContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AudioProvider;
|
||||||
11
packages/napcat-webui-frontend/src/hooks/use-music.ts
Normal file
11
packages/napcat-webui-frontend/src/hooks/use-music.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { AudioContext } from '@/contexts/songs';
|
||||||
|
|
||||||
|
const useMusic = () => {
|
||||||
|
const music = React.useContext(AudioContext);
|
||||||
|
|
||||||
|
return music;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useMusic;
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Selection } from '@react-types/shared';
|
import 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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
33
packages/napcat-webui-frontend/src/monaco.ts
Normal file
33
packages/napcat-webui-frontend/src/monaco.ts
Normal 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;
|
||||||
@ -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">跳转 →</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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后,您可以更便捷地登录WebUI,无需每次输入token
|
注册Passkey后,您可以更便捷地登录WebUI,无需每次输入token
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 中不再包含 Token,Token 单独放入输入框
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/api/Debug/ws`;
|
|
||||||
|
|
||||||
setSocketConfig({
|
|
||||||
url: wsUrl,
|
|
||||||
token: token
|
|
||||||
});
|
|
||||||
setInputUrl(wsUrl);
|
|
||||||
setInputToken(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create debug adapter:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initAdapter();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const 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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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='实时日志'>
|
||||||
|
|||||||
@ -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) => (
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
317
packages/napcat-webui-frontend/src/types/server.d.ts
vendored
317
packages/napcat-webui-frontend/src/types/server.d.ts
vendored
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
122
packages/napcat-webui-frontend/src/utils/music.ts
Normal file
122
packages/napcat-webui-frontend/src/utils/music.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { PlayMode } from '@/const/enum';
|
||||||
|
|
||||||
|
import WebUIManager from '@/controllers/webui_manager';
|
||||||
|
import type {
|
||||||
|
FinalMusic,
|
||||||
|
Music163ListResponse,
|
||||||
|
Music163URLResponse,
|
||||||
|
} from '@/types/music';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取网易云音乐歌单
|
||||||
|
* @param id 歌单id
|
||||||
|
* @returns 歌单信息
|
||||||
|
*/
|
||||||
|
export const get163MusicList = async (id: string) => {
|
||||||
|
const res = await WebUIManager.proxy<Music163ListResponse>(
|
||||||
|
'https://wavesgame.top/playlist/track/all?id=' + id
|
||||||
|
);
|
||||||
|
// const res = await request.get<Music163ListResponse>(
|
||||||
|
// `https://wavesgame.top/playlist/track/all?id=${id}`
|
||||||
|
// )
|
||||||
|
if (res?.data?.code !== 200) {
|
||||||
|
throw new Error('获取歌曲列表失败');
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取歌曲地址
|
||||||
|
* @param ids 歌曲id
|
||||||
|
* @returns 歌曲地址
|
||||||
|
*/
|
||||||
|
export const getSongsURL = async (ids: number[]) => {
|
||||||
|
const _ids = ids.reduce((prev, cur, index) => {
|
||||||
|
const groupIndex = Math.floor(index / 10);
|
||||||
|
if (!prev[groupIndex]) {
|
||||||
|
prev[groupIndex] = [];
|
||||||
|
}
|
||||||
|
prev[groupIndex].push(cur);
|
||||||
|
return prev;
|
||||||
|
}, [] as number[][]);
|
||||||
|
const res = await Promise.all(
|
||||||
|
_ids.map(async (id) => {
|
||||||
|
const res = await WebUIManager.proxy<Music163URLResponse>(
|
||||||
|
`https://wavesgame.top/song/url?id=${id.join(',')}`
|
||||||
|
);
|
||||||
|
if (res?.data?.code !== 200) {
|
||||||
|
throw new Error('获取歌曲地址失败');
|
||||||
|
}
|
||||||
|
return res.data.data;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const result = res.reduce((prev, cur) => {
|
||||||
|
return prev.concat(...cur);
|
||||||
|
}, []);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取网易云音乐歌单歌曲
|
||||||
|
* @param id 歌单id
|
||||||
|
* @returns 歌曲信息
|
||||||
|
*/
|
||||||
|
export const get163MusicListSongs = async (id: string) => {
|
||||||
|
const listRes = await get163MusicList(id);
|
||||||
|
const songs = listRes.songs.map((song) => song.id);
|
||||||
|
const songsRes = await getSongsURL(songs);
|
||||||
|
const finalMusic: FinalMusic[] = [];
|
||||||
|
for (let i = 0; i < listRes.songs.length; i++) {
|
||||||
|
const song = listRes.songs[i];
|
||||||
|
const music = songsRes.find((s) => s.id === song.id);
|
||||||
|
const songURL = music?.url;
|
||||||
|
if (songURL) {
|
||||||
|
finalMusic.push({
|
||||||
|
id: song.id,
|
||||||
|
url: songURL.replace(/http:\/\//, '//').replace(/https:\/\//, '//'),
|
||||||
|
title: song.name,
|
||||||
|
artist: song.ar.map((p) => p.name).join('/'),
|
||||||
|
cover: song.al.picUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finalMusic;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取随机音乐
|
||||||
|
* @param ids 歌曲id
|
||||||
|
* @param currentId 当前音乐id
|
||||||
|
* @returns 随机音乐id
|
||||||
|
*/
|
||||||
|
export const getRandomMusic = (ids: number[], currentId: number): number => {
|
||||||
|
const randomIndex = Math.floor(Math.random() * ids.length);
|
||||||
|
const randomId = ids[randomIndex];
|
||||||
|
if (randomId === currentId) {
|
||||||
|
return getRandomMusic(ids, currentId);
|
||||||
|
}
|
||||||
|
return randomId;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取下一首音乐id
|
||||||
|
* @param ids 歌曲id
|
||||||
|
* @param currentId 当前音乐ID
|
||||||
|
* @param mode 播放模式
|
||||||
|
*/
|
||||||
|
export const getNextMusic = (
|
||||||
|
musics: FinalMusic[],
|
||||||
|
currentId: number,
|
||||||
|
mode: PlayMode
|
||||||
|
): number => {
|
||||||
|
const ids = musics.map((music) => music.id);
|
||||||
|
if (mode === PlayMode.Loop) {
|
||||||
|
const currentIndex = ids.findIndex((id) => id === currentId);
|
||||||
|
const nextIndex = currentIndex + 1;
|
||||||
|
return ids[nextIndex] || ids[0];
|
||||||
|
}
|
||||||
|
if (mode === PlayMode.Random) {
|
||||||
|
return getRandomMusic(ids, currentId);
|
||||||
|
}
|
||||||
|
return currentId;
|
||||||
|
};
|
||||||
@ -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);
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
14184
pnpm-lock.yaml
14184
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user