Compare commits

...

13 Commits

Author SHA1 Message Date
时瑾
9bdacc4d41 feat: 优化webui界面和文件管理器 2025-12-27 19:42:30 +08:00
时瑾
cd495fc7a0 fix: close #1467 close #1471 2025-12-27 16:27:54 +08:00
时瑾
656279d74b fix: close #1463 2025-12-27 00:20:59 +08:00
手瓜一十雪
377c780d1a Comment out manual chunking for @heroui in Vite config
The manual chunking logic for '@heroui' modules in the Vite configuration has been commented out. This may be to simplify chunk splitting or address build issues related to custom chunking.
2025-12-24 19:31:52 +08:00
手瓜一十雪
aefa8985b1 Remove vite-plugin-font and related dependencies
This update removes vite-plugin-font and its associated dependencies from pnpm-lock.yaml. This likely reflects a cleanup of unused packages or a migration away from font-related build tooling.
2025-12-24 18:30:55 +08:00
手瓜一十雪
b034940dfd Remove vite-plugin-font from dependencies
Deleted the vite-plugin-font package from both the root and frontend package.json files as it is no longer required.
2025-12-24 18:29:17 +08:00
手瓜一十雪
cb8e10cc7e Add sw_template.js to build and improve service worker loading
Updated the Vite config to copy sw_template.js to the static assets during build. Modified backend to load sw_template.js from the static directory if available, falling back to the source assets if not. This ensures the service worker template is correctly served in production builds.
2025-12-24 18:20:51 +08:00
手瓜一十雪
afed164ba1 Add background-aware styling to sidebar and usage pie
Updated sidebar, navigation list, and usage pie components to adjust their styles based on the presence of a custom background image. This improves visual integration when a background image is set, ensuring text and UI elements remain readable and aesthetically consistent.
2025-12-24 18:14:04 +08:00
手瓜一十雪
a34a86288b Refactor font handling and theme config, switch to CodeMirror editor
Replaces Monaco editor with CodeMirror in the frontend, removing related dependencies and configuration. Refactors font management to support multiple formats (woff, woff2, ttf, otf) and dynamic font switching, including backend API and frontend theme config UI. Adds gzip compression middleware to backend. Updates theme config to allow font selection and custom font upload, and improves theme preview and color customization UI. Cleans up unused code and improves sidebar and terminal font sizing responsiveness.
2025-12-24 18:02:54 +08:00
手瓜一十雪
50bcd71144 Remove unused dependencies and optimize Monaco workers
Removed @simplewebauthn/browser, framer-motion, and react-responsive from dependencies as they are no longer used. Updated Monaco editor configuration to only load the JSON worker for improved performance, falling back to the basic editor worker for other languages. Refactored the new version tip UI to use Chip and Spinner instead of Button and removed unused react-icons import. Also updated Vite config to stop sharing react-icons.
2025-12-24 15:32:21 +08:00
手瓜一十雪
fa3a229827 Refactor dashboard and components, remove echarts
Replaces echarts-based usage pie chart with a custom SVG implementation, removing the echarts dependency. Improves caching for version and system info requests, simplifies page background to static elements, and switches dashboard state to use localStorage for persistence. Also removes polling from hitokoto and updates button styling in system info.
2025-12-24 13:56:34 +08:00
手瓜一十雪
e56b912bbd Remove debug log from emoji_like event handler
Eliminated a console.log statement in the 'event:emoji_like' event handler to clean up debug output.
2025-12-22 17:08:32 +08:00
手瓜一十雪
da0dd01460 Remove debug console.log statements from DebugAdapter
Eliminated several console.log statements used for debugging in the DebugAdapter and DebugAdapterManager classes to clean up console output.
2025-12-22 16:29:05 +08:00
58 changed files with 3687 additions and 878 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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' },

View File

@@ -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-96 是性能和压缩率的平衡点
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');
}
});
// ------------中间件结束------------
// ------------挂载路由------------

View File

@@ -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",

View File

@@ -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 未初始化,无法注册适配器');
}

View File

@@ -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);
}

View 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))
);
});

View File

@@ -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');
}

View File

@@ -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',
},
},

View File

@@ -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);

View File

@@ -1 +1 @@
VITE_DEBUG_BACKEND_URL="http://127.0.0.1:6099"
VITE_DEBUG_BACKEND_URL="http://127.0.0.1:6099"

View File

@@ -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"
}
}
}
}

View 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);

View File

@@ -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;

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'

View File

@@ -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...'}

View File

@@ -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'
)}
>

View File

@@ -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'

View File

@@ -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'

View File

@@ -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>

View File

@@ -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,
}

View File

@@ -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>

View File

@@ -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> = ({

View File

@@ -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 }}

View File

@@ -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"
/>
)
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);
});
});
}
}

View File

@@ -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;

View File

@@ -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>
</>
);
};

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'>

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
};

View File

@@ -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: [

View File

@@ -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

File diff suppressed because it is too large Load Diff