mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-10 23:10:26 +00:00
Compare commits
13 Commits
v4.9.90
...
style-webu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bdacc4d41 | ||
|
|
cd495fc7a0 | ||
|
|
656279d74b | ||
|
|
377c780d1a | ||
|
|
aefa8985b1 | ||
|
|
b034940dfd | ||
|
|
cb8e10cc7e | ||
|
|
afed164ba1 | ||
|
|
a34a86288b | ||
|
|
50bcd71144 | ||
|
|
fa3a229827 | ||
|
|
e56b912bbd | ||
|
|
da0dd01460 |
@@ -126,7 +126,7 @@ export class NapCatCore {
|
||||
container.bind(TypedEventEmitter).toConstantValue(this.event);
|
||||
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
|
||||
container.bind(ServiceClass).toSelf();
|
||||
console.log(`Registering service handler for: ${serviceName}`);
|
||||
//console.log(`Registering service handler for: ${serviceName}`);
|
||||
this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => {
|
||||
const serviceInstance = container.get(ServiceClass);
|
||||
return serviceInstance.handler(seq, hex_data);
|
||||
|
||||
@@ -14,7 +14,7 @@ export class PacketMsgBuilder {
|
||||
|
||||
buildFakeMsg (selfUid: string, element: PacketMsg[]): NapProtoEncodeStructType<typeof PushMsgBody>[] {
|
||||
return element.map((node): NapProtoEncodeStructType<typeof PushMsgBody> => {
|
||||
const avatar = `https://q.qlogo.cn/headimg_dl?dst_uin=${node.senderUin}&spec=640&img_type=jpg`;
|
||||
const avatar = `https://q.qlogo.cn/headimg_dl?dst_uin=${node.senderUin}&spec=0&img_type=jpg`;
|
||||
const msgContent = node.msg.reduceRight((acc: undefined | Uint8Array, msg: IPacketMsgElement<PacketSendMsgElement>) => {
|
||||
return acc ?? msg.buildContent();
|
||||
}, undefined);
|
||||
|
||||
@@ -174,7 +174,6 @@ export class OneBotGroupApi {
|
||||
|
||||
async registerParseGroupReactEventByCore () {
|
||||
this.core.event.on('event:emoji_like', async (data) => {
|
||||
console.log('Received emoji_like event from core:', data);
|
||||
const event = await this.createGroupEmojiLikeEvent(
|
||||
data.groupId,
|
||||
data.senderUin,
|
||||
|
||||
@@ -749,26 +749,31 @@ export class OneBotMsgApi {
|
||||
|
||||
[OB11MessageDataType.music]: async ({ data }, context) => {
|
||||
// 保留, 直到...找到更好的解决方案
|
||||
const supportedPlatforms = ['qq', '163', 'kugou', 'kuwo', 'migu'];
|
||||
const supportedPlatformsWithCustom = [...supportedPlatforms, 'custom'];
|
||||
|
||||
// 验证音乐类型
|
||||
if (data.id !== undefined) {
|
||||
if (!['qq', '163', 'kugou', 'kuwo', 'migu'].includes(data.type)) {
|
||||
this.core.context.logger.logError('音乐卡片type错误, 只支持qq、163、kugou、kuwo、migu,当前type:', data.type);
|
||||
if (!supportedPlatforms.includes(data.type)) {
|
||||
this.core.context.logger.logError(`[音乐卡片] type参数错误: "${data.type}",仅支持: ${supportedPlatforms.join('、')}`);
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
if (!['qq', '163', 'kugou', 'kuwo', 'migu', 'custom'].includes(data.type)) {
|
||||
this.core.context.logger.logError('音乐卡片type错误, 只支持qq、163、kugou、kuwo、migu、custom,当前type:', data.type);
|
||||
if (!supportedPlatformsWithCustom.includes(data.type)) {
|
||||
this.core.context.logger.logError(`[音乐卡片] type参数错误: "${data.type}",仅支持: ${supportedPlatformsWithCustom.join('、')}`);
|
||||
return undefined;
|
||||
}
|
||||
if (!data.url) {
|
||||
this.core.context.logger.logError('自定义音卡缺少参数url');
|
||||
this.core.context.logger.logError('[音乐卡片] 自定义音乐卡片缺少必需参数: url');
|
||||
return undefined;
|
||||
}
|
||||
if (!data.image) {
|
||||
this.core.context.logger.logError('自定义音卡缺少参数image');
|
||||
this.core.context.logger.logError('[音乐卡片] 自定义音乐卡片缺少必需参数: image');
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// 构建请求数据
|
||||
let postData: IdMusicSignPostData | CustomMusicSignPostData;
|
||||
if (data.id === undefined && data.content) {
|
||||
const { content, ...others } = data;
|
||||
@@ -776,11 +781,14 @@ export class OneBotMsgApi {
|
||||
} else {
|
||||
postData = data;
|
||||
}
|
||||
|
||||
// 获取签名服务地址
|
||||
let signUrl = this.obContext.configLoader.configData.musicSignUrl;
|
||||
if (!signUrl) {
|
||||
signUrl = 'https://ss.xingzhige.com/music_card/card';// 感谢思思!已获思思许可 其余地方使用请自行询问
|
||||
// throw Error('音乐消息签名地址未配置');
|
||||
}
|
||||
|
||||
// 请求签名服务
|
||||
try {
|
||||
const musicJson = await RequestUtil.HttpGetJson<string>(signUrl, 'POST', postData);
|
||||
return this.ob11ToRawConverters.json({
|
||||
@@ -788,9 +796,16 @@ export class OneBotMsgApi {
|
||||
type: OB11MessageDataType.json,
|
||||
}, context);
|
||||
} catch (e) {
|
||||
this.core.context.logger.logError('生成音乐消息失败', e);
|
||||
const errorMessage = e instanceof Error ? e.message : String(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,
|
||||
|
||||
@@ -27,6 +27,7 @@ const ShellBaseConfigPlugin: PluginOption[] = [
|
||||
targets: [
|
||||
{ src: '../napcat-native/', dest: 'dist/native', flatten: false },
|
||||
{ src: '../napcat-webui-frontend/dist/', dest: 'dist/static/', flatten: false },
|
||||
{ src: '../napcat-webui-backend/src/assets/sw_template.js', dest: 'dist/static/' },
|
||||
{ src: '../napcat-core/external/napcat.json', dest: 'dist/config/' },
|
||||
{ src: '../../package.json', dest: 'dist' },
|
||||
{ src: '../napcat-shell-loader', dest: 'dist' },
|
||||
|
||||
@@ -23,6 +23,13 @@ import { ILogWrapper } from 'napcat-common/src/log-interface';
|
||||
import { ISubscription } from 'napcat-common/src/subscription-interface';
|
||||
import { IStatusHelperSubscription } from '@/napcat-common/src/status-interface';
|
||||
import { handleDebugWebSocket } from '@/napcat-webui-backend/src/api/Debug';
|
||||
import compression from 'compression';
|
||||
import { napCatVersion } from 'napcat-common/src/version';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
// 实例化Express
|
||||
const app = express();
|
||||
/**
|
||||
@@ -143,18 +150,31 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
// ------------注册中间件------------
|
||||
// 使用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中间件
|
||||
// TODO:
|
||||
app.use(cors);
|
||||
|
||||
// 如果是webui字体文件,挂载字体文件
|
||||
app.use('/webui/fonts/AaCute.woff', async (_req, res, next) => {
|
||||
const isFontExist = await WebUiConfig.CheckWebUIFontExist();
|
||||
if (isFontExist) {
|
||||
res.sendFile(WebUiConfig.GetWebUIFontPath());
|
||||
// 自定义字体文件路由 - 返回用户上传的字体文件
|
||||
app.use('/webui/fonts/CustomFont.woff', async (_req, res) => {
|
||||
const fontPath = await WebUiConfig.GetWebUIFontPath();
|
||||
if (fontPath) {
|
||||
res.sendFile(fontPath);
|
||||
} else {
|
||||
next();
|
||||
res.status(404).send('Custom font not found');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -176,6 +196,32 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
res.send(css);
|
||||
});
|
||||
|
||||
// 动态生成 sw.js
|
||||
app.get('/webui/sw.js', async (_req, res) => {
|
||||
try {
|
||||
// 读取模板文件
|
||||
let templatePath = resolve(__dirname, 'static', 'sw_template.js');
|
||||
if (!existsSync(templatePath)) {
|
||||
templatePath = resolve(__dirname, 'src', 'assets', 'sw_template.js');
|
||||
}
|
||||
|
||||
let swContent = readFileSync(templatePath, 'utf-8');
|
||||
|
||||
// 替换版本号
|
||||
// 使用 napCatVersion,如果为 alpha 则尝试加上时间戳或其他标识以避免缓存冲突,或者直接使用
|
||||
// 用户要求控制 sw.js 版本,napCatVersion 是核心控制点
|
||||
swContent = swContent.replace('{{VERSION}}', napCatVersion);
|
||||
|
||||
res.header('Content-Type', 'application/javascript');
|
||||
res.header('Service-Worker-Allowed', '/webui/');
|
||||
res.header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.send(swContent);
|
||||
} catch (error) {
|
||||
console.error('[NapCat] [WebUi] Error generating sw.js', error);
|
||||
res.status(500).send('Error generating service worker');
|
||||
}
|
||||
});
|
||||
|
||||
// ------------中间件结束------------
|
||||
|
||||
// ------------挂载路由------------
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@sinclair/typebox": "^0.34.38",
|
||||
"ajv": "^8.13.0",
|
||||
"compressing": "^1.10.3",
|
||||
"compression": "^1.8.1",
|
||||
"express": "^5.0.0",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"json5": "^2.2.3",
|
||||
@@ -29,6 +30,7 @@
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.0.1",
|
||||
|
||||
@@ -62,7 +62,6 @@ class DebugAdapter {
|
||||
*/
|
||||
async onEvent (event: any) {
|
||||
this.updateActivity();
|
||||
console.log(`[Debug] Adapter ${this.name} 收到事件, 类型: ${event.post_type || 'unknown'}, 客户端数: ${this.wsClients.size}`);
|
||||
|
||||
const payload = JSON.stringify(event);
|
||||
|
||||
@@ -163,7 +162,6 @@ class DebugAdapter {
|
||||
addWsClient (ws: WebSocket) {
|
||||
this.wsClients.add(ws);
|
||||
this.updateActivity();
|
||||
console.log(`[Debug] WebSocket 客户端已连接 (${this.wsClients.size})`);
|
||||
|
||||
// 发送生命周期事件 (Connect)
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
@@ -182,7 +180,6 @@ class DebugAdapter {
|
||||
*/
|
||||
removeWsClient (ws: WebSocket) {
|
||||
this.wsClients.delete(ws);
|
||||
console.log(`[Debug] WebSocket 客户端已断开 (${this.wsClients.size})`);
|
||||
}
|
||||
|
||||
updateActivity () {
|
||||
@@ -255,7 +252,6 @@ class DebugAdapterManager {
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (oneBotContext) {
|
||||
oneBotContext.networkManager.adapters.set(adapter.name, adapter as any);
|
||||
console.log(`[Debug] 已注册调试适配器: ${adapter.name}, NetworkManager中适配器数量: ${oneBotContext.networkManager.adapters.size}`);
|
||||
} else {
|
||||
console.warn('[Debug] OneBot 未初始化,无法注册适配器');
|
||||
}
|
||||
|
||||
@@ -640,10 +640,10 @@ export const UploadWebUIFontHandler: RequestHandler = async (req, res) => {
|
||||
// 删除WebUI字体文件处理方法
|
||||
export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => {
|
||||
try {
|
||||
const fontPath = WebUiConfig.GetWebUIFontPath();
|
||||
const fontPath = await WebUiConfig.GetWebUIFontPath();
|
||||
const exists = await WebUiConfig.CheckWebUIFontExist();
|
||||
|
||||
if (!exists) {
|
||||
if (!exists || !fontPath) {
|
||||
return sendSuccess(res, true);
|
||||
}
|
||||
|
||||
|
||||
132
packages/napcat-webui-backend/src/assets/sw_template.js
Normal file
132
packages/napcat-webui-backend/src/assets/sw_template.js
Normal file
@@ -0,0 +1,132 @@
|
||||
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))
|
||||
);
|
||||
});
|
||||
@@ -176,17 +176,35 @@ export class WebUiConfigWrapper {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 判断字体是否存在(webui.woff)
|
||||
// 判断字体是否存在(支持多种格式)
|
||||
async CheckWebUIFontExist (): Promise<boolean> {
|
||||
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
|
||||
const fontPath = await this.GetWebUIFontPath();
|
||||
if (!fontPath) return false;
|
||||
return await fs
|
||||
.access(resolve(fontsPath, './webui.woff'), constants.F_OK)
|
||||
.access(fontPath, constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
// 获取webui字体文件路径
|
||||
GetWebUIFontPath (): string {
|
||||
// 获取webui字体文件路径(支持多种格式)
|
||||
async GetWebUIFontPath (): Promise<string | null> {
|
||||
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
|
||||
const extensions = ['.woff', '.woff2', '.ttf', '.otf'];
|
||||
for (const ext of extensions) {
|
||||
const fontPath = resolve(fontsPath, `webui${ext}`);
|
||||
const exists = await fs
|
||||
.access(fontPath, constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (exists) {
|
||||
return fontPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 同步版本,用于 multer 配置
|
||||
GetWebUIFontPathSync (): string {
|
||||
return resolve(webUiPathWrapper.configPath, './fonts/webui.woff');
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ export const themeType = Type.Object(
|
||||
{
|
||||
dark: Type.Record(Type.String(), Type.String()),
|
||||
light: Type.Record(Type.String(), Type.String()),
|
||||
fontMode: Type.String({ default: 'system' }),
|
||||
},
|
||||
{
|
||||
default: {
|
||||
fontMode: 'system',
|
||||
dark: {
|
||||
'--heroui-background': '0 0% 0%',
|
||||
'--heroui-foreground-50': '240 5.88% 10%',
|
||||
@@ -124,11 +126,11 @@ export const themeType = Type.Object(
|
||||
'--heroui-border-width-medium': '2px',
|
||||
'--heroui-border-width-large': '3px',
|
||||
'--heroui-box-shadow-small':
|
||||
'0px 0px 5px 0px rgba(0, 0, 0, .05), 0px 2px 10px 0px rgba(0, 0, 0, .2), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
|
||||
'0px 0px 5px 0px rgba(0, 0, 0, .05), 0px 2px 10px 0px rgba(0, 0, 0, .2), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
|
||||
'--heroui-box-shadow-medium':
|
||||
'0px 0px 15px 0px rgba(0, 0, 0, .06), 0px 2px 30px 0px rgba(0, 0, 0, .22), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
|
||||
'0px 0px 15px 0px rgba(0, 0, 0, .06), 0px 2px 30px 0px rgba(0, 0, 0, .22), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
|
||||
'--heroui-box-shadow-large':
|
||||
'0px 0px 30px 0px rgba(0, 0, 0, .07), 0px 30px 60px 0px rgba(0, 0, 0, .26), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
|
||||
'0px 0px 30px 0px rgba(0, 0, 0, .07), 0px 30px 60px 0px rgba(0, 0, 0, .26), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
|
||||
'--heroui-hover-opacity': '.9',
|
||||
},
|
||||
light: {
|
||||
@@ -248,11 +250,11 @@ export const themeType = Type.Object(
|
||||
'--heroui-border-width-medium': '2px',
|
||||
'--heroui-border-width-large': '3px',
|
||||
'--heroui-box-shadow-small':
|
||||
'0px 0px 5px 0px rgba(0, 0, 0, .02), 0px 2px 10px 0px rgba(0, 0, 0, .06), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
|
||||
'0px 0px 5px 0px rgba(0, 0, 0, .02), 0px 2px 10px 0px rgba(0, 0, 0, .06), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
|
||||
'--heroui-box-shadow-medium':
|
||||
'0px 0px 15px 0px rgba(0, 0, 0, .03), 0px 2px 30px 0px rgba(0, 0, 0, .08), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
|
||||
'0px 0px 15px 0px rgba(0, 0, 0, .03), 0px 2px 30px 0px rgba(0, 0, 0, .08), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
|
||||
'--heroui-box-shadow-large':
|
||||
'0px 0px 30px 0px rgba(0, 0, 0, .04), 0px 30px 60px 0px rgba(0, 0, 0, .12), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
|
||||
'0px 0px 30px 0px rgba(0, 0, 0, .04), 0px 30px 60px 0px rgba(0, 0, 0, .12), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
|
||||
'--heroui-hover-opacity': '.8',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,30 +4,51 @@ import fs from 'fs';
|
||||
import type { Request, Response } from 'express';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
|
||||
// 支持的字体格式
|
||||
const SUPPORTED_FONT_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf'];
|
||||
|
||||
// 清理旧的字体文件
|
||||
const cleanOldFontFiles = (fontsPath: string) => {
|
||||
for (const ext of SUPPORTED_FONT_EXTENSIONS) {
|
||||
const fontPath = path.join(fontsPath, `webui${ext}`);
|
||||
try {
|
||||
if (fs.existsSync(fontPath)) {
|
||||
fs.unlinkSync(fontPath);
|
||||
}
|
||||
} catch {
|
||||
// 忽略删除失败
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const webUIFontStorage = multer.diskStorage({
|
||||
destination: (_, __, cb) => {
|
||||
try {
|
||||
const fontsPath = path.dirname(WebUiConfig.GetWebUIFontPath());
|
||||
const fontsPath = path.dirname(WebUiConfig.GetWebUIFontPathSync());
|
||||
// 确保字体目录存在
|
||||
fs.mkdirSync(fontsPath, { recursive: true });
|
||||
// 清理旧的字体文件
|
||||
cleanOldFontFiles(fontsPath);
|
||||
cb(null, fontsPath);
|
||||
} catch (error) {
|
||||
// 确保错误信息被正确传递
|
||||
cb(new Error(`创建字体目录失败:${(error as Error).message}`), '');
|
||||
}
|
||||
},
|
||||
filename: (_, __, cb) => {
|
||||
// 统一保存为webui.woff
|
||||
cb(null, 'webui.woff');
|
||||
filename: (_, file, cb) => {
|
||||
// 保留原始扩展名,统一文件名为 webui
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, `webui${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const webUIFontUpload = multer({
|
||||
storage: webUIFontStorage,
|
||||
fileFilter: (_, file, cb) => {
|
||||
// 再次验证文件类型
|
||||
if (!file.originalname.toLowerCase().endsWith('.woff')) {
|
||||
cb(new Error('只支持WOFF格式的字体文件'));
|
||||
// 验证文件类型
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!SUPPORTED_FONT_EXTENSIONS.includes(ext)) {
|
||||
cb(new Error('只支持 WOFF/WOFF2/TTF/OTF 格式的字体文件'));
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
@@ -41,8 +62,6 @@ const webUIFontUploader = (req: Request, res: Response) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
webUIFontUpload(req, res, (error) => {
|
||||
if (error) {
|
||||
// 错误处理
|
||||
// sendError(res, error.message, true);
|
||||
return reject(error);
|
||||
}
|
||||
return resolve(true);
|
||||
|
||||
@@ -1 +1 @@
|
||||
VITE_DEBUG_BACKEND_URL="http://127.0.0.1:6099"
|
||||
VITE_DEBUG_BACKEND_URL="http://127.0.0.1:6099"
|
||||
@@ -5,12 +5,19 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host=0.0.0.0",
|
||||
"build": "tsc && vite build",
|
||||
"build": "vite build",
|
||||
"build:full": "tsc && vite build",
|
||||
"fontmin": "node scripts/fontmin.cjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -46,11 +53,10 @@
|
||||
"@heroui/theme": "2.4.6",
|
||||
"@heroui/tooltip": "2.2.8",
|
||||
"@monaco-editor/loader": "^1.4.0",
|
||||
"@monaco-editor/react": "4.7.0-rc.0",
|
||||
"@react-aria/visually-hidden": "^3.8.19",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
@@ -59,10 +65,7 @@
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"echarts": "^5.5.1",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"framer-motion": "^12.0.6",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"motion": "^12.0.6",
|
||||
"path-browserify": "^1.0.1",
|
||||
"qface": "^1.4.1",
|
||||
@@ -79,7 +82,6 @@
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-photo-view": "^1.2.7",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-router-dom": "^7.1.4",
|
||||
"react-use-websocket": "^4.11.1",
|
||||
"react-window": "^1.8.11",
|
||||
@@ -107,10 +109,15 @@
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-prettier": "5.2.3",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"fontmin": "^0.9.9",
|
||||
"glob": "^10.3.10",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^3.4.2",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-image-optimizer": "^2.0.3",
|
||||
"vite-plugin-static-copy": "^2.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
@@ -124,4 +131,4 @@
|
||||
"react-dom": "$react-dom"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
137
packages/napcat-webui-frontend/scripts/fontmin.cjs
Normal file
137
packages/napcat-webui-frontend/scripts/fontmin.cjs
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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);
|
||||
BIN
packages/napcat-webui-frontend/src/assets/fonts/AaCute-full.ttf
Normal file
BIN
packages/napcat-webui-frontend/src/assets/fonts/AaCute-full.ttf
Normal file
Binary file not shown.
BIN
packages/napcat-webui-frontend/src/assets/fonts/AaCute.ttf
Normal file
BIN
packages/napcat-webui-frontend/src/assets/fonts/AaCute.ttf
Normal file
Binary file not shown.
BIN
packages/napcat-webui-frontend/src/assets/fonts/AaCute.woff
Normal file
BIN
packages/napcat-webui-frontend/src/assets/fonts/AaCute.woff
Normal file
Binary file not shown.
@@ -1,46 +1,126 @@
|
||||
import Editor, { OnMount, loader } from '@monaco-editor/react';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import React, { useImperativeHandle, useEffect, useState } from 'react';
|
||||
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import monaco from '@/monaco';
|
||||
const getLanguageExtension = (lang?: string) => {
|
||||
switch (lang) {
|
||||
case 'json': return json();
|
||||
default: return [];
|
||||
}
|
||||
};
|
||||
|
||||
loader.config({
|
||||
monaco,
|
||||
});
|
||||
|
||||
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
|
||||
test?: string;
|
||||
export interface CodeEditorProps {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
language?: string;
|
||||
defaultLanguage?: string;
|
||||
onChange?: (value: string | undefined) => void;
|
||||
height?: string;
|
||||
options?: any;
|
||||
onMount?: any;
|
||||
}
|
||||
|
||||
export type CodeEditorRef = monaco.editor.IStandaloneCodeEditor;
|
||||
export interface CodeEditorRef {
|
||||
getValue: () => string;
|
||||
}
|
||||
|
||||
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>(
|
||||
(props, ref) => {
|
||||
const { isDark } = useTheme();
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (props.value !== undefined) {
|
||||
setVal(props.value);
|
||||
}
|
||||
}, [props.value]);
|
||||
|
||||
return (
|
||||
<Editor
|
||||
{...props}
|
||||
onMount={handleEditorDidMount}
|
||||
theme={isDark ? 'vs-dark' : 'light'}
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
export default CodeEditor;
|
||||
|
||||
@@ -37,6 +37,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
onEnable,
|
||||
onDelete,
|
||||
onEnableDebug,
|
||||
showType,
|
||||
}: NetworkDisplayCardProps<T>) => {
|
||||
const { name, enable, debug } = data;
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -60,15 +61,16 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
|
||||
return (
|
||||
<DisplayCardContainer
|
||||
className="w-full max-w-[420px]"
|
||||
className='w-full max-w-[420px]'
|
||||
tag={showType ? typeLabel : undefined}
|
||||
action={
|
||||
<div className="flex gap-2 w-full">
|
||||
<div className='flex gap-2 w-full'>
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className="flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-warning/20 hover:text-warning transition-colors"
|
||||
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} />}
|
||||
onPress={onEdit}
|
||||
isDisabled={editing}
|
||||
@@ -82,10 +84,10 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className={clsx(
|
||||
"flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors",
|
||||
'flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors',
|
||||
debug
|
||||
? "hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary"
|
||||
: "hover:bg-success/20 hover:text-success data-[hover=true]:text-success"
|
||||
? 'hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary'
|
||||
: 'hover:bg-success/20 hover:text-success data-[hover=true]:text-success'
|
||||
)}
|
||||
startContent={<CgDebug size={16} />}
|
||||
onPress={handleEnableDebug}
|
||||
@@ -113,11 +115,11 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
isSelected={enable}
|
||||
onChange={handleEnable}
|
||||
classNames={{
|
||||
wrapper: "group-data-[selected=true]:bg-primary-400",
|
||||
wrapper: 'group-data-[selected=true]:bg-primary-400',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={typeLabel}
|
||||
title={name}
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
{(() => {
|
||||
@@ -125,29 +127,30 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
|
||||
if (targetFullField) {
|
||||
// 模式1:存在全宽字段(如URL),布局为:
|
||||
// Row 1: 名称 (全宽)
|
||||
// Row 1: 类型 (全宽)
|
||||
// Row 2: 全宽字段 (全宽)
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
|
||||
>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>名称</span>
|
||||
<div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
|
||||
{name}
|
||||
<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'>
|
||||
{typeLabel}
|
||||
</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">
|
||||
<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>
|
||||
)}
|
||||
@@ -157,7 +160,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
);
|
||||
} else {
|
||||
// 模式2:无全宽字段,布局为 4 个小块 (2行 x 2列)
|
||||
// Row 1: 名称 | Field 0
|
||||
// Row 1: 类型 | Field 0
|
||||
// Row 2: Field 1 | Field 2
|
||||
const displayFields = fields.slice(0, 3);
|
||||
return (
|
||||
@@ -165,9 +168,9 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
<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}
|
||||
<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'>
|
||||
{typeLabel}
|
||||
</div>
|
||||
</div>
|
||||
{displayFields.map((field, index) => (
|
||||
@@ -176,7 +179,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
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">
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{field.render
|
||||
? (
|
||||
field.render(field.value)
|
||||
@@ -184,7 +187,8 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
: (
|
||||
<span className={clsx(
|
||||
typeof field.value === 'string' && (field.value.startsWith('http') || field.value.includes('.') || field.value.includes(':')) ? 'font-mono' : ''
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
{String(field.value)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
} from '@heroui/modal';
|
||||
|
||||
interface CreateFileModalProps {
|
||||
isOpen: boolean
|
||||
fileType: 'file' | 'directory'
|
||||
newFileName: string
|
||||
onTypeChange: (type: 'file' | 'directory') => void
|
||||
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onClose: () => void
|
||||
onCreate: () => void
|
||||
isOpen: boolean;
|
||||
fileType: 'file' | 'directory';
|
||||
newFileName: string;
|
||||
onTypeChange: (type: 'file' | 'directory') => void;
|
||||
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onClose: () => void;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
export default function CreateFileModal ({
|
||||
@@ -28,12 +28,12 @@ export default function CreateFileModal ({
|
||||
onCreate,
|
||||
}: CreateFileModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>新建</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<ButtonGroup color='primary'>
|
||||
<ButtonGroup radius='sm' color='primary'>
|
||||
<Button
|
||||
variant={fileType === 'file' ? 'solid' : 'flat'}
|
||||
onPress={() => onTypeChange('file')}
|
||||
@@ -47,14 +47,14 @@ export default function CreateFileModal ({
|
||||
目录
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<Input label='名称' value={newFileName} onChange={onNameChange} />
|
||||
<Input radius='sm' label='名称' value={newFileName} onChange={onNameChange} />
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color='primary' onPress={onCreate}>
|
||||
<Button radius='sm' color='primary' onPress={onCreate}>
|
||||
创建
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
import CodeEditor from '@/components/code_editor';
|
||||
|
||||
interface FileEditModalProps {
|
||||
isOpen: boolean
|
||||
file: { path: string; content: string } | null
|
||||
onClose: () => void
|
||||
onSave: () => void
|
||||
onContentChange: (newContent?: string) => void
|
||||
isOpen: boolean;
|
||||
file: { path: string; content: string; } | null;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onContentChange: (newContent?: string) => void;
|
||||
}
|
||||
|
||||
export default function FileEditModal ({
|
||||
@@ -63,14 +63,22 @@ export default function FileEditModal ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal size='full' isOpen={isOpen} onClose={onClose}>
|
||||
<Modal radius='sm' size='full' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader className='flex items-center gap-2 bg-content2 bg-opacity-50'>
|
||||
<ModalHeader className='flex items-center gap-2 border-b border-default-200/50'>
|
||||
<span>编辑文件</span>
|
||||
<Code className='text-xs'>{file?.path}</Code>
|
||||
<Code radius='sm' className='text-xs'>{file?.path}</Code>
|
||||
<div className="ml-auto text-xs text-default-400 font-normal px-2">
|
||||
按 <span className="px-1 py-0.5 rounded border border-default-300 bg-default-100">Ctrl/Cmd + S</span> 保存
|
||||
</div>
|
||||
</ModalHeader>
|
||||
<ModalBody className='p-0'>
|
||||
<div className='h-full'>
|
||||
<ModalBody className='p-4 bg-content2/50'>
|
||||
<div className='h-full' onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
onSave();
|
||||
}
|
||||
}}>
|
||||
<CodeEditor
|
||||
height='100%'
|
||||
value={file?.content || ''}
|
||||
@@ -80,11 +88,11 @@ export default function FileEditModal ({
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
<ModalFooter className="border-t border-default-200/50">
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color='primary' onPress={onSave}>
|
||||
<Button radius='sm' color='primary' onPress={onSave}>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -14,9 +14,9 @@ import { useEffect } from 'react';
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
isOpen: boolean
|
||||
filePath: string
|
||||
onClose: () => void
|
||||
isOpen: boolean;
|
||||
filePath: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const videoExts = ['.mp4', '.webm'];
|
||||
@@ -75,14 +75,14 @@ export default function FilePreviewModal ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior='inside' size='3xl'>
|
||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose} scrollBehavior='inside' size='3xl'>
|
||||
<ModalContent>
|
||||
<ModalHeader>文件预览</ModalHeader>
|
||||
<ModalBody className='flex justify-center items-center'>
|
||||
{contentElement}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -105,6 +105,7 @@ export default function FileTable ({
|
||||
/>
|
||||
<Table
|
||||
aria-label='文件列表'
|
||||
radius='sm'
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={onSortChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
@@ -175,6 +176,7 @@ export default function FileTable ({
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
radius='sm'
|
||||
variant='light'
|
||||
onPress={() =>
|
||||
file.isDirectory
|
||||
@@ -202,7 +204,7 @@ export default function FileTable ({
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>{new Date(file.mtime).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<ButtonGroup size='sm' variant='light'>
|
||||
<ButtonGroup radius='sm' size='sm' variant='light'>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='default'
|
||||
|
||||
@@ -10,17 +10,17 @@ import FileManager from '@/controllers/file_manager';
|
||||
import FileIcon from '../file_icon';
|
||||
|
||||
export interface PreviewImage {
|
||||
key: string
|
||||
src: string
|
||||
alt: string
|
||||
key: string;
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
export const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp'];
|
||||
|
||||
export interface ImageNameButtonProps {
|
||||
name: string
|
||||
filePath: string
|
||||
onPreview: () => void
|
||||
onAddPreview: (image: PreviewImage) => void
|
||||
name: string;
|
||||
filePath: string;
|
||||
onPreview: () => void;
|
||||
onAddPreview: (image: PreviewImage) => void;
|
||||
}
|
||||
|
||||
export default function ImageNameButton ({
|
||||
@@ -61,6 +61,7 @@ export default function ImageNameButton ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
radius='sm'
|
||||
variant='light'
|
||||
className='text-left justify-start'
|
||||
onPress={onPreview}
|
||||
|
||||
@@ -83,15 +83,16 @@ function DirectoryTree ({
|
||||
return (
|
||||
<div className='ml-4'>
|
||||
<Button
|
||||
radius='sm'
|
||||
onPress={handleClick}
|
||||
className='py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md'
|
||||
className='py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-sm'
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant={variant}
|
||||
startContent={
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-md',
|
||||
'rounded-sm',
|
||||
isSeleted ? 'bg-primary-600' : 'bg-primary-50'
|
||||
)}
|
||||
>
|
||||
@@ -140,11 +141,11 @@ export default function MoveModal ({
|
||||
onSelect,
|
||||
}: MoveModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>选择目标目录</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='rounded-md p-2 border border-default-300 overflow-auto max-h-60'>
|
||||
<div className='rounded-sm p-2 border border-default-300 overflow-auto max-h-60'>
|
||||
<DirectoryTree
|
||||
basePath='/'
|
||||
onSelect={onSelect}
|
||||
@@ -157,10 +158,10 @@ export default function MoveModal ({
|
||||
<p className='text-sm text-default-500'>移动项:{selectionInfo}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color='primary' onPress={onMove}>
|
||||
<Button radius='sm' color='primary' onPress={onMove}>
|
||||
确定
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
} from '@heroui/modal';
|
||||
|
||||
interface RenameModalProps {
|
||||
isOpen: boolean
|
||||
newFileName: string
|
||||
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onClose: () => void
|
||||
onRename: () => void
|
||||
isOpen: boolean;
|
||||
newFileName: string;
|
||||
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onClose: () => void;
|
||||
onRename: () => void;
|
||||
}
|
||||
|
||||
export default function RenameModal ({
|
||||
@@ -24,17 +24,17 @@ export default function RenameModal ({
|
||||
onRename,
|
||||
}: RenameModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>重命名</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input label='新名称' value={newFileName} onChange={onNameChange} />
|
||||
<Input radius='sm' label='新名称' value={newFileName} onChange={onNameChange} />
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color='primary' onPress={onRename}>
|
||||
<Button radius='sm' color='primary' onPress={onRename}>
|
||||
确定
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable @stylistic/jsx-closing-bracket-location */
|
||||
/* eslint-disable @stylistic/jsx-closing-tag-location */
|
||||
import { Button } from '@heroui/button';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
@@ -19,7 +21,6 @@ export default function Hitokoto () {
|
||||
loading,
|
||||
run,
|
||||
} = useRequest(() => request.get<IHitokoto>('https://hitokoto.152710.xyz/'), {
|
||||
pollingInterval: 10000,
|
||||
throttleWait: 1000,
|
||||
});
|
||||
const backupData = {
|
||||
@@ -41,30 +42,36 @@ export default function Hitokoto () {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className='relative flex flex-col items-center justify-center p-6 min-h-[120px]'>
|
||||
<div className='overflow-hidden'>
|
||||
<div className='relative flex flex-col items-center justify-center p-4 md:p-6'>
|
||||
{loading && !data && <PageLoading />}
|
||||
{data && (
|
||||
<>
|
||||
<IoMdQuote className={clsx(
|
||||
"text-4xl mb-4",
|
||||
hasBackground ? "text-white/30" : "text-primary/20"
|
||||
)} />
|
||||
'text-4xl mb-4',
|
||||
hasBackground ? 'text-white/30' : 'text-primary/20'
|
||||
)}
|
||||
/>
|
||||
<div className={clsx(
|
||||
"text-xl font-medium tracking-wide leading-relaxed italic",
|
||||
hasBackground ? "text-white drop-shadow-sm" : "text-default-700 dark:text-gray-200"
|
||||
)}>
|
||||
'text-xl font-medium tracking-wide leading-relaxed italic',
|
||||
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
|
||||
)}
|
||||
>
|
||||
" {data?.hitokoto} "
|
||||
</div>
|
||||
<div className='mt-4 flex flex-col items-center text-sm'>
|
||||
<span className={clsx(
|
||||
'font-bold',
|
||||
hasBackground ? 'text-white/90' : 'text-primary-500/80'
|
||||
)}>—— {data?.from}</span>
|
||||
)}
|
||||
>—— {data?.from}
|
||||
</span>
|
||||
{data?.from_who && <span className={clsx(
|
||||
"text-xs mt-1",
|
||||
hasBackground ? "text-white/70" : "text-default-400"
|
||||
)}>{data?.from_who}</span>}
|
||||
'text-xs mt-1',
|
||||
hasBackground ? 'text-white/70' : 'text-default-400'
|
||||
)}
|
||||
> {data?.from_who}
|
||||
</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -73,8 +80,8 @@ export default function Hitokoto () {
|
||||
<Tooltip content='刷新' placement='top'>
|
||||
<Button
|
||||
className={clsx(
|
||||
"transition-colors",
|
||||
hasBackground ? "text-white/60 hover:text-white" : "text-default-400 hover:text-primary"
|
||||
'transition-colors',
|
||||
hasBackground ? 'text-white/60 hover:text-white' : 'text-default-400 hover:text-primary'
|
||||
)}
|
||||
onPress={run}
|
||||
size='sm'
|
||||
@@ -89,8 +96,8 @@ export default function Hitokoto () {
|
||||
<Tooltip content='复制' placement='top'>
|
||||
<Button
|
||||
className={clsx(
|
||||
"transition-colors",
|
||||
hasBackground ? "text-white/60 hover:text-white" : "text-default-400 hover:text-success"
|
||||
'transition-colors',
|
||||
hasBackground ? 'text-white/60 hover:text-white' : 'text-default-400 hover:text-success'
|
||||
)}
|
||||
onPress={onCopy}
|
||||
size='sm'
|
||||
|
||||
@@ -274,8 +274,9 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
|
||||
<div className='flex-1 min-h-0 relative px-3 pb-2 mt-1'>
|
||||
<div className={clsx(
|
||||
'h-full rounded-xl overflow-y-auto no-scrollbar transition-all',
|
||||
hasBackground ? 'bg-transparent' : 'bg-white/10 dark:bg-black/10'
|
||||
'h-full transition-all',
|
||||
activeTab !== 'request' && 'rounded-xl overflow-y-auto no-scrollbar',
|
||||
hasBackground ? 'bg-transparent' : (activeTab !== 'request' && 'bg-white/10 dark:bg-black/10')
|
||||
)}>
|
||||
{activeTab === 'request' ? (
|
||||
<CodeEditor
|
||||
@@ -351,7 +352,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
|
||||
{/* Response Content - Code Editor */}
|
||||
{responseExpanded && (
|
||||
<div style={{ height: responseHeight }} className="relative bg-black/5 dark:bg-black/20">
|
||||
<div style={{ height: responseHeight }} className="relative bg-transparent">
|
||||
<PageLoading loading={isFetching} />
|
||||
<CodeEditor
|
||||
value={responseContent || '// Waiting for response...'}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Input } from '@heroui/input';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { TbChevronRight, TbFolder, TbSearch } from 'react-icons/tb';
|
||||
|
||||
import 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';
|
||||
@@ -22,6 +24,8 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
||||
const { data, selectedApi, onSelect, openSideBar, onToggle } = props;
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const rawGroups = [
|
||||
@@ -70,7 +74,9 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
||||
// Mobile: absolute position, drawer style
|
||||
// Desktop: relative position, pushing content
|
||||
'absolute md:relative left-0 top-0',
|
||||
'bg-white/80 dark:bg-black/80 md:bg-transparent backdrop-blur-2xl md:backdrop-blur-none'
|
||||
hasBackground
|
||||
? 'bg-white/10 dark:bg-black/40 backdrop-blur-xl md:bg-transparent md:backdrop-blur-none'
|
||||
: 'bg-white/80 dark:bg-black/40 backdrop-blur-xl md:bg-transparent md:backdrop-blur-none'
|
||||
)}
|
||||
initial={false}
|
||||
animate={{
|
||||
@@ -139,7 +145,7 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
||||
className={clsx(
|
||||
'flex flex-col gap-0.5 px-3 py-2 rounded-lg cursor-pointer transition-all border border-transparent select-none',
|
||||
isSelected
|
||||
? 'bg-primary/20 border-primary/20 shadow-sm'
|
||||
? (hasBackground ? '' : 'bg-primary/20 border-primary/20 shadow-sm')
|
||||
: 'hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -13,18 +13,18 @@ import type {
|
||||
import { renderMessageContent } from '../render_message';
|
||||
|
||||
export interface OneBotMessageProps {
|
||||
data: OB11Message
|
||||
data: OB11Message;
|
||||
}
|
||||
|
||||
export interface OneBotMessageGroupProps {
|
||||
data: OB11GroupMessage
|
||||
data: OB11GroupMessage;
|
||||
}
|
||||
|
||||
export interface OneBotMessagePrivateProps {
|
||||
data: OB11PrivateMessage
|
||||
data: OB11PrivateMessage;
|
||||
}
|
||||
|
||||
const MessageContent: React.FC<{ data: OB11Message }> = ({ data }) => {
|
||||
const MessageContent: React.FC<{ data: OB11Message; }> = ({ data }) => {
|
||||
return (
|
||||
<div className='h-full flex flex-col overflow-hidden flex-1'>
|
||||
<div className='flex gap-2 items-center flex-shrink-0'>
|
||||
@@ -35,8 +35,8 @@ const MessageContent: React.FC<{ data: OB11Message }> = ({ data }) => {
|
||||
<span
|
||||
className={clsx(
|
||||
isOB11GroupMessage(data) &&
|
||||
data.sender.card &&
|
||||
'text-default-400 font-normal'
|
||||
data.sender.card &&
|
||||
'text-default-400 font-normal'
|
||||
)}
|
||||
>
|
||||
{data.sender.nickname}
|
||||
@@ -73,7 +73,7 @@ const OneBotMessageGroup: React.FC<OneBotMessageGroupProps> = ({ data }) => {
|
||||
<div className='h-full overflow-hidden flex flex-col w-full'>
|
||||
<div className='flex items-center p-1 flex-shrink-0'>
|
||||
<Avatar
|
||||
src={`https://p.qlogo.cn/gh/${data.group_id}/${data.group_id}/640/`}
|
||||
src={`https://p.qlogo.cn/gh/${data.group_id}/${data.group_id}/0/`}
|
||||
alt='群头像'
|
||||
size='sm'
|
||||
className='flex-shrink-0 mr-2'
|
||||
|
||||
@@ -61,7 +61,7 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
|
||||
构造请求
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='h-96 dark:bg-[rgb(30,30,30)] p-2 rounded-md border border-default-100'>
|
||||
<div className='h-96'>
|
||||
<CodeEditor
|
||||
height='100%'
|
||||
defaultLanguage='json'
|
||||
|
||||
@@ -1,34 +1,15 @@
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
const PageBackground = () => {
|
||||
return (
|
||||
<div className='fixed inset-0 w-full h-full -z-10 overflow-hidden bg-gradient-to-br from-indigo-50 via-white to-pink-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900'>
|
||||
{/* 动态呼吸光斑 - ACG风格 */}
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
rotate: [0, 90, 0],
|
||||
opacity: [0.3, 0.5, 0.3]
|
||||
}}
|
||||
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
|
||||
{/* 静态光斑 - ACG风格 */}
|
||||
<div
|
||||
className='absolute top-[-10%] left-[-10%] w-[500px] h-[500px] rounded-full bg-primary-200/40 blur-[100px]'
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.3, 1],
|
||||
x: [0, 100, 0],
|
||||
opacity: [0.3, 0.6, 0.3]
|
||||
}}
|
||||
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut", delay: 2 }}
|
||||
<div
|
||||
className='absolute top-[20%] right-[-10%] w-[400px] h-[400px] rounded-full bg-secondary-200/40 blur-[90px]'
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.1, 1],
|
||||
y: [0, -50, 0],
|
||||
opacity: [0.2, 0.4, 0.2]
|
||||
}}
|
||||
transition={{ duration: 12, repeat: Infinity, ease: "easeInOut", delay: 5 }}
|
||||
<div
|
||||
className='absolute bottom-[-10%] left-[20%] w-[600px] h-[600px] rounded-full bg-pink-200/30 blur-[110px]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,13 @@ import { Spinner } from '@heroui/spinner';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface PageLoadingProps {
|
||||
loading?: boolean
|
||||
loading?: boolean;
|
||||
}
|
||||
const PageLoading: React.FC<PageLoadingProps> = ({ loading }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute top-0 left-0 w-full h-full bg-zinc-500 bg-opacity-10 z-50 flex justify-center items-center backdrop-blur',
|
||||
'absolute top-0 left-0 w-full h-full bg-zinc-500 bg-opacity-10 z-30 flex justify-center items-center backdrop-blur',
|
||||
{
|
||||
hidden: !loading,
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
: (
|
||||
<CardBody className='flex-row items-center gap-4 overflow-hidden relative p-4'>
|
||||
{!hasBackground && (
|
||||
<div className='absolute right-[-10px] bottom-[-10px] text-7xl text-default-400/10 rotate-12 pointer-events-none'>
|
||||
<div className='absolute right-[-10px] bottom-[-10px] text-7xl text-default-400/10 rotate-12 pointer-events-none dark:hidden'>
|
||||
<BsTencentQq />
|
||||
</div>
|
||||
)}
|
||||
@@ -48,7 +48,7 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
<Image
|
||||
src={
|
||||
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=0`
|
||||
}
|
||||
className='shadow-sm rounded-full w-14 aspect-square ring-2 ring-white/50 dark:ring-white/10'
|
||||
/>
|
||||
@@ -63,13 +63,15 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
<div className={clsx(
|
||||
'text-xl font-bold truncate mb-0.5',
|
||||
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-800 dark:text-gray-100'
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
{data?.nick || '未知用户'}
|
||||
</div>
|
||||
<div className={clsx(
|
||||
'font-mono text-xs tracking-wider',
|
||||
hasBackground ? 'text-white/80' : 'text-default-500 opacity-80'
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
{data?.uin || 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,17 +7,17 @@ import { IoMdRefresh } from 'react-icons/io';
|
||||
import { isQQQuickNewItem } from '@/utils/qq';
|
||||
|
||||
export interface QQItem {
|
||||
uin: string
|
||||
uin: string;
|
||||
}
|
||||
|
||||
interface QuickLoginProps {
|
||||
qqList: (QQItem | LoginListItem)[]
|
||||
refresh: boolean
|
||||
isLoading: boolean
|
||||
selectedQQ: string
|
||||
onUpdateQQList: () => void
|
||||
handleSelectionChange: React.ChangeEventHandler<HTMLSelectElement>
|
||||
onSubmit: () => void
|
||||
qqList: (QQItem | LoginListItem)[];
|
||||
refresh: boolean;
|
||||
isLoading: boolean;
|
||||
selectedQQ: string;
|
||||
onUpdateQQList: () => void;
|
||||
handleSelectionChange: React.ChangeEventHandler<HTMLSelectElement>;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
const QuickLogin: React.FC<QuickLoginProps> = ({
|
||||
|
||||
@@ -24,8 +24,10 @@ const SideBar: React.FC<SideBarProps> = (props) => {
|
||||
const { open, items, onClose } = props;
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const { revokeAuth } = useAuth();
|
||||
const [b64img] = useLocalStorage(key.backgroundImage, '');
|
||||
const dialog = useDialog();
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const onRevokeAuth = () => {
|
||||
dialog.confirm({
|
||||
title: '退出登录',
|
||||
@@ -50,9 +52,11 @@ const SideBar: React.FC<SideBarProps> = (props) => {
|
||||
</AnimatePresence>
|
||||
<motion.div
|
||||
className={clsx(
|
||||
'overflow-hidden fixed top-0 left-0 h-full z-50 md:static shadow-md md:shadow-none rounded-r-md md:rounded-none',
|
||||
b64img ? 'bg-black/20 backdrop-blur-md border-r border-white/10' : 'bg-background',
|
||||
'md:bg-transparent md:border-r-0 md:backdrop-blur-none'
|
||||
'overflow-hidden fixed top-0 left-0 h-full z-50 md:static md:shadow-none rounded-r-2xl md:rounded-none',
|
||||
hasBackground
|
||||
? 'bg-transparent backdrop-blur-md'
|
||||
: 'bg-content1/70 backdrop-blur-xl backdrop-saturate-150 shadow-xl',
|
||||
'md:bg-transparent md:backdrop-blur-none md:backdrop-saturate-100 md:shadow-none'
|
||||
)}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: open ? '16rem' : 0 }}
|
||||
|
||||
@@ -97,11 +97,12 @@ const renderItems = (items: MenuItem[], children = false) => {
|
||||
: (
|
||||
<div
|
||||
className={clsx(
|
||||
'w-3 h-1.5 rounded-full ml-auto shadow-lg',
|
||||
'w-3 h-1.5 rounded-full ml-auto',
|
||||
isActive
|
||||
? 'bg-primary-500 animate-spinner-ease-spin'
|
||||
: 'bg-primary-200 dark:bg-white'
|
||||
? 'bg-primary-500 animate-nav-spin'
|
||||
: 'bg-primary-200 dark:bg-white shadow-lg'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Button } from '@heroui/button';
|
||||
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useRequest } from 'ahooks';
|
||||
import clsx from 'clsx';
|
||||
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6';
|
||||
import { FaCircleInfo, FaQq } from 'react-icons/fa6';
|
||||
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io';
|
||||
import { RiMacFill } from 'react-icons/ri';
|
||||
import { useState } from 'react';
|
||||
@@ -293,7 +293,11 @@ const UpdateDialogContent: React.FC<{
|
||||
const NewVersionTip = (props: NewVersionTipProps) => {
|
||||
const { currentVersion } = props;
|
||||
const dialog = useDialog();
|
||||
const { data: latestVersion, error } = useRequest(WebUIManager.getLatestTag);
|
||||
const { data: latestVersion, error } = useRequest(WebUIManager.getLatestTag, {
|
||||
cacheKey: 'napcat-latest-tag',
|
||||
staleTime: 10 * 60 * 1000,
|
||||
cacheTime: 30 * 60 * 1000,
|
||||
});
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
|
||||
|
||||
if (error || !latestVersion || !currentVersion || latestVersion === currentVersion) {
|
||||
@@ -359,17 +363,19 @@ const NewVersionTip = (props: NewVersionTipProps) => {
|
||||
|
||||
return (
|
||||
<Tooltip content='有新版本可用'>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
color='primary'
|
||||
variant='shadow'
|
||||
className='!w-5 !h-5 !min-w-0 text-small shadow-md'
|
||||
isLoading={updateStatus === 'updating'}
|
||||
onPress={showUpdateDialog}
|
||||
>
|
||||
<FaInfo />
|
||||
</Button>
|
||||
<div className="cursor-pointer" onClick={updateStatus === 'updating' ? undefined : showUpdateDialog}>
|
||||
<Chip
|
||||
size="sm"
|
||||
color="danger"
|
||||
variant="flat"
|
||||
classNames={{
|
||||
content: "font-bold text-[10px] px-1",
|
||||
base: "h-5 min-h-5"
|
||||
}}
|
||||
>
|
||||
{updateStatus === 'updating' ? <Spinner size="sm" color="danger" /> : 'New'}
|
||||
</Chip>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -383,7 +389,11 @@ const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false })
|
||||
data: packageData,
|
||||
loading: packageLoading,
|
||||
error: packageError,
|
||||
} = useRequest(WebUIManager.GetNapCatVersion);
|
||||
} = useRequest(WebUIManager.GetNapCatVersion, {
|
||||
cacheKey: 'napcat-version',
|
||||
staleTime: 60 * 60 * 1000,
|
||||
cacheTime: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const currentVersion = packageData?.version;
|
||||
|
||||
@@ -419,7 +429,11 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
||||
data: qqVersionData,
|
||||
loading: qqVersionLoading,
|
||||
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;
|
||||
|
||||
|
||||
@@ -142,11 +142,13 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
|
||||
systemUsage={Number(data?.cpu.usage.system) || 0}
|
||||
processUsage={Number(data?.cpu.usage.qq) || 0}
|
||||
title='CPU占用'
|
||||
hasBackground={hasBackground}
|
||||
/>
|
||||
<UsagePie
|
||||
systemUsage={memoryUsage.system}
|
||||
processUsage={memoryUsage.qq}
|
||||
title='内存占用'
|
||||
hasBackground={hasBackground}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
|
||||
@@ -1,143 +1,153 @@
|
||||
import * as echarts from 'echarts';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
interface UsagePieProps {
|
||||
systemUsage: number
|
||||
processUsage: number
|
||||
title?: string
|
||||
systemUsage: number;
|
||||
processUsage: number;
|
||||
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> = ({
|
||||
systemUsage,
|
||||
processUsage,
|
||||
title,
|
||||
hasBackground,
|
||||
}) => {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstance = useRef<echarts.ECharts | null>(null);
|
||||
const { theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (chartRef.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
const option = defaultOption;
|
||||
chartInstance.current.setOption(option);
|
||||
const observer = new ResizeObserver(() => {
|
||||
chartInstance.current?.resize();
|
||||
});
|
||||
observer.observe(chartRef.current);
|
||||
return () => {
|
||||
chartInstance.current?.dispose();
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
// Ensure values are clean and consistent
|
||||
// Process usage cannot exceed system usage, and system usage cannot be less than process usage.
|
||||
const rawSystem = Math.max(systemUsage || 0, 0);
|
||||
const rawProcess = Math.max(processUsage || 0, 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.setOption({
|
||||
series: [
|
||||
{
|
||||
label: {
|
||||
formatter: title,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}, [title]);
|
||||
const cleanSystem = Math.min(Math.max(rawSystem, rawProcess), 100);
|
||||
const cleanProcess = Math.min(rawProcess, cleanSystem);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.setOption({
|
||||
darkMode: theme === 'dark',
|
||||
tooltip: {
|
||||
backgroundColor:
|
||||
theme === 'dark'
|
||||
? 'rgba(0, 0, 0, 0.8)'
|
||||
: 'rgba(255, 255, 255, 0.8)',
|
||||
textStyle: {
|
||||
color: theme === 'dark' ? '#fff' : '#333',
|
||||
},
|
||||
},
|
||||
color:
|
||||
theme === 'dark'
|
||||
? ['#D33FF0', '#EF8664', '#E25180']
|
||||
: ['#D33FF0', '#EA7D9B', '#FFC107'],
|
||||
series: [
|
||||
{
|
||||
itemStyle: {
|
||||
borderColor: theme === 'dark' ? '#333' : '#F0A9A7',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}, [theme]);
|
||||
// SVG Config
|
||||
const size = 100;
|
||||
const strokeWidth = 10;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const center = size / 2;
|
||||
|
||||
useEffect(() => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.setOption({
|
||||
series: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
value: processUsage,
|
||||
name: 'QQ占用',
|
||||
},
|
||||
{
|
||||
value: systemUsage - processUsage,
|
||||
name: '其他进程占用',
|
||||
},
|
||||
{
|
||||
value: 100 - systemUsage,
|
||||
name: '剩余系统总量',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}, [systemUsage, processUsage]);
|
||||
// Colors
|
||||
const colors = {
|
||||
qq: '#D33FF0',
|
||||
other: theme === 'dark' ? '#EF8664' : '#EA7D9B',
|
||||
track: theme === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)',
|
||||
};
|
||||
|
||||
return <div ref={chartRef} className='w-36 h-36 flex-shrink-0' />;
|
||||
// Dash Arrays
|
||||
// 1. Total System Usage (QQ + Others)
|
||||
const systemDash = useMemo(() => {
|
||||
return `${(cleanSystem / 100) * circumference} ${circumference}`;
|
||||
}, [cleanSystem, circumference]);
|
||||
|
||||
// 2. QQ Usage (Subset of System)
|
||||
const processDash = useMemo(() => {
|
||||
return `${(cleanProcess / 100) * circumference} ${circumference}`;
|
||||
}, [cleanProcess, circumference]);
|
||||
|
||||
// 计算其他进程占用(系统总占用 - QQ占用)
|
||||
const otherUsage = Math.max(cleanSystem - cleanProcess, 0);
|
||||
|
||||
// Tooltip 内容
|
||||
const tooltipContent = (
|
||||
<div className='flex flex-col gap-1 p-1 text-xs'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='w-2 h-2 rounded-full' style={{ backgroundColor: colors.qq }} />
|
||||
<span>QQ进程: {cleanProcess.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='w-2 h-2 rounded-full' style={{ backgroundColor: colors.other }} />
|
||||
<span>其他进程: {otherUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='w-2 h-2 rounded-full' style={{ backgroundColor: colors.track }} />
|
||||
<span>空闲: {(100 - cleanSystem).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipContent} placement='top'>
|
||||
<div className='relative w-36 h-36 flex items-center justify-center cursor-pointer'>
|
||||
<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>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsagePie;
|
||||
|
||||
@@ -36,13 +36,23 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
const { theme } = useTheme();
|
||||
useEffect(() => {
|
||||
// 根据屏幕宽度决定字体大小,手机端使用更小的字体
|
||||
const isMobile = window.innerWidth < 768;
|
||||
const fontSize = isMobile ? 11 : 14;
|
||||
const width = window.innerWidth;
|
||||
// 按屏幕宽度自适应字体大小
|
||||
let fontSize = 16;
|
||||
if (width < 400) {
|
||||
fontSize = 4;
|
||||
} else if (width < 600) {
|
||||
fontSize = 5;
|
||||
} else if (width < 900) {
|
||||
fontSize = 6;
|
||||
} else if (width < 1280) {
|
||||
fontSize = 12;
|
||||
} // ≥1280: 16
|
||||
|
||||
const terminal = new Terminal({
|
||||
allowTransparency: true,
|
||||
fontFamily:
|
||||
'"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace',
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", "JetBrains Mono", monospace',
|
||||
cursorInactiveStyle: 'outline',
|
||||
drawBoldTextInBrightColors: false,
|
||||
fontSize: fontSize,
|
||||
@@ -60,10 +70,8 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(domRef.current!);
|
||||
|
||||
// 只在非手机端使用 Canvas 渲染器,手机端使用默认 DOM 渲染器以避免渲染问题
|
||||
if (!isMobile) {
|
||||
terminal.loadAddon(new CanvasAddon());
|
||||
}
|
||||
// 所有端都使用 Canvas 渲染器(包括手机端)
|
||||
terminal.loadAddon(new CanvasAddon());
|
||||
terminal.onData((data) => {
|
||||
if (onInput) {
|
||||
onInput(data);
|
||||
|
||||
@@ -8,7 +8,7 @@ import '@/styles/globals.css';
|
||||
|
||||
import key from './const/key';
|
||||
import WebUIManager from './controllers/webui_manager';
|
||||
import { loadTheme } from './utils/theme';
|
||||
import { initFont, loadTheme } from './utils/theme';
|
||||
|
||||
WebUIManager.checkWebUiLogined();
|
||||
|
||||
@@ -24,6 +24,7 @@ if (theme && !theme.startsWith('"')) {
|
||||
}
|
||||
|
||||
loadTheme();
|
||||
initFont();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
// <React.StrictMode>
|
||||
@@ -34,3 +35,19 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
</BrowserRouter>
|
||||
// </React.StrictMode>
|
||||
);
|
||||
|
||||
if (!import.meta.env.DEV) {
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
const baseUrl = import.meta.env.BASE_URL;
|
||||
const swUrl = `${baseUrl}sw.js`;
|
||||
navigator.serviceWorker.register(swUrl, { scope: baseUrl })
|
||||
.then((registration) => {
|
||||
console.log('SW registered: ', registration);
|
||||
})
|
||||
.catch((registrationError) => {
|
||||
console.log('SW registration failed: ', registrationError);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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,28 +1,33 @@
|
||||
import { Accordion, AccordionItem } from '@heroui/accordion';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { useRequest } from 'ahooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||
import { Controller, useForm, useWatch } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
import { FaUserAstronaut } from 'react-icons/fa';
|
||||
import { FaFont, FaUserAstronaut, FaCheck } from 'react-icons/fa';
|
||||
import { FaPaintbrush } from 'react-icons/fa6';
|
||||
import { IoIosColorPalette } from 'react-icons/io';
|
||||
import { IoIosColorPalette, IoMdRefresh } from 'react-icons/io';
|
||||
import { MdDarkMode, MdLightMode } from 'react-icons/md';
|
||||
|
||||
import themes from '@/const/themes';
|
||||
|
||||
import ColorPicker from '@/components/ColorPicker';
|
||||
import SaveButtons from '@/components/button/save_buttons';
|
||||
import FileInput from '@/components/input/file_input';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
|
||||
import { colorKeys, generateTheme, loadTheme } from '@/utils/theme';
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
import { applyFont, colorKeys, generateTheme, loadTheme, updateFontCache } from '@/utils/theme';
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
|
||||
export type PreviewThemeCardProps = {
|
||||
theme: ThemeInfo;
|
||||
onPreview: () => void;
|
||||
isSelected?: boolean;
|
||||
};
|
||||
|
||||
const values = [
|
||||
@@ -47,7 +52,7 @@ const colors = [
|
||||
'default',
|
||||
];
|
||||
|
||||
function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
|
||||
function PreviewThemeCard ({ theme, onPreview, isSelected }: PreviewThemeCardProps) {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = generateTheme(theme.theme, theme.name);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
@@ -64,8 +69,19 @@ function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
|
||||
radius='sm'
|
||||
isPressable
|
||||
onPress={onPreview}
|
||||
className={clsx('text-primary bg-primary-50', theme.name)}
|
||||
className={clsx(
|
||||
'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'>
|
||||
<div className='px-1 rounded-md bg-primary text-primary-foreground'>
|
||||
{theme.name}
|
||||
@@ -74,20 +90,20 @@ function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
|
||||
<FaUserAstronaut />
|
||||
{theme.author ?? '未知'}
|
||||
</div>
|
||||
<div className='text-xs text-primary-200'>{theme.description}</div>
|
||||
<div className='text-xs text-primary-200 whitespace-nowrap overflow-hidden text-ellipsis w-full'>{theme.description}</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className='flex flex-col gap-1'>
|
||||
{colors.map((color) => (
|
||||
<div className='flex gap-1 items-center flex-wrap' key={color}>
|
||||
<div className='text-xs w-4 text-right'>
|
||||
<div className='flex gap-1 items-center flex-nowrap' key={color}>
|
||||
<div className='text-xs w-4 text-right flex-shrink-0'>
|
||||
{color[0].toUpperCase()}
|
||||
</div>
|
||||
{values.map((value) => (
|
||||
<div
|
||||
key={value}
|
||||
className={clsx(
|
||||
'w-2 h-2 rounded-full shadow-small',
|
||||
'w-2 h-2 rounded-full shadow-small flex-shrink-0',
|
||||
`bg-${color}${value}`
|
||||
)}
|
||||
/>
|
||||
@@ -100,6 +116,29 @@ function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// 比较两个主题配置是否相同(不比较 fontMode)
|
||||
const isThemeColorsEqual = (a: ThemeConfig, b: ThemeConfig): boolean => {
|
||||
if (!a || !b) return false;
|
||||
const aKeys = [...Object.keys(a.light || {}), ...Object.keys(a.dark || {})];
|
||||
const bKeys = [...Object.keys(b.light || {}), ...Object.keys(b.dark || {})];
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
|
||||
for (const key of Object.keys(a.light || {})) {
|
||||
if (a.light?.[key as keyof ThemeConfigItem] !== b.light?.[key as keyof ThemeConfigItem]) return false;
|
||||
}
|
||||
for (const key of Object.keys(a.dark || {})) {
|
||||
if (a.dark?.[key as keyof ThemeConfigItem] !== b.dark?.[key as keyof ThemeConfigItem]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 字体模式显示名称映射
|
||||
const fontModeNames: Record<string, string> = {
|
||||
aacute: 'Aa 偷吃可爱长大的',
|
||||
system: '系统默认',
|
||||
custom: '自定义字体',
|
||||
};
|
||||
|
||||
const ThemeConfigCard = () => {
|
||||
const { data, loading, error, refreshAsync } = useRequest(
|
||||
WebUIManager.getThemeConfig
|
||||
@@ -116,19 +155,29 @@ const ThemeConfigCard = () => {
|
||||
theme: {
|
||||
dark: {},
|
||||
light: {},
|
||||
fontMode: 'aacute',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [dataLoaded, setDataLoaded] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
// 使用 useRef 存储 style 标签引用
|
||||
const styleTagRef = useRef<HTMLStyleElement | null>(null);
|
||||
const originalDataRef = useRef<ThemeConfig | null>(null);
|
||||
|
||||
// 在组件挂载时创建 style 标签,并在卸载时清理
|
||||
// 同时在卸载时恢复字体到已保存的状态(避免"伪自动保存"问题)
|
||||
useEffect(() => {
|
||||
const styleTag = document.createElement('style');
|
||||
document.head.appendChild(styleTag);
|
||||
styleTagRef.current = styleTag;
|
||||
return () => {
|
||||
// 组件卸载时,恢复到已保存的字体设置
|
||||
if (originalDataRef.current?.fontMode) {
|
||||
applyFont(originalDataRef.current.fontMode);
|
||||
}
|
||||
if (styleTagRef.current) {
|
||||
document.head.removeChild(styleTagRef.current);
|
||||
}
|
||||
@@ -137,13 +186,45 @@ const ThemeConfigCard = () => {
|
||||
|
||||
const theme = useWatch({ control, name: 'theme' });
|
||||
|
||||
const reset = () => {
|
||||
if (data) setOnebotValue('theme', data);
|
||||
};
|
||||
// 检测是否有未保存的更改
|
||||
useEffect(() => {
|
||||
if (originalDataRef.current && dataLoaded) {
|
||||
const colorsChanged = !isThemeColorsEqual(theme, originalDataRef.current);
|
||||
const fontChanged = theme.fontMode !== originalDataRef.current.fontMode;
|
||||
setHasUnsavedChanges(colorsChanged || fontChanged);
|
||||
}
|
||||
}, [theme, dataLoaded]);
|
||||
|
||||
const onSubmit = handleOnebotSubmit(async (data) => {
|
||||
const reset = useCallback(() => {
|
||||
if (data) {
|
||||
setOnebotValue('theme', data);
|
||||
originalDataRef.current = data;
|
||||
// 应用已保存的字体设置
|
||||
if (data.fontMode) {
|
||||
applyFont(data.fontMode);
|
||||
}
|
||||
}
|
||||
setDataLoaded(true);
|
||||
setHasUnsavedChanges(false);
|
||||
}, [data, setOnebotValue]);
|
||||
|
||||
// 实时应用字体预设(预览)
|
||||
useEffect(() => {
|
||||
if (dataLoaded && theme.fontMode) {
|
||||
applyFont(theme.fontMode);
|
||||
}
|
||||
}, [theme.fontMode, dataLoaded]);
|
||||
|
||||
const onSubmit = handleOnebotSubmit(async (formData) => {
|
||||
try {
|
||||
await WebUIManager.setThemeConfig(data.theme);
|
||||
await WebUIManager.setThemeConfig(formData.theme);
|
||||
// 更新原始数据引用
|
||||
originalDataRef.current = formData.theme;
|
||||
// 更新字体缓存
|
||||
if (formData.theme.fontMode) {
|
||||
updateFontCache(formData.theme.fontMode);
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
toast.success('保存成功');
|
||||
loadTheme();
|
||||
} catch (error) {
|
||||
@@ -164,7 +245,7 @@ const ThemeConfigCard = () => {
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [data]);
|
||||
}, [data, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme && styleTagRef.current) {
|
||||
@@ -173,6 +254,23 @@ const ThemeConfigCard = () => {
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// 找到当前选中的主题(预览中的)
|
||||
const selectedThemeName = useMemo(() => {
|
||||
return themes.find(t => isThemeColorsEqual(t.theme, theme))?.name;
|
||||
}, [theme]);
|
||||
|
||||
// 找到已保存的主题名称
|
||||
const savedThemeName = useMemo(() => {
|
||||
if (!originalDataRef.current) return null;
|
||||
return themes.find(t => isThemeColorsEqual(t.theme, originalDataRef.current!))?.name || '自定义';
|
||||
}, [dataLoaded, hasUnsavedChanges]);
|
||||
|
||||
// 已保存的字体模式显示名称
|
||||
const savedFontModeDisplayName = useMemo(() => {
|
||||
const mode = originalDataRef.current?.fontMode || 'aacute';
|
||||
return fontModeNames[mode] || mode;
|
||||
}, [dataLoaded, hasUnsavedChanges]);
|
||||
|
||||
if (loading) return <PageLoading loading />;
|
||||
|
||||
if (error) {
|
||||
@@ -185,96 +283,209 @@ const ThemeConfigCard = () => {
|
||||
<>
|
||||
<title>主题配置 - NapCat WebUI</title>
|
||||
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
refresh={onRefresh}
|
||||
className='items-end w-full p-4'
|
||||
/>
|
||||
<div className='px-4 text-sm text-default-600'>实时预览,记得保存!</div>
|
||||
<Accordion variant='splitted' defaultExpandedKeys={['select']}>
|
||||
<AccordionItem
|
||||
key='select'
|
||||
aria-label='Pick Color'
|
||||
title='选择主题'
|
||||
subtitle='可以切换夜间/白昼模式查看对应颜色'
|
||||
className='shadow-small'
|
||||
startContent={<IoIosColorPalette />}
|
||||
>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{themes.map((theme) => (
|
||||
<PreviewThemeCard
|
||||
key={theme.name}
|
||||
theme={theme}
|
||||
onPreview={() => {
|
||||
setOnebotValue('theme', theme.theme);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* 顶部操作栏 */}
|
||||
<div className='sticky top-0 z-20 bg-background/80 backdrop-blur-md border-b border-divider'>
|
||||
<div className='flex items-center justify-between p-4'>
|
||||
<div className='flex items-center gap-3 flex-wrap'>
|
||||
<div className='flex items-center gap-2 text-sm'>
|
||||
<span className='text-default-400'>当前主题:</span>
|
||||
<Chip size='sm' color='primary' variant='flat'>
|
||||
{savedThemeName || '加载中...'}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-sm'>
|
||||
<span className='text-default-400'>字体:</span>
|
||||
<Chip size='sm' color='secondary' variant='flat'>
|
||||
{savedFontModeDisplayName}
|
||||
</Chip>
|
||||
</div>
|
||||
{hasUnsavedChanges && (
|
||||
<Chip size='sm' color='warning' variant='solid'>
|
||||
有未保存的更改
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
radius='full'
|
||||
variant='flat'
|
||||
className='font-medium bg-default-100 text-default-600 dark:bg-default-50/50'
|
||||
onPress={() => {
|
||||
reset();
|
||||
toast.success('已重置');
|
||||
}}
|
||||
isDisabled={!hasUnsavedChanges}
|
||||
>
|
||||
取消更改
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
color='primary'
|
||||
radius='full'
|
||||
className='font-medium shadow-md shadow-primary/20'
|
||||
isLoading={isSubmitting}
|
||||
onPress={() => onSubmit()}
|
||||
isDisabled={!hasUnsavedChanges}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
variant='flat'
|
||||
className='text-default-500 bg-default-100 dark:bg-default-50/50'
|
||||
onPress={onRefresh}
|
||||
>
|
||||
<IoMdRefresh size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccordionItem
|
||||
key='pick'
|
||||
aria-label='Pick Color'
|
||||
title='自定义配色'
|
||||
className='shadow-small'
|
||||
startContent={<FaPaintbrush />}
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
{(['dark', 'light'] as const).map((mode) => (
|
||||
<div
|
||||
key={mode}
|
||||
className={clsx(
|
||||
'p-2 rounded-md',
|
||||
mode === 'dark' ? 'text-white' : 'text-black',
|
||||
mode === 'dark'
|
||||
? 'bg-content1-foreground dark:bg-content1'
|
||||
: 'bg-content1 dark:bg-content1-foreground'
|
||||
)}
|
||||
>
|
||||
<h3 className='text-center p-2 rounded-md bg-content2 mb-2 text-default-800 flex items-center justify-center'>
|
||||
{mode === 'dark'
|
||||
? (
|
||||
<MdDarkMode size={24} />
|
||||
)
|
||||
: (
|
||||
<MdLightMode size={24} />
|
||||
)}
|
||||
{mode === 'dark' ? '夜间模式主题' : '白昼模式主题'}
|
||||
</h3>
|
||||
{colorKeys.map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className='grid grid-cols-2 items-center mb-2 gap-2'
|
||||
<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
|
||||
>
|
||||
<label className='text-right'>{key}</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`theme.${mode}.${key}`}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const hslArray = value?.split(' ') ?? [0, 0, 0];
|
||||
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
|
||||
return (
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(result) => {
|
||||
onChange(
|
||||
`${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<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>
|
||||
<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>
|
||||
</Accordion>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
key='select'
|
||||
aria-label='Pick Color'
|
||||
title='选择主题'
|
||||
subtitle='点击主题卡片即可预览,记得保存'
|
||||
className='shadow-small'
|
||||
startContent={<IoIosColorPalette />}
|
||||
>
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3'>
|
||||
{themes.map((t) => (
|
||||
<PreviewThemeCard
|
||||
key={t.name}
|
||||
theme={t}
|
||||
isSelected={selectedThemeName === t.name}
|
||||
onPreview={() => {
|
||||
setOnebotValue('theme', { ...t.theme, fontMode: theme.fontMode });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
key='pick'
|
||||
aria-label='Pick Color'
|
||||
title='自定义配色'
|
||||
subtitle='精细调整每个颜色变量'
|
||||
className='shadow-small'
|
||||
startContent={<FaPaintbrush />}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
{(['light', 'dark'] as const).map((mode) => (
|
||||
<div
|
||||
key={mode}
|
||||
className={clsx(
|
||||
'p-4 rounded-lg',
|
||||
mode === 'dark' ? 'bg-zinc-900 text-white' : 'bg-zinc-100 text-black'
|
||||
)}
|
||||
>
|
||||
<h3 className='flex items-center justify-center gap-2 p-2 rounded-md bg-opacity-20 mb-4 font-medium'>
|
||||
{mode === 'dark' ? <MdDarkMode size={20} /> : <MdLightMode size={20} />}
|
||||
{mode === 'dark' ? '深色模式' : '浅色模式'}
|
||||
</h3>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
|
||||
{colorKeys.map((colorKey) => (
|
||||
<div
|
||||
key={colorKey}
|
||||
className='flex items-center gap-2 p-2 rounded bg-black/5 dark:bg-white/5'
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`theme.${mode}.${colorKey}`}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const hslArray = value?.split(' ') ?? [0, 0, 0];
|
||||
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
|
||||
return (
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(result) => {
|
||||
onChange(
|
||||
`${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className='text-xs font-mono truncate flex-1' title={colorKey}>
|
||||
{colorKey.replace('--heroui-', '')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,11 +7,9 @@ import toast from 'react-hot-toast';
|
||||
import key from '@/const/key';
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons';
|
||||
import FileInput from '@/components/input/file_input';
|
||||
import ImageInput from '@/components/input/image_input';
|
||||
|
||||
import { siteConfig } from '@/config/site';
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
|
||||
// Base64URL to Uint8Array converter
|
||||
@@ -37,10 +35,10 @@ const WebUIConfigCard = () => {
|
||||
handleSubmit: handleWebuiSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue: setWebuiValue,
|
||||
} = useForm<IConfig['webui']>({
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
background: '',
|
||||
customIcons: {},
|
||||
customIcons: {} as Record<string, string>,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -92,39 +90,6 @@ const WebUIConfigCard = () => {
|
||||
return (
|
||||
<>
|
||||
<title>WebUI配置 - NapCat WebUI</title>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>WebUI字体</div>
|
||||
<div className='text-sm text-default-400'>
|
||||
此项不需要手动保存,上传成功后需清空网页缓存并刷新
|
||||
<FileInput
|
||||
label='中文字体'
|
||||
placeholder='选择字体文件'
|
||||
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>
|
||||
<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>
|
||||
<Controller
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @stylistic/indent */
|
||||
import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
@@ -320,9 +321,9 @@ export default function FileManagerPage () {
|
||||
}
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
|
||||
onDrop,
|
||||
noClick: true,
|
||||
noClick: true, // 禁用自动点击,使用 open() 手动触发
|
||||
onDragOver: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -336,13 +337,15 @@ export default function FileManagerPage () {
|
||||
return (
|
||||
<div className='h-full flex flex-col relative gap-4 w-full p-2 md:p-4'>
|
||||
<div className={clsx(
|
||||
'mb-4 flex flex-col md:flex-row items-stretch md:items-center gap-4 sticky top-14 z-10 backdrop-blur-sm shadow-sm py-2 px-4 rounded-xl transition-colors',
|
||||
'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-sm transition-colors',
|
||||
hasBackground
|
||||
? 'bg-white/20 dark:bg-black/10 border border-white/40 dark:border-white/10'
|
||||
: 'bg-white/60 dark:bg-black/40 border border-white/40 dark:border-white/10'
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-x-auto hide-scrollbar pb-1 md:pb-0'>
|
||||
<Button
|
||||
radius='sm'
|
||||
color='primary'
|
||||
size='sm'
|
||||
isIconOnly
|
||||
@@ -354,6 +357,7 @@ export default function FileManagerPage () {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
radius='sm'
|
||||
color='primary'
|
||||
size='sm'
|
||||
isIconOnly
|
||||
@@ -365,6 +369,7 @@ export default function FileManagerPage () {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
radius='sm'
|
||||
color='primary'
|
||||
isLoading={loading}
|
||||
size='sm'
|
||||
@@ -376,6 +381,7 @@ export default function FileManagerPage () {
|
||||
<MdRefresh />
|
||||
</Button>
|
||||
<Button
|
||||
radius='sm'
|
||||
color='primary'
|
||||
size='sm'
|
||||
isIconOnly
|
||||
@@ -390,6 +396,7 @@ export default function FileManagerPage () {
|
||||
selectedFiles === 'all') && (
|
||||
<>
|
||||
<Button
|
||||
radius='sm'
|
||||
color='primary'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
@@ -402,6 +409,7 @@ export default function FileManagerPage () {
|
||||
)
|
||||
</Button>
|
||||
<Button
|
||||
radius='sm'
|
||||
color='primary'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
@@ -417,6 +425,7 @@ export default function FileManagerPage () {
|
||||
)
|
||||
</Button>
|
||||
<Button
|
||||
radius='sm'
|
||||
color='primary'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
@@ -433,7 +442,10 @@ export default function FileManagerPage () {
|
||||
</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'>
|
||||
<Breadcrumbs
|
||||
radius='sm'
|
||||
className='flex-1 bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border border-white/20 px-2 py-2 rounded-sm overflow-x-auto hide-scrollbar whitespace-nowrap'
|
||||
>
|
||||
{currentPath.split('/').map((part, index, parts) => (
|
||||
<BreadcrumbItem
|
||||
key={part}
|
||||
@@ -448,6 +460,7 @@ export default function FileManagerPage () {
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
<Input
|
||||
radius='sm'
|
||||
type='text'
|
||||
placeholder='输入跳转路径'
|
||||
value={jumpPath}
|
||||
@@ -470,7 +483,7 @@ export default function FileManagerPage () {
|
||||
animate={{ height: showUpload ? 'auto' : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={clsx(
|
||||
'border-dashed rounded-lg text-center',
|
||||
'border-dashed rounded-sm text-center overflow-hidden',
|
||||
isDragActive ? 'border-primary bg-primary/10' : 'border-default-300',
|
||||
showUpload ? 'mb-4 border-2' : 'border-none'
|
||||
)}
|
||||
@@ -479,9 +492,15 @@ export default function FileManagerPage () {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div {...getRootProps()} className='w-full h-full p-4'>
|
||||
<div {...getRootProps()} className='w-full h-full p-4 cursor-pointer hover:bg-default-100 transition-colors'>
|
||||
<input {...getInputProps()} multiple />
|
||||
<p>拖拽文件或文件夹到此处上传,或点击选择文件</p>
|
||||
<div className='flex flex-col items-center gap-2'>
|
||||
<FiUpload className='text-3xl text-primary' />
|
||||
<p className='text-default-600'>拖拽文件到此处上传</p>
|
||||
<Button radius='sm' color='primary' size='sm' variant='flat' onPress={open}>
|
||||
点击选择文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Card, CardBody } from '@heroui/card';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
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';
|
||||
@@ -65,7 +65,7 @@ export interface SystemStatusCardProps {
|
||||
setArchInfo: (arch: string | undefined) => void;
|
||||
}
|
||||
const SystemStatusCard: React.FC<SystemStatusCardProps> = ({ setArchInfo }) => {
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus>();
|
||||
const [systemStatus, setSystemStatus] = useLocalStorage<SystemStatus | undefined>('napcat_system_status_cache', undefined);
|
||||
const isSetted = useRef(false);
|
||||
const getStatus = useCallback(() => {
|
||||
try {
|
||||
@@ -94,7 +94,7 @@ const SystemStatusCard: React.FC<SystemStatusCardProps> = ({ setArchInfo }) => {
|
||||
};
|
||||
|
||||
const DashboardIndexPage: React.FC = () => {
|
||||
const [archInfo, setArchInfo] = useState<string>();
|
||||
const [archInfo, setArchInfo] = useLocalStorage<string | undefined>('napcat_arch_info_cache', undefined);
|
||||
// @ts-ignore
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
@@ -102,7 +102,7 @@ const DashboardIndexPage: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<title>基础信息 - NapCat WebUI</title>
|
||||
<section className='w-full p-2 md:p-4 md:max-w-[1000px] mx-auto'>
|
||||
<section className='w-full p-2 md:p-4 md:max-w-[1000px] mx-auto overflow-hidden'>
|
||||
<div className='grid grid-cols-1 lg:grid-cols-3 gap-4 items-stretch'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<QQInfo />
|
||||
@@ -112,10 +112,11 @@ const DashboardIndexPage: React.FC = () => {
|
||||
</div>
|
||||
<Networks />
|
||||
<Card className={clsx(
|
||||
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all',
|
||||
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all overflow-hidden',
|
||||
hasBackground ? 'bg-white/10 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
|
||||
)}>
|
||||
<CardBody>
|
||||
)}
|
||||
>
|
||||
<CardBody className='overflow-hidden'>
|
||||
<Hitokoto />
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function TerminalPage () {
|
||||
variant='flat'
|
||||
onPress={createNewTerminal}
|
||||
startContent={<IoAdd />}
|
||||
className='text-xl'
|
||||
className='text-xl ml-auto'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-grow overflow-hidden'>
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
@font-face {
|
||||
font-family: 'Aa偷吃可爱长大的';
|
||||
src: url('/fonts/AaCute.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/fonts/JetBrainsMono.ttf') format('truetype');
|
||||
src: url('/webui/fonts/JetBrainsMono.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/fonts/JetBrainsMono-Italic.ttf') format('truetype');
|
||||
src: url('/webui/fonts/JetBrainsMono-Italic.ttf') format('truetype');
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
@@ -5,40 +5,42 @@
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family:
|
||||
'Quicksand',
|
||||
'Nunito',
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Arial,
|
||||
'PingFang SC',
|
||||
'Microsoft YaHei',
|
||||
sans-serif !important;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-smooth: always;
|
||||
letter-spacing: 0.02em;
|
||||
font-family: var(--font-family-base, 'Quicksand', 'Nunito', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif) !important;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-smooth: always;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
:root {
|
||||
--heroui-primary: 217.2 91.2% 59.8%; /* 自然的现代蓝 */
|
||||
/* 字体变量:可被 JS 动态覆盖 */
|
||||
--font-family-fallbacks: 'Quicksand', 'Nunito', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-family-base: var(--font-family-fallbacks);
|
||||
--heroui-primary: 217.2 91.2% 59.8%;
|
||||
/* 自然的现代蓝 */
|
||||
--heroui-primary-foreground: 210 40% 98%;
|
||||
--heroui-radius: 0.75rem;
|
||||
--text-primary: 222.2 47.4% 11.2%;
|
||||
--text-secondary: 215.4 16.3% 46.9%;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
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 {
|
||||
.dark h1,
|
||||
.dark h2,
|
||||
.dark h3,
|
||||
.dark h4,
|
||||
.dark h5,
|
||||
.dark h6 {
|
||||
color: hsl(210 40% 98%);
|
||||
}
|
||||
|
||||
@@ -52,11 +54,13 @@ h1, h2, h3, h4, h5, h6 {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar-thumb {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar-track {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
@@ -80,7 +84,8 @@ h1, h2, h3, h4, h5, h6 {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 182, 193, 0.4); /* 浅粉色滚动条 */
|
||||
background-color: rgba(255, 182, 193, 0.4);
|
||||
/* 浅粉色滚动条 */
|
||||
border-radius: 3px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
@@ -123,16 +128,18 @@ h1, h2, h3, h4, h5, h6 {
|
||||
|
||||
.context-view.monaco-menu-container * {
|
||||
font-family:
|
||||
PingFang SC,
|
||||
'Aa偷吃可爱长大的',
|
||||
Helvetica Neue,
|
||||
Microsoft YaHei,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
'PingFang SC',
|
||||
'Microsoft YaHei',
|
||||
sans-serif !important;
|
||||
}
|
||||
|
||||
.ql-hidden {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.ql-editor img {
|
||||
@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,191 +1,192 @@
|
||||
interface ServerResponse<T> {
|
||||
code: number
|
||||
data: T
|
||||
message: string
|
||||
code: number;
|
||||
data: T;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
Credential: string
|
||||
Credential: string;
|
||||
}
|
||||
|
||||
interface LoginListItem {
|
||||
uin: string
|
||||
uid: string
|
||||
nickName: string
|
||||
faceUrl: string
|
||||
facePath: string
|
||||
loginType: 1 // 1是二维码登录?
|
||||
isQuickLogin: boolean // 是否可以快速登录
|
||||
isAutoLogin: boolean // 是否可以自动登录
|
||||
uin: string;
|
||||
uid: string;
|
||||
nickName: string;
|
||||
faceUrl: string;
|
||||
facePath: string;
|
||||
loginType: 1; // 1是二维码登录?
|
||||
isQuickLogin: boolean; // 是否可以快速登录
|
||||
isAutoLogin: boolean; // 是否可以自动登录
|
||||
}
|
||||
|
||||
interface PackageInfo {
|
||||
name: string
|
||||
version: string
|
||||
private: boolean
|
||||
type: string
|
||||
scripts: Record<string, string>
|
||||
dependencies: Record<string, string>
|
||||
devDependencies: Record<string, string>
|
||||
name: string;
|
||||
version: string;
|
||||
private: boolean;
|
||||
type: string;
|
||||
scripts: Record<string, string>;
|
||||
dependencies: Record<string, string>;
|
||||
devDependencies: Record<string, string>;
|
||||
}
|
||||
|
||||
interface SystemStatus {
|
||||
cpu: {
|
||||
core: number
|
||||
model: string
|
||||
speed: string
|
||||
core: number;
|
||||
model: string;
|
||||
speed: string;
|
||||
usage: {
|
||||
system: string
|
||||
qq: string
|
||||
}
|
||||
}
|
||||
system: string;
|
||||
qq: string;
|
||||
};
|
||||
};
|
||||
memory: {
|
||||
total: string
|
||||
total: string;
|
||||
usage: {
|
||||
system: string
|
||||
qq: string
|
||||
}
|
||||
}
|
||||
arch: string
|
||||
system: string;
|
||||
qq: string;
|
||||
};
|
||||
};
|
||||
arch: string;
|
||||
}
|
||||
|
||||
interface ThemeConfigItem {
|
||||
'--heroui-background': string
|
||||
'--heroui-foreground-50': string
|
||||
'--heroui-foreground-100': string
|
||||
'--heroui-foreground-200': string
|
||||
'--heroui-foreground-300': string
|
||||
'--heroui-foreground-400': string
|
||||
'--heroui-foreground-500': string
|
||||
'--heroui-foreground-600': string
|
||||
'--heroui-foreground-700': string
|
||||
'--heroui-foreground-800': string
|
||||
'--heroui-foreground-900': string
|
||||
'--heroui-foreground': string
|
||||
'--heroui-focus': string
|
||||
'--heroui-overlay': string
|
||||
'--heroui-divider': string
|
||||
'--heroui-divider-opacity': string
|
||||
'--heroui-content1': string
|
||||
'--heroui-content1-foreground': string
|
||||
'--heroui-content2': string
|
||||
'--heroui-content2-foreground': string
|
||||
'--heroui-content3': string
|
||||
'--heroui-content3-foreground': string
|
||||
'--heroui-content4': string
|
||||
'--heroui-content4-foreground': string
|
||||
'--heroui-default-50': string
|
||||
'--heroui-default-100': string
|
||||
'--heroui-default-200': string
|
||||
'--heroui-default-300': string
|
||||
'--heroui-default-400': string
|
||||
'--heroui-default-500': string
|
||||
'--heroui-default-600': string
|
||||
'--heroui-default-700': string
|
||||
'--heroui-default-800': string
|
||||
'--heroui-default-900': string
|
||||
'--heroui-default-foreground': string
|
||||
'--heroui-default': string
|
||||
'--heroui-background': string;
|
||||
'--heroui-foreground-50': string;
|
||||
'--heroui-foreground-100': string;
|
||||
'--heroui-foreground-200': string;
|
||||
'--heroui-foreground-300': string;
|
||||
'--heroui-foreground-400': string;
|
||||
'--heroui-foreground-500': string;
|
||||
'--heroui-foreground-600': string;
|
||||
'--heroui-foreground-700': string;
|
||||
'--heroui-foreground-800': string;
|
||||
'--heroui-foreground-900': string;
|
||||
'--heroui-foreground': string;
|
||||
'--heroui-focus': string;
|
||||
'--heroui-overlay': string;
|
||||
'--heroui-divider': string;
|
||||
'--heroui-divider-opacity': string;
|
||||
'--heroui-content1': string;
|
||||
'--heroui-content1-foreground': string;
|
||||
'--heroui-content2': string;
|
||||
'--heroui-content2-foreground': string;
|
||||
'--heroui-content3': string;
|
||||
'--heroui-content3-foreground': string;
|
||||
'--heroui-content4': string;
|
||||
'--heroui-content4-foreground': string;
|
||||
'--heroui-default-50': string;
|
||||
'--heroui-default-100': string;
|
||||
'--heroui-default-200': string;
|
||||
'--heroui-default-300': string;
|
||||
'--heroui-default-400': string;
|
||||
'--heroui-default-500': string;
|
||||
'--heroui-default-600': string;
|
||||
'--heroui-default-700': string;
|
||||
'--heroui-default-800': string;
|
||||
'--heroui-default-900': string;
|
||||
'--heroui-default-foreground': string;
|
||||
'--heroui-default': string;
|
||||
// 新增 danger
|
||||
'--heroui-danger-50': string
|
||||
'--heroui-danger-100': string
|
||||
'--heroui-danger-200': string
|
||||
'--heroui-danger-300': string
|
||||
'--heroui-danger-400': string
|
||||
'--heroui-danger-500': string
|
||||
'--heroui-danger-600': string
|
||||
'--heroui-danger-700': string
|
||||
'--heroui-danger-800': string
|
||||
'--heroui-danger-900': string
|
||||
'--heroui-danger-foreground': string
|
||||
'--heroui-danger': string
|
||||
'--heroui-danger-50': string;
|
||||
'--heroui-danger-100': string;
|
||||
'--heroui-danger-200': string;
|
||||
'--heroui-danger-300': string;
|
||||
'--heroui-danger-400': string;
|
||||
'--heroui-danger-500': string;
|
||||
'--heroui-danger-600': string;
|
||||
'--heroui-danger-700': string;
|
||||
'--heroui-danger-800': string;
|
||||
'--heroui-danger-900': string;
|
||||
'--heroui-danger-foreground': string;
|
||||
'--heroui-danger': string;
|
||||
// 新增 primary
|
||||
'--heroui-primary-50': string
|
||||
'--heroui-primary-100': string
|
||||
'--heroui-primary-200': string
|
||||
'--heroui-primary-300': string
|
||||
'--heroui-primary-400': string
|
||||
'--heroui-primary-500': string
|
||||
'--heroui-primary-600': string
|
||||
'--heroui-primary-700': string
|
||||
'--heroui-primary-800': string
|
||||
'--heroui-primary-900': string
|
||||
'--heroui-primary-foreground': string
|
||||
'--heroui-primary': string
|
||||
'--heroui-primary-50': string;
|
||||
'--heroui-primary-100': string;
|
||||
'--heroui-primary-200': string;
|
||||
'--heroui-primary-300': string;
|
||||
'--heroui-primary-400': string;
|
||||
'--heroui-primary-500': string;
|
||||
'--heroui-primary-600': string;
|
||||
'--heroui-primary-700': string;
|
||||
'--heroui-primary-800': string;
|
||||
'--heroui-primary-900': string;
|
||||
'--heroui-primary-foreground': string;
|
||||
'--heroui-primary': string;
|
||||
// 新增 secondary
|
||||
'--heroui-secondary-50': string
|
||||
'--heroui-secondary-100': string
|
||||
'--heroui-secondary-200': string
|
||||
'--heroui-secondary-300': string
|
||||
'--heroui-secondary-400': string
|
||||
'--heroui-secondary-500': string
|
||||
'--heroui-secondary-600': string
|
||||
'--heroui-secondary-700': string
|
||||
'--heroui-secondary-800': string
|
||||
'--heroui-secondary-900': string
|
||||
'--heroui-secondary-foreground': string
|
||||
'--heroui-secondary': string
|
||||
'--heroui-secondary-50': string;
|
||||
'--heroui-secondary-100': string;
|
||||
'--heroui-secondary-200': string;
|
||||
'--heroui-secondary-300': string;
|
||||
'--heroui-secondary-400': string;
|
||||
'--heroui-secondary-500': string;
|
||||
'--heroui-secondary-600': string;
|
||||
'--heroui-secondary-700': string;
|
||||
'--heroui-secondary-800': string;
|
||||
'--heroui-secondary-900': string;
|
||||
'--heroui-secondary-foreground': string;
|
||||
'--heroui-secondary': string;
|
||||
// 新增 success
|
||||
'--heroui-success-50': string
|
||||
'--heroui-success-100': string
|
||||
'--heroui-success-200': string
|
||||
'--heroui-success-300': string
|
||||
'--heroui-success-400': string
|
||||
'--heroui-success-500': string
|
||||
'--heroui-success-600': string
|
||||
'--heroui-success-700': string
|
||||
'--heroui-success-800': string
|
||||
'--heroui-success-900': string
|
||||
'--heroui-success-foreground': string
|
||||
'--heroui-success': string
|
||||
'--heroui-success-50': string;
|
||||
'--heroui-success-100': string;
|
||||
'--heroui-success-200': string;
|
||||
'--heroui-success-300': string;
|
||||
'--heroui-success-400': string;
|
||||
'--heroui-success-500': string;
|
||||
'--heroui-success-600': string;
|
||||
'--heroui-success-700': string;
|
||||
'--heroui-success-800': string;
|
||||
'--heroui-success-900': string;
|
||||
'--heroui-success-foreground': string;
|
||||
'--heroui-success': string;
|
||||
// 新增 warning
|
||||
'--heroui-warning-50': string
|
||||
'--heroui-warning-100': string
|
||||
'--heroui-warning-200': string
|
||||
'--heroui-warning-300': string
|
||||
'--heroui-warning-400': string
|
||||
'--heroui-warning-500': string
|
||||
'--heroui-warning-600': string
|
||||
'--heroui-warning-700': string
|
||||
'--heroui-warning-800': string
|
||||
'--heroui-warning-900': string
|
||||
'--heroui-warning-foreground': string
|
||||
'--heroui-warning': string
|
||||
'--heroui-warning-50': string;
|
||||
'--heroui-warning-100': string;
|
||||
'--heroui-warning-200': string;
|
||||
'--heroui-warning-300': string;
|
||||
'--heroui-warning-400': string;
|
||||
'--heroui-warning-500': string;
|
||||
'--heroui-warning-600': string;
|
||||
'--heroui-warning-700': string;
|
||||
'--heroui-warning-800': string;
|
||||
'--heroui-warning-900': string;
|
||||
'--heroui-warning-foreground': string;
|
||||
'--heroui-warning': string;
|
||||
// 其它配置
|
||||
'--heroui-code-background': string
|
||||
'--heroui-strong': string
|
||||
'--heroui-code-mdx': string
|
||||
'--heroui-divider-weight': string
|
||||
'--heroui-disabled-opacity': string
|
||||
'--heroui-font-size-tiny': string
|
||||
'--heroui-font-size-small': string
|
||||
'--heroui-font-size-medium': string
|
||||
'--heroui-font-size-large': string
|
||||
'--heroui-line-height-tiny': string
|
||||
'--heroui-line-height-small': string
|
||||
'--heroui-line-height-medium': string
|
||||
'--heroui-line-height-large': string
|
||||
'--heroui-radius-small': string
|
||||
'--heroui-radius-medium': string
|
||||
'--heroui-radius-large': string
|
||||
'--heroui-border-width-small': string
|
||||
'--heroui-border-width-medium': string
|
||||
'--heroui-border-width-large': string
|
||||
'--heroui-box-shadow-small': string
|
||||
'--heroui-box-shadow-medium': string
|
||||
'--heroui-box-shadow-large': string
|
||||
'--heroui-hover-opacity': string
|
||||
'--heroui-code-background': string;
|
||||
'--heroui-strong': string;
|
||||
'--heroui-code-mdx': string;
|
||||
'--heroui-divider-weight': string;
|
||||
'--heroui-disabled-opacity': string;
|
||||
'--heroui-font-size-tiny': string;
|
||||
'--heroui-font-size-small': string;
|
||||
'--heroui-font-size-medium': string;
|
||||
'--heroui-font-size-large': string;
|
||||
'--heroui-line-height-tiny': string;
|
||||
'--heroui-line-height-small': string;
|
||||
'--heroui-line-height-medium': string;
|
||||
'--heroui-line-height-large': string;
|
||||
'--heroui-radius-small': string;
|
||||
'--heroui-radius-medium': string;
|
||||
'--heroui-radius-large': string;
|
||||
'--heroui-border-width-small': string;
|
||||
'--heroui-border-width-medium': string;
|
||||
'--heroui-border-width-large': string;
|
||||
'--heroui-box-shadow-small': string;
|
||||
'--heroui-box-shadow-medium': string;
|
||||
'--heroui-box-shadow-large': string;
|
||||
'--heroui-hover-opacity': string;
|
||||
}
|
||||
|
||||
interface ThemeConfig {
|
||||
dark: ThemeConfigItem
|
||||
light: ThemeConfigItem
|
||||
dark: ThemeConfigItem;
|
||||
light: ThemeConfigItem;
|
||||
fontMode?: string;
|
||||
}
|
||||
|
||||
interface WebUIConfig {
|
||||
host: string
|
||||
port: number
|
||||
loginRate: number
|
||||
disableWebUI: boolean
|
||||
disableNonLANAccess: boolean
|
||||
host: string;
|
||||
port: number;
|
||||
loginRate: number;
|
||||
disableWebUI: boolean;
|
||||
disableNonLANAccess: boolean;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ import { request } from './request';
|
||||
const style = document.createElement('style');
|
||||
document.head.appendChild(style);
|
||||
|
||||
// 字体样式标签
|
||||
const fontStyle = document.createElement('style');
|
||||
fontStyle.id = 'dynamic-font-style';
|
||||
document.head.appendChild(fontStyle);
|
||||
|
||||
export function loadTheme () {
|
||||
request('/files/theme.css?_t=' + Date.now())
|
||||
.then((res) => res.data)
|
||||
@@ -14,6 +19,29 @@ export function loadTheme () {
|
||||
});
|
||||
}
|
||||
|
||||
// 动态加载字体 CSS
|
||||
const loadFontCSS = (mode: string) => {
|
||||
let css = '';
|
||||
|
||||
if (mode === 'aacute') {
|
||||
css = `
|
||||
@font-face {
|
||||
font-family: 'Aa偷吃可爱长大的';
|
||||
src: url('/webui/fonts/AaCute.woff') format('woff');
|
||||
font-display: swap;
|
||||
}`;
|
||||
} else if (mode === 'custom') {
|
||||
css = `
|
||||
@font-face {
|
||||
font-family: 'CustomFont';
|
||||
src: url('/webui/fonts/CustomFont.woff') format('woff');
|
||||
font-display: swap;
|
||||
}`;
|
||||
}
|
||||
|
||||
fontStyle.innerHTML = css;
|
||||
};
|
||||
|
||||
export const colorKeys = [
|
||||
'--heroui-background',
|
||||
|
||||
@@ -139,3 +167,53 @@ export const generateTheme = (theme: ThemeConfig, validField?: string) => {
|
||||
css += '}';
|
||||
return css;
|
||||
};
|
||||
|
||||
export const applyFont = (mode: string) => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// 先加载字体 CSS
|
||||
loadFontCSS(mode);
|
||||
|
||||
if (mode === 'aacute') {
|
||||
root.style.setProperty('--font-family-base', "'Aa偷吃可爱长大的', var(--font-family-fallbacks)", 'important');
|
||||
} else if (mode === 'custom') {
|
||||
root.style.setProperty('--font-family-base', "'CustomFont', var(--font-family-fallbacks)", 'important');
|
||||
} else {
|
||||
// system or default - restore default
|
||||
root.style.setProperty('--font-family-base', 'var(--font-family-fallbacks)', 'important');
|
||||
}
|
||||
};
|
||||
|
||||
const FONT_MODE_CACHE_KEY = 'webui-font-mode-cache';
|
||||
|
||||
export const initFont = () => {
|
||||
// 先从缓存读取,立即应用
|
||||
const cached = localStorage.getItem(FONT_MODE_CACHE_KEY);
|
||||
if (cached) {
|
||||
applyFont(cached);
|
||||
} else {
|
||||
// 默认使用系统字体
|
||||
applyFont('system');
|
||||
}
|
||||
|
||||
// 后台拉取最新配置并更新缓存
|
||||
request('/api/base/Theme')
|
||||
.then((res) => {
|
||||
const data = res.data as { data: ThemeConfig; };
|
||||
const fontMode = data?.data?.fontMode || 'system';
|
||||
// 更新缓存
|
||||
localStorage.setItem(FONT_MODE_CACHE_KEY, fontMode);
|
||||
// 如果与当前不同,则应用新字体
|
||||
if (fontMode !== cached) {
|
||||
applyFont(fontMode);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Failed to fetch font config', e);
|
||||
});
|
||||
};
|
||||
|
||||
// 保存时更新缓存
|
||||
export const updateFontCache = (fontMode: string) => {
|
||||
localStorage.setItem(FONT_MODE_CACHE_KEY, fontMode);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,20 @@ export default {
|
||||
},
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
mono: [
|
||||
'ui-monospace',
|
||||
'SFMono-Regular',
|
||||
'SF Mono',
|
||||
'Menlo',
|
||||
'Consolas',
|
||||
'Liberation Mono',
|
||||
'JetBrains Mono',
|
||||
'monospace',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
darkMode: 'class',
|
||||
plugins: [
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
import { defineConfig, loadEnv, normalizePath } from 'vite';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import viteCompression from 'vite-plugin-compression';
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
const monacoEditorPath = normalizePath(
|
||||
path.resolve(__dirname, 'node_modules/monaco-editor/min/vs')
|
||||
);
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd());
|
||||
@@ -17,14 +13,7 @@ export default defineConfig(({ mode }) => {
|
||||
plugins: [
|
||||
react(),
|
||||
tsconfigPaths(),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: monacoEditorPath,
|
||||
dest: 'monaco-editor/min',
|
||||
},
|
||||
],
|
||||
}),
|
||||
ViteImageOptimizer({})
|
||||
],
|
||||
base: '/webui/',
|
||||
server: {
|
||||
@@ -41,20 +30,41 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
'/api': backendDebugUrl,
|
||||
'/files': backendDebugUrl,
|
||||
'/webui/fonts/CustomFont.woff': backendDebugUrl,
|
||||
'/webui/sw.js': backendDebugUrl,
|
||||
},
|
||||
},
|
||||
build: {
|
||||
assetsInlineLimit: 0,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'monaco-editor': ['monaco-editor'],
|
||||
'react-dom': ['react-dom'],
|
||||
'react-router-dom': ['react-router-dom'],
|
||||
'react-hook-form': ['react-hook-form'],
|
||||
'react-icons': ['react-icons'],
|
||||
'react-hot-toast': ['react-hot-toast'],
|
||||
qface: ['qface'],
|
||||
manualChunks (id) {
|
||||
if (id.includes('node_modules')) {
|
||||
// if (id.includes('@heroui/')) {
|
||||
// return 'heroui';
|
||||
// }
|
||||
if (id.includes('react-dom')) {
|
||||
return 'react-dom';
|
||||
}
|
||||
if (id.includes('react-router-dom')) {
|
||||
return 'react-router-dom';
|
||||
}
|
||||
if (id.includes('react-hook-form')) {
|
||||
return 'react-hook-form';
|
||||
}
|
||||
if (id.includes('react-hot-toast')) {
|
||||
return 'react-hot-toast';
|
||||
}
|
||||
if (id.includes('qface')) {
|
||||
return 'qface';
|
||||
}
|
||||
if (id.includes('@uiw/react-codemirror') || id.includes('@codemirror/view') || id.includes('@codemirror/theme-one-dark')) {
|
||||
return 'codemirror-core';
|
||||
}
|
||||
if (id.includes('@codemirror/lang-')) {
|
||||
return 'codemirror-lang';
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
2201
pnpm-lock.yaml
generated
2201
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user