Compare commits

...

119 Commits

Author SHA1 Message Date
bietiaop
b08a29897f fix: #769 2025-02-05 19:45:30 +08:00
Mlikiowa
b59c1d9122 release: v4.5.9 2025-02-05 11:14:25 +00:00
手瓜一十雪
adb9cea701 Merge pull request #765 from NapNeko/fix/multi-forward-protocol-fetch
fix: #721
2025-02-05 19:08:08 +08:00
Mlikiowa
5e148d2e82 release: v4.5.8 2025-02-05 11:02:28 +00:00
手瓜一十雪
a0d780558e fix 2025-02-05 19:01:14 +08:00
Mlikiowa
ad56065a4e release: v4.5.7 2025-02-05 07:10:27 +00:00
手瓜一十雪
f5dee80b6e Merge branch 'main' of https://github.com/NapNeko/NapCatQQ 2025-02-05 15:09:27 +08:00
手瓜一十雪
9cc75881b8 fix: arm64 2025-02-05 14:51:12 +08:00
bietiaop
593fb13b61 style: 语义化样式 2025-02-05 10:38:12 +08:00
pk5ls20
fca90592d6 try fix: #755 2025-02-05 08:29:37 +08:00
pk5ls20
d6848e2855 fix: #721 2025-02-05 08:07:58 +08:00
bietiaop
7539a4129f fix: 获取歌单 2025-02-04 22:14:23 +08:00
bietiaop
5402574266 feat: AI更新总结 2025-02-04 22:03:37 +08:00
Mlikiowa
853175aa1a release: v4.5.6 2025-02-04 13:24:46 +00:00
手瓜一十雪
feb84809ec fix: #761 2025-02-04 21:22:36 +08:00
bietiaop
a812c568e4 fix: 文件预览 2025-02-04 21:12:13 +08:00
bietiaop
11db25e355 fix: 文件预览 2025-02-04 21:08:28 +08:00
手瓜一十雪
ecd2fba629 fix: #762 2025-02-04 20:42:13 +08:00
Mlikiowa
a6763cf5a1 release: v4.5.5 2025-02-04 11:50:37 +00:00
手瓜一十雪
c9e91a9b94 fix: defalut config 2025-02-04 19:49:56 +08:00
Mlikiowa
43fb62c5bd release: v4.5.4 2025-02-04 11:35:51 +00:00
手瓜一十雪
cb8727d487 fix: reload and parse msg 2025-02-04 19:34:51 +08:00
Mlikiowa
a94e03e2fd release: v4.5.3 2025-02-04 10:16:07 +00:00
手瓜一十雪
425c3c6432 fix: 避免重复reload 2025-02-04 18:14:13 +08:00
手瓜一十雪
89b9610016 fix: 避免read异常 2025-02-04 18:13:42 +08:00
手瓜一十雪
62fe88f868 Merge pull request #760 from NapNeko/config-refactor
refactor
2025-02-04 18:09:57 +08:00
手瓜一十雪
11a7f5fade refactor 2025-02-04 18:09:30 +08:00
bietiaop
fbde997f7c style: 调整样式 2025-02-04 17:58:38 +08:00
bietiaop
26734a35ef fix: 文件下载 2025-02-04 15:31:10 +08:00
Mlikiowa
715c4ac534 release: v4.5.2 2025-02-04 06:52:11 +00:00
bietiaop
bd4b0885a1 fix: 预览 2025-02-04 14:47:38 +08:00
手瓜一十雪
e3c7af3d91 fix: 解决nonebot可能卡死问题 2025-02-04 14:42:14 +08:00
手瓜一十雪
a7ee21bfd8 fix: #757 2025-02-04 14:34:55 +08:00
手瓜一十雪
d0f51d92ac feat: tailwind css 2025-02-04 13:52:53 +08:00
手瓜一十雪
e6dc148ea2 fix: diy status问题 2025-02-04 13:44:35 +08:00
手瓜一十雪
514ab6637f feat: 全局字体优化 2025-02-04 13:37:11 +08:00
bietiaop
377794abe8 style: font & terminal
style: font & terminal
2025-02-04 13:09:00 +08:00
bietiaop
0f3251f35b fix: 字体、终端样式 2025-02-04 12:59:51 +08:00
手瓜一十雪
8002dc5bc5 fix 2025-02-04 00:16:59 +08:00
手瓜一十雪
c75a13dcf4 fix 2025-02-04 00:14:15 +08:00
Mlikiowa
91d153bb9d release: v4.5.1 2025-02-03 12:48:00 +00:00
bietiaop
b32f9fa397 feat: 文件下载/上传 2025-02-03 19:56:33 +08:00
Mlikiowa
80593730ae release: v4.4.20 2025-02-03 08:36:59 +00:00
手瓜一十雪
090d54a78d fix: typo 2025-02-03 16:36:25 +08:00
Mlikiowa
b7d1fb181c release: v4.4.19 2025-02-03 08:34:24 +00:00
手瓜一十雪
6e56693ca7 feat: 支持set_diy_online_status 2025-02-03 16:33:31 +08:00
Mlikiowa
7403db9b20 release: v4.4.18 2025-02-03 07:05:56 +00:00
手瓜一十雪
9d167cd883 Merge branch 'main' of https://github.com/NapNeko/NapCatQQ 2025-02-03 15:05:16 +08:00
手瓜一十雪
197eec40ad fix 2025-02-03 15:05:12 +08:00
Mlikiowa
07819a6618 release: v4.4.17 2025-02-03 06:50:58 +00:00
手瓜一十雪
b72156866d fix: broken pipe 2025-02-03 14:50:11 +08:00
手瓜一十雪
59a7d12a8c style: lint 2025-02-03 14:29:38 +08:00
手瓜一十雪
179351b23a Merge pull request #754 from NapNeko/qrcode-refactor
fix: thumb残留
2025-02-03 14:11:22 +08:00
手瓜一十雪
790809e8e5 fix: thumb残留 2025-02-03 14:09:51 +08:00
手瓜一十雪
1414a8a8c9 Merge pull request #753 from NapNeko/qrcode-refactor
refactor: qrcode to ts
2025-02-03 14:02:48 +08:00
bietiaop
9ab41734a5 fix: piscina src 2025-02-03 13:33:18 +08:00
手瓜一十雪
03cace2ea1 优化依赖 2025-02-03 13:07:05 +08:00
手瓜一十雪
c7371ab869 refactor: qrcode to ts 2025-02-03 12:51:50 +08:00
手瓜一十雪
b32d4b618c fix: 必须清理并回收 2025-02-03 11:40:58 +08:00
手瓜一十雪
3a27f37686 fix: pcm清理 2025-02-03 11:37:39 +08:00
手瓜一十雪
fe2d21979d Merge pull request #749 from NapNeko/type-force
style: Type force
2025-02-03 11:13:55 +08:00
手瓜一十雪
48b1f3d4f0 Merge branch 'main' into type-force 2025-02-03 11:13:46 +08:00
手瓜一十雪
93ed589ac7 Merge pull request #745 from NapNeko/dev-terminal
feat: 文件管理 & 系统终端
2025-02-03 11:13:14 +08:00
手瓜一十雪
96de9e2c16 style: 简化loader 避免全局error 2025-02-03 10:40:33 +08:00
手瓜一十雪
b25f9d3bec feat: 摇树生成&多平台统一改造 2025-02-03 10:33:10 +08:00
手瓜一十雪
15854c605b style: 强类型大法 2025-02-02 23:22:21 +08:00
手瓜一十雪
ac193cc94a style: lint 2025-02-02 20:17:28 +08:00
手瓜一十雪
d626f872e6 feat: 类型规范 2025-02-02 20:16:11 +08:00
bietiaop
3eb66fa34a fix: 关闭终端启动QQ 2025-02-02 15:34:53 +08:00
bietiaop
0fdd0175b7 fix: 重复关闭 2025-02-02 15:09:38 +08:00
pk5ls20
dec9b477e0 fix: #747 2025-02-02 14:51:36 +08:00
bietiaop
a0a4b0dd1d fix: 频率限制 2025-02-02 14:47:34 +08:00
bietiaop
8dc6da56a7 fix: node-pty 2025-02-02 14:33:39 +08:00
bietiaop
b4e07aacfe chore(terminal): 使用prebuild 2025-02-02 12:59:00 +08:00
bietiaop
19b47f0f42 fix: id生成使用uuid 2025-02-02 12:00:55 +08:00
bietiaop
f9ef3d63c7 fix: id生成使用uuid 2025-02-02 11:57:28 +08:00
bietiaop
2b574d33b5 fix: 更换重命名图标防止误解 2025-02-02 11:46:04 +08:00
bietiaop
6039e9bb46 feat: 文件管理 2025-02-02 11:37:58 +08:00
手瓜一十雪
adfd4b043f docs: fix 2025-02-02 11:02:54 +08:00
bietiaop
719189be55 feat: file manager 2025-02-01 22:47:51 +08:00
bietiaop
ef9907f4b6 style: 优化样式 2025-02-01 20:51:45 +08:00
bietiaop
16b7447df1 style: 优化样式 2025-02-01 20:47:54 +08:00
bietiaop
4157746478 feat: 系统终端 2025-02-01 20:35:01 +08:00
bietiaop
5120786708 Merge branch 'dev-terminal' of https://github.com/NapNeko/NapCatQQ into dev-terminal 2025-02-01 13:44:18 +08:00
bietiaop
0176fa75ef dev: terminal 2025-02-01 13:41:20 +08:00
bietiaop
e6968f2d80 fix(webui): name重复问题 2025-02-01 11:44:30 +08:00
bietiaop
c0dd8a53e8 chore(dep): 更新依赖,移除overrides(strtok3已经修复) 2025-01-31 19:05:40 +08:00
bietiaop
3cb3135235 feat: 登录状态机 2025-01-31 18:48:46 +08:00
bietiaop
28182cac64 chore(dep): 更新webui依赖 2025-01-31 12:07:57 +08:00
bietiaop
73b80d2482 release: v4.4.16 2025-01-30 10:42:46 +08:00
bietiaop
f22eb22409 release: v4.4.16 2025-01-30 10:12:35 +08:00
bietiaop
4a95b17a47 feat(webui): 修改token 2025-01-29 22:08:45 +08:00
bietiaop
f4a71159fd fix(dep): 尝试解决peek-readable依赖问题 2025-01-29 21:34:59 +08:00
bietiaop
c0431e3dc2 feat(webui_api): update token 2025-01-29 20:40:23 +08:00
Mlikiowa
7f87cee282 release: v4.4.15 2025-01-27 11:53:31 +00:00
手瓜一十雪
c24c704439 fix: clone object 2025-01-27 19:53:03 +08:00
Mlikiowa
232e5d55b8 release: v4.4.14 2025-01-27 11:31:15 +00:00
手瓜一十雪
da24ae7e1c fix: 空json5 2025-01-27 19:30:45 +08:00
Mlikiowa
8fc13f8a8f release: v4.4.13 2025-01-27 11:26:17 +00:00
手瓜一十雪
7e1fe31085 fix: http支持json5 2025-01-27 19:25:51 +08:00
Mlikiowa
c3cba8ba4e release: v4.4.12 2025-01-27 10:44:43 +00:00
手瓜一十雪
ba619986c9 fix: #739 2025-01-27 18:42:26 +08:00
bietiaop
dcef3f3c3b fix: 终端字符宽度&微调样式&路由切换动画 2025-01-27 15:58:27 +08:00
bietiaop
823faa2790 fix: 质量检查 2025-01-26 21:58:04 +08:00
bietiaop
ef4248d2a3 fix: 依赖缺失 2025-01-26 21:52:35 +08:00
bietiaop
3917cb0dc9 feat: webui检查更新&修复日志字体渲染 2025-01-26 21:48:45 +08:00
Mlikiowa
520cec0eaa release: v4.4.11 2025-01-26 13:03:34 +00:00
手瓜一十雪
e7655e0ff6 Merge pull request #737 from NapNeko/ffmpeg
feat: no spawn ffmpeg
2025-01-26 21:02:57 +08:00
手瓜一十雪
350ced55c0 fix 2025-01-26 21:00:47 +08:00
手瓜一十雪
2ca6d0a00e feat: 移除测试代码 2025-01-26 20:44:27 +08:00
手瓜一十雪
844abad0d0 fix: 彻底完成迁移 2025-01-26 20:42:05 +08:00
手瓜一十雪
d278e9d8bc feat: ffmpeg 2025-01-26 16:26:21 +08:00
手瓜一十雪
6e261f30c2 fix: typo 2025-01-26 13:29:47 +08:00
bietiaop
84f0e43369 feat: debug 配置记忆 2025-01-26 10:44:31 +08:00
Mlikiowa
3223a06983 release: v4.4.10 2025-01-25 12:08:25 +00:00
手瓜一十雪
1b874a0264 fix: defaultHttpUrl 2025-01-25 20:00:24 +08:00
Mlikiowa
26525a0ff9 release: v4.4.9 2025-01-25 10:59:08 +00:00
手瓜一十雪
49234ea5c7 fix: music proxy 2025-01-25 18:34:11 +08:00
Mlikiowa
c474158a09 release: v4.4.8 2025-01-25 05:40:09 +00:00
452 changed files with 10498 additions and 2737 deletions

View File

@@ -12,7 +12,7 @@ insert_final_newline = true
# Set default charset
charset = utf-8
# 2 space indentation
# 4 space indentation
[*.{cjs,mjs,js,jsx,ts,tsx,css,scss,sass,html,json}]
indent_style = space
indent_size = 4
@@ -21,4 +21,4 @@ indent_size = 4
charset = latin1
# Unfortunately, EditorConfig doesn't support space configuration inside import braces directly.
# You'll need to rely on your linter/formatter like ESLint or Prettier for that.
# You'll need to rely on your linter/formatter like ESLint or Prettier for that.

3
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Develop
node_modules/
package-lock.json
pnpm-lock.yaml
out/
dist/
/src/core.lib/common/
@@ -13,4 +14,4 @@ devconfig/*
# Build
*.db
checkVersion.sh
bun.lockb
bun.lockb

View File

@@ -5,5 +5,6 @@
".env.universal": ".env.*",
"tsconfig.json": "tsconfig.*.json, env.d.ts, vite.config.ts",
"package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE"
}
},
"css.customData": [".vscode/tailwindcss.json"],
}

55
.vscode/tailwindcss.json vendored Normal file
View File

@@ -0,0 +1,55 @@
{
"version": 1.1,
"atDirectives": [
{
"name": "@tailwind",
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
}
]
},
{
"name": "@apply",
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that youd like to extract to a new component.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
}
]
},
{
"name": "@responsive",
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
}
]
},
{
"name": "@screen",
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
}
]
},
{
"name": "@variants",
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
}
]
}
]
}

View File

@@ -64,4 +64,4 @@ NapCat 在设计理念下遵守 OneBot 规范大多数要求并且积极改进
## 开源附加
任何使用本仓库代码的地方,都应当严格遵守[本仓库开源许可](./LICENSE)。**此外,禁止任何项目未经仓库主作者授权二次分发或基于 NapCat 代码开发。**
任何使用本仓库代码的地方,都应当严格遵守[本仓库开源许可](./LICENSE)。**本仓库仅用于提高IM易用性实现类似Hook推送此外,禁止任何项目未经仓库主作者授权二次分发或基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。**

View File

@@ -1,70 +1,32 @@
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import _import from "eslint-plugin-import";
import { fixupPluginRules } from "@eslint/compat";
import eslint from '@eslint/js';
import tsEslintPlugin from '@typescript-eslint/eslint-plugin';
import tsEslintParser from '@typescript-eslint/parser';
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
const compat = new FlatCompat({
baseDirectory: dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
export default [{
ignores: ["src/core/proto/"],
}, ...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended"), {
plugins: {
"@typescript-eslint": typescriptEslint,
import: fixupPluginRules(_import),
},
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
parser: tsParser,
ecmaVersion: "latest",
sourceType: "module",
},
settings: {
"import/parsers": {
"@typescript-eslint/parser": [".ts"],
},
"import/resolver": {
typescript: {
alwaysTryTypes: true,
const customTsFlatConfig = [
{
name: 'typescript-eslint/base',
languageOptions: {
parser: tsEslintParser,
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
NodeJS: 'readonly', // 添加 NodeJS 全局变量
},
},
},
rules: {
indent: ["error", 4],
semi: ["error", "always"],
"no-unused-vars": "off",
"no-async-promise-executor": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-var-requires": "off",
"object-curly-spacing": ["error", "always"],
},
}, {
files: ["**/.eslintrc.{js,cjs}"],
languageOptions: {
globals: {
...globals.node,
files: ['**/*.{ts,tsx}'],
rules: {
...tsEslintPlugin.configs.recommended.rules,
'quotes': ['error', 'single'], // 使用单引号
'semi': ['error', 'always'], // 强制使用分号
'indent': ['error', 4], // 使用 4 空格缩进
},
ecmaVersion: 5,
sourceType: "commonjs",
plugins: {
'@typescript-eslint': tsEslintPlugin,
},
ignores: ['src/webui/**'], // 忽略 src/webui/ 目录所有文件
},
}];
];
export default [eslint.configs.recommended, ...customTsFlatConfig];

View File

@@ -4,7 +4,7 @@
"name": "NapCatQQ",
"slug": "NapCat.Framework",
"description": "高性能的 OneBot 11 协议实现",
"version": "4.4.7",
"version": "4.5.9",
"icon": "./logo.png",
"authors": [
{

View File

@@ -4,14 +4,15 @@
"version": "0.0.6",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --host=0.0.0.0",
"build": "tsc && vite build",
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
"preview": "vite preview"
},
"dependencies": {
"@monaco-editor/loader": "^1.4.0",
"@monaco-editor/react": "4.7.0-rc.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@heroui/avatar": "2.2.7",
"@heroui/breadcrumbs": "2.2.7",
"@heroui/button": "2.2.10",
@@ -28,76 +29,87 @@
"@heroui/listbox": "2.3.10",
"@heroui/modal": "2.2.8",
"@heroui/navbar": "2.2.9",
"@heroui/pagination": "^2.2.9",
"@heroui/popover": "2.3.10",
"@heroui/select": "2.4.10",
"@heroui/skeleton": "^2.2.6",
"@heroui/slider": "2.4.8",
"@heroui/snippet": "2.2.11",
"@heroui/spinner": "2.2.7",
"@heroui/switch": "2.2.9",
"@heroui/system": "2.4.7",
"@heroui/table": "^2.2.9",
"@heroui/tabs": "2.2.8",
"@heroui/theme": "2.4.6",
"@heroui/tooltip": "2.2.8",
"@react-aria/visually-hidden": "3.8.18",
"@reduxjs/toolkit": "^2.5.0",
"@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",
"@uidotdev/usehooks": "^2.4.1",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"ahooks": "^3.8.4",
"axios": "^1.7.9",
"clsx": "2.1.1",
"clsx": "^2.1.1",
"echarts": "^5.5.1",
"event-source-polyfill": "^1.0.31",
"framer-motion": "^11.15.0",
"framer-motion": "^12.0.6",
"monaco-editor": "^0.52.2",
"motion": "^11.15.0",
"motion": "^12.0.6",
"path-browserify": "^1.0.1",
"qface": "^1.4.1",
"qrcode.react": "^4.2.0",
"quill": "^2.0.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.5",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.4.0",
"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.0",
"react-router-dom": "^7.1.4",
"react-use-websocket": "^4.11.1",
"react-window": "^1.8.11",
"tailwind-variants": "0.3.0",
"tailwindcss": "3.4.17",
"remark-gfm": "^4.0.0",
"tailwind-variants": "^0.3.0",
"tailwindcss": "^3.4.17",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@eslint/js": "^9.19.0",
"@react-types/shared": "^3.26.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/event-source-polyfill": "^1.0.5",
"@types/fabric": "^5.3.9",
"@types/node": "22.10.2",
"@types/react": "19.0.2",
"@types/react-dom": "19.0.2",
"@types/node": "^22.12.0",
"@types/path-browserify": "^1.0.3",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/react-window": "^1.8.8",
"@typescript-eslint/eslint-plugin": "8.18.1",
"@typescript-eslint/parser": "8.18.1",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "10.4.20",
"eslint": "^9.17.0",
"eslint-config-prettier": "9.1.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-prettier": "5.2.3",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-unused-imports": "4.1.4",
"eslint-plugin-unused-imports": "^4.1.4",
"globals": "^15.14.0",
"postcss": "8.4.49",
"prettier": "3.4.2",
"typescript": "5.7.2",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"typescript": "^5.7.3",
"vite": "^6.0.5",
"vite-plugin-static-copy": "^2.2.0",
"vite-tsconfig-paths": "^5.1.4"

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -16,6 +16,16 @@ import store from '@/store'
const WebLoginPage = lazy(() => import('@/pages/web_login'))
const IndexPage = lazy(() => import('@/pages/index'))
const QQLoginPage = lazy(() => import('@/pages/qq_login'))
const DashboardIndexPage = lazy(() => import('@/pages/dashboard'))
const AboutPage = lazy(() => import('@/pages/dashboard/about'))
const ConfigPage = lazy(() => import('@/pages/dashboard/config'))
const DebugPage = lazy(() => import('@/pages/dashboard/debug'))
const HttpDebug = lazy(() => import('@/pages/dashboard/debug/http'))
const WSDebug = lazy(() => import('@/pages/dashboard/debug/websocket'))
const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager'))
const LogsPage = lazy(() => import('@/pages/dashboard/logs'))
const NetworkPage = lazy(() => import('@/pages/dashboard/network'))
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'))
function App() {
return (
@@ -58,9 +68,21 @@ function AuthChecker({ children }: { children: React.ReactNode }) {
function AppRoutes() {
return (
<Routes>
<Route element={<IndexPage />} path="/*" />
<Route element={<QQLoginPage />} path="/qq_login" />
<Route element={<WebLoginPage />} path="/web_login" />
<Route path="/" element={<IndexPage />}>
<Route index element={<DashboardIndexPage />} />
<Route path="network" element={<NetworkPage />} />
<Route path="config" element={<ConfigPage />} />
<Route path="logs" element={<LogsPage />} />
<Route path="debug" element={<DebugPage />}>
<Route path="ws" element={<WSDebug />} />
<Route path="http" element={<HttpDebug />} />
</Route>
<Route path="file_manager" element={<FileManagerPage />} />
<Route path="terminal" element={<TerminalPage />} />
<Route path="about" element={<AboutPage />} />
</Route>
<Route path="/qq_login" element={<QQLoginPage />} />
<Route path="/web_login" element={<WebLoginPage />} />
</Routes>
)
}

View File

@@ -187,7 +187,7 @@ export default function AudioPlayer(props: AudioPlayerProps) {
return (
<div
className={clsx(
'fixed right-0 bottom-0 z-[9999] w-full md:w-96',
'fixed right-0 bottom-0 z-[52] w-full md:w-96',
!translateX && !translateY && 'transition-transform',
isCollapsed && 'md:hover:!translate-x-80'
)}
@@ -231,7 +231,7 @@ export default function AudioPlayer(props: AudioPlayerProps) {
: 'top-3 -left-8 rounded-l-full bg-opacity-50 backdrop-blur-md'
)}
variant="solid"
color="danger"
color="primary"
size="sm"
onPress={() => setIsCollapsed(!isCollapsed)}
>

View File

@@ -33,7 +33,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
>
<DropdownTrigger>
<Button
color="danger"
color="primary"
startContent={<IoAddCircleOutline className="text-2xl" />}
>

View File

@@ -5,7 +5,7 @@ import { IoMdRefresh } from 'react-icons/io'
export interface SaveButtonsProps {
onSubmit: () => void
reset: () => void
refresh: () => void
refresh?: () => void
isSubmitting: boolean
}
@@ -33,15 +33,17 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
>
</Button>
<Button
isIconOnly
color="secondary"
radius="full"
variant="flat"
onPress={() => refresh()}
>
<IoMdRefresh size={24} />
</Button>
{refresh && (
<Button
isIconOnly
color="secondary"
radius="full"
variant="flat"
onPress={() => refresh()}
>
<IoMdRefresh size={24} />
</Button>
)}
</div>
</div>
)

View File

@@ -110,7 +110,7 @@ const AudioInsert = () => {
<Tooltip content="发送音频">
<div className="max-w-fit">
<PopoverTrigger>
<Button color="danger" variant="flat" isIconOnly radius="full">
<Button color="primary" variant="flat" isIconOnly radius="full">
<IoMic className="text-xl" />
</Button>
</PopoverTrigger>
@@ -120,7 +120,7 @@ const AudioInsert = () => {
<Tooltip content="上传音频">
<Button
className="text-lg"
color="danger"
color="primary"
isIconOnly
variant="flat"
radius="full"
@@ -137,7 +137,7 @@ const AudioInsert = () => {
<PopoverTrigger tooltip="输入音频地址">
<Button
className="text-lg"
color="danger"
color="primary"
isIconOnly
variant="flat"
radius="full"
@@ -154,7 +154,7 @@ const AudioInsert = () => {
placeholder="请输入音频地址"
/>
<Button
color="danger"
color="primary"
variant="flat"
isIconOnly
radius="full"
@@ -177,7 +177,7 @@ const AudioInsert = () => {
<PopoverTrigger>
<Button
className="text-lg"
color="danger"
color="primary"
isIconOnly
variant="flat"
radius="full"
@@ -190,7 +190,7 @@ const AudioInsert = () => {
<PopoverContent className="flex-col gap-2 p-4">
<div className="flex gap-2">
<Button
color={isRecording ? 'danger' : 'danger'}
color={isRecording ? 'primary' : 'primary'}
variant="flat"
onPress={isRecording ? stopRecording : startRecording}
>
@@ -198,7 +198,7 @@ const AudioInsert = () => {
</Button>
{showPreview && audioPreview && (
<Button
color="danger"
color="primary"
variant="flat"
onPress={handleShowPreview}
>
@@ -212,7 +212,7 @@ const AudioInsert = () => {
className={clsx(
'w-4 h-4 rounded-full',
isRecording
? 'animate-pulse bg-danger-400'
? 'animate-pulse bg-primary-400'
: 'bg-success-400'
)}
></span>

View File

@@ -10,7 +10,7 @@ const DiceInsert = () => {
return (
<Tooltip content="发送骰子">
<Button
color="danger"
color="primary"
variant="flat"
isIconOnly
radius="full"

View File

@@ -55,7 +55,7 @@ const EmojiPicker = ({ onInsertEmoji, onOpenChange }: EmojiPickerProps) => {
<Tooltip content="插入表情">
<div className="max-w-fit">
<PopoverTrigger>
<Button color="danger" variant="flat" isIconOnly radius="full">
<Button color="primary" variant="flat" isIconOnly radius="full">
<MdEmojiEmotions className="text-xl" />
</Button>
</PopoverTrigger>
@@ -65,7 +65,7 @@ const EmojiPicker = ({ onInsertEmoji, onOpenChange }: EmojiPickerProps) => {
{visibleEmojis.map((emoji) => (
<Button
key={emoji.id}
color="danger"
color="primary"
variant="flat"
isIconOnly
radius="full"

View File

@@ -35,7 +35,7 @@ const FileInsert = () => {
<Tooltip content="发送文件">
<div className="max-w-fit">
<PopoverTrigger>
<Button color="danger" variant="flat" isIconOnly radius="full">
<Button color="primary" variant="flat" isIconOnly radius="full">
<FaFolder className="text-lg" />
</Button>
</PopoverTrigger>
@@ -45,7 +45,7 @@ const FileInsert = () => {
<Tooltip content="上传文件">
<Button
className="text-lg"
color="danger"
color="primary"
isIconOnly
variant="flat"
radius="full"
@@ -62,7 +62,7 @@ const FileInsert = () => {
<PopoverTrigger tooltip="输入文件地址">
<Button
className="text-lg"
color="danger"
color="primary"
isIconOnly
variant="flat"
radius="full"
@@ -79,7 +79,7 @@ const FileInsert = () => {
placeholder="请输入文件地址"
/>
<Button
color="danger"
color="primary"
variant="flat"
isIconOnly
radius="full"

View File

@@ -23,7 +23,7 @@ const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => {
<Tooltip content="插入图片">
<div className="max-w-fit">
<PopoverTrigger>
<Button color="danger" variant="flat" isIconOnly radius="full">
<Button color="primary" variant="flat" isIconOnly radius="full">
<MdImage className="text-xl" />
</Button>
</PopoverTrigger>
@@ -33,7 +33,7 @@ const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => {
<Tooltip content="上传图片">
<Button
className="text-lg"
color="danger"
color="primary"
isIconOnly
variant="flat"
radius="full"
@@ -50,7 +50,7 @@ const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => {
<PopoverTrigger tooltip="输入图片地址">
<Button
className="text-lg"
color="danger"
color="primary"
isIconOnly
variant="flat"
radius="full"
@@ -67,7 +67,7 @@ const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => {
placeholder="请输入图片地址"
/>
<Button
color="danger"
color="primary"
variant="flat"
isIconOnly
radius="full"

View File

@@ -80,7 +80,7 @@ const MusicInsert = () => {
<Tooltip content="发送音乐">
<div className="max-w-fit">
<PopoverTrigger>
<Button color="danger" variant="flat" isIconOnly radius="full">
<Button color="primary" variant="flat" isIconOnly radius="full">
<IoMusicalNotes className="text-xl" />
</Button>
</PopoverTrigger>
@@ -132,7 +132,7 @@ const MusicInsert = () => {
<Button
fullWidth
size="lg"
color="danger"
color="primary"
variant="flat"
radius="full"
onPress={() => {
@@ -236,7 +236,7 @@ const MusicInsert = () => {
<Button
fullWidth
size="lg"
color="danger"
color="primary"
variant="flat"
radius="full"
type="submit"

View File

@@ -19,7 +19,7 @@ const ReplyInsert = ({ insertReply }: ReplyInsertProps) => {
<Tooltip content="回复消息">
<div className="max-w-fit">
<PopoverTrigger>
<Button color="danger" variant="flat" isIconOnly radius="full">
<Button color="primary" variant="flat" isIconOnly radius="full">
<BsChatQuoteFill className="text-lg" />
</Button>
</PopoverTrigger>
@@ -38,7 +38,7 @@ const ReplyInsert = ({ insertReply }: ReplyInsertProps) => {
}}
/>
<Button
color="danger"
color="primary"
variant="flat"
radius="full"
isIconOnly

View File

@@ -10,7 +10,7 @@ const RPSInsert = () => {
return (
<Tooltip content="发送猜拳">
<Button
color="danger"
color="primary"
variant="flat"
isIconOnly
radius="full"

View File

@@ -35,7 +35,7 @@ const VideoInsert = () => {
<Tooltip content="发送视频">
<div className="max-w-fit">
<PopoverTrigger>
<Button color="danger" variant="flat" isIconOnly radius="full">
<Button color="primary" variant="flat" isIconOnly radius="full">
<IoVideocam className="text-xl" />
</Button>
</PopoverTrigger>
@@ -45,7 +45,7 @@ const VideoInsert = () => {
<Tooltip content="上传视频">
<Button
className="text-lg"
color="danger"
color="primary"
isIconOnly
variant="flat"
radius="full"
@@ -62,7 +62,7 @@ const VideoInsert = () => {
<PopoverTrigger tooltip="输入视频地址">
<Button
className="text-lg"
color="danger"
color="primary"
isIconOnly
variant="flat"
radius="full"
@@ -79,7 +79,7 @@ const VideoInsert = () => {
placeholder="请输入视频地址"
/>
<Button
color="danger"
color="primary"
variant="flat"
isIconOnly
radius="full"

View File

@@ -190,7 +190,7 @@ const ChatInput = () => {
<DiceInsert />
<RPSInsert />
<Button
color="danger"
color="primary"
onPress={() => {
const messages = getChatMessage()
showStructuredMessage(messages)

View File

@@ -15,7 +15,7 @@ export default function ChatInputModal() {
return (
<>
<Button onPress={onOpen} color="danger" radius="full" variant="flat">
<Button onPress={onOpen} color="primary" radius="full" variant="flat">
</Button>
<Modal
@@ -36,7 +36,7 @@ export default function ChatInputModal() {
</div>
</ModalBody>
<ModalFooter>
<Button color="danger" onPress={onClose} variant="flat">
<Button color="primary" onPress={onClose} variant="flat">
</Button>
</ModalFooter>

View File

@@ -19,12 +19,6 @@ loader.config({
}
})
loader.config({
'vs/nls': {
availableLanguages: { '*': 'zh-cn' }
}
})
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
test?: string
}

View File

@@ -78,7 +78,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
{debug ? '关闭调试' : '开启调试'}
</Button>
<Button
color="danger"
color="primary"
startContent={<MdDeleteForever />}
onPress={handleDelete}
>

View File

@@ -19,7 +19,7 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
className={clsx(
'bg-opacity-60 shadow-sm md:rounded-3xl',
size === 'md'
? 'col-span-8 md:col-span-2 bg-danger-50 shadow-danger-100'
? 'col-span-8 md:col-span-2 bg-primary-50 shadow-primary-100'
: 'col-span-2 md:col-span-1 bg-warning-100 shadow-warning-200'
)}
shadow="sm"
@@ -27,7 +27,7 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
<CardBody className="items-center md:gap-1 p-1 md:p-2">
<div
className={clsx(
'font-outfit flex-1',
'flex-1',
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
title({
color: size === 'md' ? 'pink' : 'yellow',

View File

@@ -0,0 +1,166 @@
import {
FaFile,
FaFileAudio,
FaFileCode,
FaFileCsv,
FaFileExcel,
FaFileImage,
FaFileLines,
FaFilePdf,
FaFilePowerpoint,
FaFileVideo,
FaFileWord,
FaFileZipper,
FaFolderClosed
} from 'react-icons/fa6'
export interface FileIconProps {
name?: string
isDirectory?: boolean
}
const FileIcon = (props: FileIconProps) => {
const { name, isDirectory = false } = props
if (isDirectory) {
return <FaFolderClosed className="text-yellow-500" />
}
const ext = name?.split('.').pop() || ''
if (ext) {
switch (ext.toLowerCase()) {
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'svg':
case 'bmp':
case 'ico':
case 'webp':
case 'tiff':
case 'tif':
case 'heic':
case 'heif':
case 'avif':
case 'apng':
case 'flif':
case 'ai':
case 'psd':
case 'xcf':
case 'sketch':
case 'fig':
case 'xd':
case 'svgz':
return <FaFileImage className="text-green-500" />
case 'pdf':
return <FaFilePdf className="text-red-500" />
case 'doc':
case 'docx':
return <FaFileWord className="text-blue-500" />
case 'xls':
case 'xlsx':
return <FaFileExcel className="text-green-500" />
case 'csv':
return <FaFileCsv className="text-green-500" />
case 'ppt':
case 'pptx':
return <FaFilePowerpoint className="text-red-500" />
case 'zip':
case 'rar':
case '7z':
case 'tar':
case 'gz':
case 'bz2':
case 'xz':
case 'lz':
case 'lzma':
case 'zst':
case 'zstd':
case 'z':
case 'taz':
case 'tz':
case 'tzo':
return <FaFileZipper className="text-green-500" />
case 'txt':
return <FaFileLines className="text-gray-500" />
case 'mp3':
case 'wav':
case 'flac':
return <FaFileAudio className="text-green-500" />
case 'mp4':
case 'avi':
case 'mov':
case 'wmv':
return <FaFileVideo className="text-red-500" />
case 'html':
case 'css':
case 'js':
case 'ts':
case 'jsx':
case 'tsx':
case 'json':
case 'xml':
case 'yaml':
case 'yml':
case 'md':
case 'sh':
case 'py':
case 'java':
case 'c':
case 'cpp':
case 'cs':
case 'go':
case 'php':
case 'rb':
case 'pl':
case 'swift':
case 'kt':
case 'rs':
case 'sql':
case 'r':
case 'scala':
case 'groovy':
case 'dart':
case 'lua':
case 'perl':
case 'h':
case 'm':
case 'mm':
case 'makefile':
case 'cmake':
case 'dockerfile':
case 'gradle':
case 'properties':
case 'ini':
case 'conf':
case 'env':
case 'bat':
case 'cmd':
case 'ps1':
case 'psm1':
case 'psd1':
case 'ps1xml':
case 'psc1':
case 'pssc':
case 'nuspec':
case 'resx':
case 'resw':
case 'csproj':
case 'vbproj':
case 'vcxproj':
case 'fsproj':
case 'sln':
case 'suo':
case 'user':
case 'userosscache':
case 'sln.docstates':
case 'dll':
return <FaFileCode className="text-blue-500" />
default:
return <FaFile className="text-gray-500" />
}
}
return <FaFile className="text-gray-500" />
}
export default FileIcon

View File

@@ -0,0 +1,64 @@
import { Button, ButtonGroup } from '@heroui/button'
import { Input } from '@heroui/input'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader
} 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
}
export default function CreateFileModal({
isOpen,
fileType,
newFileName,
onTypeChange,
onNameChange,
onClose,
onCreate
}: CreateFileModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4">
<ButtonGroup color="primary">
<Button
variant={fileType === 'file' ? 'solid' : 'flat'}
onPress={() => onTypeChange('file')}
>
</Button>
<Button
variant={fileType === 'directory' ? 'solid' : 'flat'}
onPress={() => onTypeChange('directory')}
>
</Button>
</ButtonGroup>
<Input label="名称" value={newFileName} onChange={onNameChange} />
</div>
</ModalBody>
<ModalFooter>
<Button color="primary" variant="flat" onPress={onClose}>
</Button>
<Button color="primary" onPress={onCreate}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,94 @@
import { Button } from '@heroui/button'
import { Code } from '@heroui/code'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader
} from '@heroui/modal'
import CodeEditor from '@/components/code_editor'
interface FileEditModalProps {
isOpen: boolean
file: { path: string; content: string } | null
onClose: () => void
onSave: () => void
onContentChange: (newContent?: string) => void
}
export default function FileEditModal({
isOpen,
file,
onClose,
onSave,
onContentChange
}: FileEditModalProps) {
// 根据文件后缀返回对应语言
const getLanguage = (filePath: string) => {
if (filePath.endsWith('.js')) return 'javascript'
if (filePath.endsWith('.ts')) return 'typescript'
if (filePath.endsWith('.tsx')) return 'tsx'
if (filePath.endsWith('.jsx')) return 'jsx'
if (filePath.endsWith('.vue')) return 'vue'
if (filePath.endsWith('.svelte')) return 'svelte'
if (filePath.endsWith('.json')) return 'json'
if (filePath.endsWith('.html')) return 'html'
if (filePath.endsWith('.css')) return 'css'
if (filePath.endsWith('.scss')) return 'scss'
if (filePath.endsWith('.less')) return 'less'
if (filePath.endsWith('.md')) return 'markdown'
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) return 'yaml'
if (filePath.endsWith('.xml')) return 'xml'
if (filePath.endsWith('.sql')) return 'sql'
if (filePath.endsWith('.sh')) return 'shell'
if (filePath.endsWith('.bat')) return 'bat'
if (filePath.endsWith('.php')) return 'php'
if (filePath.endsWith('.java')) return 'java'
if (filePath.endsWith('.c')) return 'c'
if (filePath.endsWith('.cpp')) return 'cpp'
if (filePath.endsWith('.h')) return 'h'
if (filePath.endsWith('.hpp')) return 'hpp'
if (filePath.endsWith('.go')) return 'go'
if (filePath.endsWith('.py')) return 'python'
if (filePath.endsWith('.rb')) return 'ruby'
if (filePath.endsWith('.cs')) return 'csharp'
if (filePath.endsWith('.swift')) return 'swift'
if (filePath.endsWith('.vb')) return 'vb'
if (filePath.endsWith('.lua')) return 'lua'
if (filePath.endsWith('.pl')) return 'perl'
if (filePath.endsWith('.r')) return 'r'
return 'plaintext'
}
return (
<Modal size="full" isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader className="flex items-center gap-2 bg-content2 bg-opacity-50">
<span></span>
<Code className="text-xs">{file?.path}</Code>
</ModalHeader>
<ModalBody className="p-0">
<div className="h-full">
<CodeEditor
height="100%"
value={file?.content || ''}
onChange={onContentChange}
options={{ wordWrap: 'on' }}
language={file?.path ? getLanguage(file.path) : 'plaintext'}
/>
</div>
</ModalBody>
<ModalFooter>
<Button color="primary" variant="flat" onPress={onClose}>
</Button>
<Button color="primary" onPress={onSave}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,92 @@
import { Button } from '@heroui/button'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader
} from '@heroui/modal'
import { Spinner } from '@heroui/spinner'
import { useRequest } from 'ahooks'
import path from 'path-browserify'
import { useEffect } from 'react'
import FileManager from '@/controllers/file_manager'
interface FilePreviewModalProps {
isOpen: boolean
filePath: string
onClose: () => void
}
export const videoExts = ['.mp4', '.webm']
export const audioExts = ['.mp3', '.wav']
export const supportedPreviewExts = [...videoExts, ...audioExts]
export default function FilePreviewModal({
isOpen,
filePath,
onClose
}: FilePreviewModalProps) {
const ext = path.extname(filePath).toLowerCase()
const { data, loading, error, run } = useRequest(
async () => FileManager.downloadToURL(filePath),
{
refreshDeps: [filePath],
manual: true,
refreshDepsAction: () => {
const ext = path.extname(filePath).toLowerCase()
if (!filePath || !supportedPreviewExts.includes(ext)) {
return
}
run()
}
}
)
useEffect(() => {
if (filePath) {
run()
}
}, [filePath])
let contentElement = null
if (!supportedPreviewExts.includes(ext)) {
contentElement = <div></div>
} else if (error) {
contentElement = <div></div>
} else if (loading || !data) {
contentElement = (
<div className="flex justify-center items-center h-full">
<Spinner />
</div>
)
} else if (videoExts.includes(ext)) {
contentElement = <video src={data} controls className="max-w-full" />
} else if (audioExts.includes(ext)) {
contentElement = <audio src={data} controls className="w-full" />
} else {
contentElement = (
<div className="flex justify-center items-center h-full">
<Spinner />
</div>
)
}
return (
<Modal 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>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,245 @@
import { Button, ButtonGroup } from '@heroui/button'
import { Pagination } from '@heroui/pagination'
import { Spinner } from '@heroui/spinner'
import {
type Selection,
type SortDescriptor,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow
} from '@heroui/table'
import path from 'path-browserify'
import { useCallback, useEffect, useState } from 'react'
import { BiRename } from 'react-icons/bi'
import { FiCopy, FiDownload, FiMove, FiTrash2 } from 'react-icons/fi'
import { PhotoSlider } from 'react-photo-view'
import FileIcon from '@/components/file_icon'
import type { FileInfo } from '@/controllers/file_manager'
import { supportedPreviewExts } from './file_preview_modal'
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button'
export interface FileTableProps {
files: FileInfo[]
currentPath: string
loading: boolean
sortDescriptor: SortDescriptor
onSortChange: (descriptor: SortDescriptor) => void
selectedFiles: Selection
onSelectionChange: (selected: Selection) => void
onDirectoryClick: (dirPath: string) => void
onEdit: (filePath: string) => void
onPreview: (filePath: string) => void
onRenameRequest: (name: string) => void
onMoveRequest: (name: string) => void
onCopyPath: (fileName: string) => void
onDelete: (filePath: string) => void
onDownload: (filePath: string) => void
}
const PAGE_SIZE = 20
export default function FileTable({
files,
currentPath,
loading,
sortDescriptor,
onSortChange,
selectedFiles,
onSelectionChange,
onDirectoryClick,
onEdit,
onPreview,
onRenameRequest,
onMoveRequest,
onCopyPath,
onDelete,
onDownload
}: FileTableProps) {
const [page, setPage] = useState(1)
const pages = Math.ceil(files.length / PAGE_SIZE) || 1
const start = (page - 1) * PAGE_SIZE
const end = start + PAGE_SIZE
const displayFiles = files.slice(start, end)
const [showImage, setShowImage] = useState(false)
const [previewIndex, setPreviewIndex] = useState(0)
const [previewImages, setPreviewImages] = useState<PreviewImage[]>([])
const addPreviewImage = useCallback((image: PreviewImage) => {
setPreviewImages((prev) => {
const exists = prev.some((p) => p.key === image.key)
if (exists) return prev
return [...prev, image]
})
}, [])
useEffect(() => {
setPreviewImages([])
setPreviewIndex(0)
setShowImage(false)
}, [currentPath])
const onPreviewImage = (name: string, images: PreviewImage[]) => {
const index = images.findIndex((image) => image.key === name)
if (index === -1) {
return
}
setPreviewIndex(index)
setShowImage(true)
}
return (
<>
<PhotoSlider
images={previewImages}
visible={showImage}
onClose={() => setShowImage(false)}
index={previewIndex}
onIndexChange={setPreviewIndex}
/>
<Table
aria-label="文件列表"
sortDescriptor={sortDescriptor}
onSortChange={onSortChange}
onSelectionChange={onSelectionChange}
defaultSelectedKeys={[]}
selectedKeys={selectedFiles}
selectionMode="multiple"
bottomContent={
<div className="flex w-full justify-center">
<Pagination
isCompact
showControls
showShadow
color="primary"
page={page}
total={pages}
onChange={(page) => setPage(page)}
/>
</div>
}
>
<TableHeader>
<TableColumn key="name" allowsSorting>
</TableColumn>
<TableColumn key="type" allowsSorting>
</TableColumn>
<TableColumn key="size" allowsSorting>
</TableColumn>
<TableColumn key="mtime" allowsSorting>
</TableColumn>
<TableColumn key="actions"></TableColumn>
</TableHeader>
<TableBody
isLoading={loading}
loadingContent={
<div className="flex justify-center items-center h-full">
<Spinner />
</div>
}
>
{displayFiles.map((file: FileInfo) => {
const filePath = path.join(currentPath, file.name)
const ext = path.extname(file.name).toLowerCase()
const previewable = supportedPreviewExts.includes(ext)
const images = previewImages
return (
<TableRow key={file.name}>
<TableCell>
{imageExts.includes(ext) ? (
<ImageNameButton
name={file.name}
filePath={filePath}
onPreview={() => onPreviewImage(file.name, images)}
onAddPreview={addPreviewImage}
/>
) : (
<Button
variant="light"
onPress={() =>
file.isDirectory
? onDirectoryClick(file.name)
: previewable
? onPreview(filePath)
: onEdit(filePath)
}
className="text-left justify-start"
startContent={
<FileIcon
name={file.name}
isDirectory={file.isDirectory}
/>
}
>
{file.name}
</Button>
)}
</TableCell>
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
<TableCell>
{isNaN(file.size) || file.isDirectory
? '-'
: `${file.size} 字节`}
</TableCell>
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
<TableCell>
<ButtonGroup size="sm">
<Button
isIconOnly
color="primary"
variant="flat"
onPress={() => onRenameRequest(file.name)}
>
<BiRename />
</Button>
<Button
isIconOnly
color="primary"
variant="flat"
onPress={() => onMoveRequest(file.name)}
>
<FiMove />
</Button>
<Button
isIconOnly
color="primary"
variant="flat"
onPress={() => onCopyPath(file.name)}
>
<FiCopy />
</Button>
<Button
isIconOnly
color="primary"
variant="flat"
onPress={() => onDownload(filePath)}
>
<FiDownload />
</Button>
<Button
isIconOnly
color="primary"
variant="flat"
onPress={() => onDelete(filePath)}
>
<FiTrash2 />
</Button>
</ButtonGroup>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</>
)
}

View File

@@ -0,0 +1,88 @@
import { Button } from '@heroui/button'
import { Image } from '@heroui/image'
import { Spinner } from '@heroui/spinner'
import { useRequest } from 'ahooks'
import path from 'path-browserify'
import { useEffect } from 'react'
import FileManager from '@/controllers/file_manager'
import FileIcon from '../file_icon'
export interface PreviewImage {
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
}
export default function ImageNameButton({
name,
filePath,
onPreview,
onAddPreview
}: ImageNameButtonProps) {
const { data, loading, error, run } = useRequest(
async () => FileManager.downloadToURL(filePath),
{
refreshDeps: [filePath],
manual: true,
refreshDepsAction: () => {
const ext = path.extname(filePath).toLowerCase()
if (!filePath || !imageExts.includes(ext)) {
return
}
run()
}
}
)
useEffect(() => {
if (data) {
onAddPreview({
key: name,
src: data,
alt: name
})
}
}, [data, name, onAddPreview])
useEffect(() => {
if (filePath) {
run()
}
}, [])
return (
<Button
variant="light"
className="text-left justify-start"
onPress={onPreview}
startContent={
error ? (
<FileIcon name={name} isDirectory={false} />
) : loading || !data ? (
<Spinner size="sm" />
) : (
<Image
src={data}
alt={name}
className="w-8 h-8 flex-shrink-0"
classNames={{
wrapper: 'w-8 h-8 flex-shrink-0'
}}
radius="sm"
/>
)
}
>
{name}
</Button>
)
}

View File

@@ -0,0 +1,168 @@
import { Button } from '@heroui/button'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader
} from '@heroui/modal'
import { Spinner } from '@heroui/spinner'
import clsx from 'clsx'
import path from 'path-browserify'
import { useState } from 'react'
import { IoAdd, IoRemove } from 'react-icons/io5'
import FileManager from '@/controllers/file_manager'
interface MoveModalProps {
isOpen: boolean
moveTargetPath: string
selectionInfo: string
onClose: () => void
onMove: () => void
onSelect: (dir: string) => void // 新增回调
}
// 将 DirectoryTree 改为递归组件
// 新增 selectedPath 属性,用于标识当前选中的目录
function DirectoryTree({
basePath,
onSelect,
selectedPath
}: {
basePath: string
onSelect: (dir: string) => void
selectedPath?: string
}) {
const [dirs, setDirs] = useState<string[]>([])
const [expanded, setExpanded] = useState(false)
// 新增loading状态
const [loading, setLoading] = useState(false)
const fetchDirectories = async () => {
try {
// 直接使用 basePath 调用接口,移除 process.platform 判断
const list = await FileManager.listDirectories(basePath)
setDirs(list.map((item) => item.name))
} catch (error) {
// ...error handling...
}
}
const handleToggle = async () => {
if (!expanded) {
setExpanded(true)
setLoading(true)
await fetchDirectories()
setLoading(false)
} else {
setExpanded(false)
}
}
const handleClick = () => {
onSelect(basePath)
handleToggle()
}
// 计算显示的名称
const getDisplayName = () => {
if (basePath === '/') return '/'
if (/^[A-Z]:$/i.test(basePath)) return basePath
return path.basename(basePath)
}
// 更新 Button 的 variant 逻辑
const isSeleted = selectedPath === basePath
const variant = isSeleted
? 'solid'
: selectedPath && path.dirname(selectedPath) === basePath
? 'flat'
: 'light'
return (
<div className="ml-4">
<Button
onPress={handleClick}
className="py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md"
size="sm"
color="primary"
variant={variant}
startContent={
<div
className={clsx(
'rounded-md',
isSeleted ? 'bg-primary-600' : 'bg-primary-50'
)}
>
{expanded ? <IoRemove /> : <IoAdd />}
</div>
}
>
{getDisplayName()}
</Button>
{expanded && (
<div>
{loading ? (
<div className="flex py-1 px-8">
<Spinner size="sm" color="primary" />
</div>
) : (
dirs.map((dirName) => {
const childPath =
basePath === '/' && /^[A-Z]:$/i.test(dirName)
? dirName
: path.join(basePath, dirName)
return (
<DirectoryTree
key={childPath}
basePath={childPath}
onSelect={onSelect}
selectedPath={selectedPath}
/>
)
})
)}
</div>
)}
</div>
)
}
export default function MoveModal({
isOpen,
moveTargetPath,
selectionInfo,
onClose,
onMove,
onSelect
}: MoveModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<div className="rounded-md p-2 border border-default-300 overflow-auto max-h-60">
<DirectoryTree
basePath="/"
onSelect={onSelect}
selectedPath={moveTargetPath}
/>
</div>
<p className="text-sm text-default-500 mt-2">
{moveTargetPath || '未选择'}
</p>
<p className="text-sm text-default-500">{selectionInfo}</p>
</ModalBody>
<ModalFooter>
<Button color="primary" variant="flat" onPress={onClose}>
</Button>
<Button color="primary" onPress={onMove}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,44 @@
import { Button } from '@heroui/button'
import { Input } from '@heroui/input'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader
} from '@heroui/modal'
interface RenameModalProps {
isOpen: boolean
newFileName: string
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onClose: () => void
onRename: () => void
}
export default function RenameModal({
isOpen,
newFileName,
onNameChange,
onClose,
onRename
}: RenameModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<Input label="新名称" value={newFileName} onChange={onNameChange} />
</ModalBody>
<ModalFooter>
<Button color="primary" variant="flat" onPress={onClose}>
</Button>
<Button color="primary" onPress={onRename}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -33,10 +33,10 @@ export default function Hitokoto() {
<div className="relative">
{loading && <PageLoading />}
{error ? (
<div className="text-danger-400">{error.message}</div>
<div className="text-primary-400">{error.message}</div>
) : (
<>
<div className="font-noto-serif">{data?.hitokoto}</div>
<div>{data?.hitokoto}</div>
<div className="text-right">
<span className="text-default-400">{data?.from}</span>{' '}
{data?.from_who}
@@ -52,7 +52,7 @@ export default function Hitokoto() {
isLoading={loading}
isIconOnly
radius="full"
color="danger"
color="primary"
variant="flat"
>
<IoRefresh />

View File

@@ -0,0 +1,146 @@
import { motion, useMotionValue, useSpring } from 'motion/react'
import { useRef, useState } from 'react'
const springValues = {
damping: 30,
stiffness: 100,
mass: 2
}
export interface HoverTiltedCardProps {
imageSrc: string
altText?: string
captionText?: string
containerHeight?: string
containerWidth?: string
imageHeight?: string
imageWidth?: string
scaleOnHover?: number
rotateAmplitude?: number
showTooltip?: boolean
overlayContent?: React.ReactNode
displayOverlayContent?: boolean
}
export default function HoverTiltedCard({
imageSrc,
altText = 'NapCat',
captionText = 'NapCat',
containerHeight = '200px',
containerWidth = '100%',
imageHeight = '200px',
imageWidth = '200px',
scaleOnHover = 1.1,
rotateAmplitude = 14,
showTooltip = false,
overlayContent = (
<div className="text-center mt-6 px-4 py-0.5 shadow-lg rounded-full bg-primary-600 text-default-100 bg-opacity-80">
NapCat
</div>
),
displayOverlayContent = true
}: HoverTiltedCardProps) {
const ref = useRef<HTMLDivElement>(null)
const x = useMotionValue(0)
const y = useMotionValue(0)
const rotateX = useSpring(useMotionValue(0), springValues)
const rotateY = useSpring(useMotionValue(0), springValues)
const scale = useSpring(1, springValues)
const opacity = useSpring(0)
const rotateFigcaption = useSpring(0, {
stiffness: 350,
damping: 30,
mass: 1
})
const [lastY, setLastY] = useState(0)
function handleMouse(e: React.MouseEvent) {
if (!ref.current) return
const rect = ref.current.getBoundingClientRect()
const offsetX = e.clientX - rect.left - rect.width / 2
const offsetY = e.clientY - rect.top - rect.height / 2
const rotationX = (offsetY / (rect.height / 2)) * -rotateAmplitude
const rotationY = (offsetX / (rect.width / 2)) * rotateAmplitude
rotateX.set(rotationX)
rotateY.set(rotationY)
x.set(e.clientX - rect.left)
y.set(e.clientY - rect.top)
const velocityY = offsetY - lastY
rotateFigcaption.set(-velocityY * 0.6)
setLastY(offsetY)
}
function handleMouseEnter() {
scale.set(scaleOnHover)
opacity.set(1)
}
function handleMouseLeave() {
opacity.set(0)
scale.set(1)
rotateX.set(0)
rotateY.set(0)
rotateFigcaption.set(0)
}
return (
<figure
ref={ref}
className="relative w-full h-full [perspective:800px] flex flex-col items-center justify-center"
style={{
height: containerHeight,
width: containerWidth
}}
onMouseMove={handleMouse}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<motion.div
className="relative [transform-style:preserve-3d]"
style={{
width: imageWidth,
height: imageHeight,
rotateX,
rotateY,
scale
}}
>
<motion.img
src={imageSrc}
alt={altText}
className="absolute top-0 left-0 object-cover rounded-md will-change-transform [transform:translateZ(0)] pointer-events-none select-none"
style={{
width: imageWidth,
height: imageHeight
}}
/>
{displayOverlayContent && overlayContent && (
<motion.div className="absolute top-0 left-0 right-0 z-10 flex justify-center will-change-transform [transform:translateZ(30px)]">
{overlayContent}
</motion.div>
)}
</motion.div>
{showTooltip && (
<motion.figcaption
className="pointer-events-none absolute left-0 top-0 rounded-md bg-white px-2 py-1 text-sm text-default-900 opacity-0 z-10 hidden sm:block"
style={{
x,
y,
opacity,
rotate: rotateFigcaption
}}
>
{captionText}
</motion.figcaption>
)}
</figure>
)
}

View File

@@ -1125,7 +1125,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1152,7 +1152,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="0ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1173,7 +1173,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1197,7 +1197,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="800ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1220,7 +1220,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1247,7 +1247,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="1600ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1270,7 +1270,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1297,7 +1297,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="2400ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1320,7 +1320,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1344,7 +1344,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="3200ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1372,7 +1372,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1399,7 +1399,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="0ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1422,7 +1422,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1446,7 +1446,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="600ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1469,7 +1469,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1496,7 +1496,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="1200ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1519,7 +1519,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1543,7 +1543,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="1800ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1566,7 +1566,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1590,7 +1590,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="2400ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1613,7 +1613,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1637,7 +1637,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="3000ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1660,7 +1660,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1684,7 +1684,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="3600ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1707,7 +1707,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1731,7 +1731,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="4200ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1744,3 +1744,224 @@ export const BietiaopIcon = (props: IconSvgProps) => (
</svg>
</>
)
export const FileIcon = (props: IconSvgProps) => (
<svg
version="1.1"
id="_x36_"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512"
xmlSpace="preserve"
{...props}
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g
id="SVGRepo_tracerCarrier"
strokeLinecap="round"
strokeLinejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
<g>
<path
style={{ fill: '#D4B476' }}
d="M441.853,393.794H70.147C31.566,393.794,0,362.228,0,323.647V106.969 c0-38.581,31.566-70.147,70.147-70.147h371.706c38.581,0,70.147,31.566,70.147,70.147v216.678 C512,362.228,480.434,393.794,441.853,393.794z"
></path>
<path
style={{ fill: '#D4B476' }}
d="M199.884,249.574H70.147C31.566,249.574,0,218.008,0,179.427V70.147C0,31.566,31.566,0,70.147,0 h129.737c38.581,0,70.147,31.566,70.147,70.147v109.28C270.031,218.008,238.465,249.574,199.884,249.574z"
></path>
<polygon
style={{ fill: '#F0EFEF' }}
points="485.439,329.388 87.357,347.774 78.653,130.095 476.734,111.709 "
></polygon>
<defs>
<filter
id="Adobe_OpacityMaskFilter"
filterUnits="userSpaceOnUse"
x="34.381"
y="60.216"
width="416.68"
height="259.557"
>
<feFlood
style={{
floodColor: 'white',
floodOpacity: 1
}}
result="back"
></feFlood>
<feBlend in="SourceGraphic" in2="back" mode="normal"></feBlend>
</filter>
</defs>
<mask
maskUnits="userSpaceOnUse"
x="34.381"
y="60.216"
width="416.68"
height="259.557"
id="SVGID_1_"
>
<g style={{ filter: 'url(#Adobe_OpacityMaskFilter)' }}>
<defs>
<filter
id="Adobe_OpacityMaskFilter_1_"
filterUnits="userSpaceOnUse"
x="34.381"
y="60.216"
width="416.68"
height="259.557"
>
<feFlood
style={{ floodColor: 'white', floodOpacity: 1 }}
result="back"
></feFlood>
<feBlend in="SourceGraphic" in2="back" mode="normal"></feBlend>
</filter>
</defs>
<mask
maskUnits="userSpaceOnUse"
x="34.381"
y="60.216"
width="416.68"
height="259.557"
id="SVGID_1_"
>
<g style={{ filter: 'url(#Adobe_OpacityMaskFilter_1_)' }}> </g>
</mask>
<linearGradient
id="SVGID_2_"
gradientUnits="userSpaceOnUse"
x1="34.3814"
y1="189.9944"
x2="451.061"
y2="189.9944"
>
<stop offset="0.57" style={{ stopColor: '#F6F6F6' }}></stop>
<stop offset="0.6039" style={{ stopColor: '#F6F6F6' }}></stop>
</linearGradient>
<polygon
style={{ mask: 'url(#SVGID_1_)', fill: 'url(#SVGID_2_)' }}
points="451.061,277.073 54.598,319.773 34.381,102.916 430.845,60.216 "
></polygon>
</g>
</mask>
<linearGradient
id="SVGID_3_"
gradientUnits="userSpaceOnUse"
x1="34.3814"
y1="189.9944"
x2="451.061"
y2="189.9944"
>
<stop offset="0.57" style={{ stopColor: '#FFFFFF' }}></stop>
<stop offset="0.6039" style={{ stopColor: '#F0F0F0' }}></stop>
</linearGradient>
<polygon
style={{ fill: 'url(#SVGID_3_)' }}
points="451.061,277.073 54.598,319.773 34.381,102.916 430.845,60.216 "
></polygon>
<path
style={{ fill: '#69A092' }}
d="M441.853,417.32H70.147C31.566,417.32,0,385.754,0,347.173V168.515h512v178.658 C512,385.754,480.434,417.32,441.853,417.32z"
></path>
<path
style={{ fill: '#D4B476' }}
d="M441.853,429.594H70.147C31.566,429.594,0,398.028,0,359.447V189.995h512v169.453 C512,398.028,480.434,429.594,441.853,429.594z"
></path>
<g>
<g>
<path
style={{ fill: '#CBBC89' }}
d="M41.051,330.321h28.918h7.581c0.686,0,1.357,0,2.012,0c0.655,0,1.171,0.126,1.545,0.375 c0.499,0.312,0.795,0.764,0.889,1.357c0.094,0.594,0.141,1.296,0.141,2.106c0,0.312,0.014,0.608,0.047,0.888 c0.031,0.281-0.016,0.547-0.14,0.796c-0.25,0.998-0.796,1.543-1.638,1.638c-0.843,0.094-1.888,0.14-3.136,0.14h-9.733H53.778 c-0.998,0-2.058-0.015-3.182-0.047c-1.122-0.03-1.965,0.173-2.526,0.608c-0.563,0.375-0.858,1.03-0.89,1.965 c-0.032,0.937-0.047,1.873-0.047,2.808v9.265c0,0.5-0.015,1.123-0.046,1.872c-0.033,0.749,0.014,1.373,0.139,1.871v0.748 c0.125,0.375,0.235,0.735,0.328,1.077c0.093,0.344,0.295,0.608,0.608,0.796c0.562,0.374,1.436,0.547,2.621,0.514 c1.184-0.03,2.214-0.047,3.088-0.047h16.845c0.561,0,1.171-0.014,1.825-0.046c0.655-0.031,1.31-0.031,1.965,0 c0.655,0.032,1.248,0.109,1.779,0.234c0.529,0.126,0.889,0.344,1.076,0.655c0.311,0.375,0.467,0.843,0.467,1.404 c0,0.56,0,1.186,0,1.871c0,0.375,0,0.719,0,1.03c0,0.313-0.062,0.594-0.186,0.842c-0.25,0.625-0.595,0.969-1.03,1.03 c-0.25,0.125-0.485,0.186-0.702,0.186c-0.219,0-0.483,0.033-0.796,0.094c-0.25,0.063-0.514,0.079-0.796,0.047 c-0.281-0.03-0.546-0.047-0.794-0.047h-3.277H54.433c-0.999,0-2.168-0.014-3.51-0.047c-1.342-0.03-2.292,0.172-2.853,0.609 c-0.314,0.25-0.547,0.64-0.702,1.169c-0.156,0.531-0.25,1.14-0.281,1.825c-0.033,0.687-0.033,1.389,0,2.106 c0.03,0.717,0.046,1.357,0.046,1.918v16.096c0,1.062,0.032,2.217,0.094,3.463c0.062,1.249-0.156,2.185-0.655,2.807 c-0.186,0.25-0.469,0.391-0.842,0.422c-0.374,0.032-0.749,0.109-1.123,0.234h-1.779c-0.875,0-1.684-0.03-2.433-0.093 c-0.749-0.062-1.279-0.375-1.59-0.937c-0.25-0.374-0.375-0.888-0.375-1.543c0-0.656,0-1.326,0-2.013v-7.488v-39.119v-10.107 c0-0.686-0.016-1.45-0.047-2.292c-0.032-0.842,0.107-1.512,0.422-2.012c0.249-0.374,0.715-0.685,1.404-0.935 c0.124-0.062,0.264-0.077,0.42-0.047C40.785,330.4,40.925,330.383,41.051,330.321z"
></path>
<path
style={{ fill: '#CBBC89' }}
d="M96.895,330.508c0.436,0,0.935-0.014,1.496-0.046c0.563-0.032,1.108-0.032,1.639,0 c0.529,0.032,1.013,0.109,1.45,0.234c0.437,0.125,0.749,0.312,0.937,0.561c0.374,0.438,0.576,1.046,0.608,1.825 c0.03,0.78,0.047,1.576,0.047,2.386v9.079v36.124v10.949c0,0.811,0,1.669,0,2.574c0,0.905-0.156,1.606-0.468,2.105 c-0.25,0.314-0.547,0.5-0.889,0.563c-0.344,0.062-0.765,0.156-1.264,0.28h-1.777c-0.874,0-1.684-0.034-2.433-0.094 c-0.749-0.061-1.28-0.374-1.592-0.935c-0.25-0.374-0.375-0.89-0.375-1.545c0-0.655,0-1.325,0-2.012v-7.487V345.95v-10.107 c0-0.686-0.015-1.451-0.045-2.292c-0.034-0.843,0.108-1.514,0.42-2.013c0.249-0.436,0.748-0.748,1.497-0.936 c0.125-0.061,0.25-0.077,0.375-0.047C96.645,330.587,96.769,330.571,96.895,330.508z"
></path>
<path
style={{ fill: '#CBBC89' }}
d="M126.093,330.321c0.436,0,0.935-0.015,1.498-0.047c0.561-0.03,1.107-0.03,1.638,0 c0.529,0.032,1.012,0.109,1.451,0.234c0.436,0.125,0.748,0.313,0.936,0.562c0.374,0.5,0.56,1.155,0.56,1.965 c0,0.811,0,1.654,0,2.528v9.826v30.042v8.609c0,0.687,0,1.436,0,2.245c0,0.813,0.094,1.468,0.281,1.967 c0.188,0.436,0.529,0.811,1.03,1.122c0.313,0.125,0.717,0.203,1.217,0.235c0.499,0.032,0.998,0.046,1.498,0.046h4.772h16.845 h5.24c0.436,0,0.89-0.014,1.359-0.046c0.467-0.032,0.888,0.016,1.263,0.139c0.81,0.25,1.34,0.594,1.591,1.03 c0.062,0.188,0.124,0.5,0.188,0.937c0.061,0.436,0.107,0.889,0.139,1.357c0.03,0.467,0.015,0.935-0.047,1.402 c-0.062,0.469-0.126,0.829-0.187,1.077c-0.126,0.437-0.406,0.782-0.843,1.029c-0.313,0.25-0.765,0.375-1.357,0.375 c-0.593,0-1.169,0-1.731,0h-6.177H133.58h-6.083c-0.874,0-1.623-0.047-2.246-0.139c-0.624-0.094-1.092-0.39-1.404-0.89 c-0.25-0.374-0.373-0.857-0.373-1.451c0-0.591,0-1.168,0-1.729v-6.74v-25.549v-20.308v-5.802c0-0.561,0.014-1.122,0.045-1.684 c0.032-0.561,0.141-0.998,0.328-1.31c0.249-0.374,0.717-0.685,1.404-0.935c0.124-0.062,0.265-0.077,0.422-0.047 C125.827,330.4,125.968,330.383,126.093,330.321z"
></path>
<path
style={{ fill: '#CBBC89' }}
d="M187.883,330.321h30.602h8.049c0.686,0,1.372-0.015,2.059-0.047 c0.685-0.03,1.248,0.109,1.684,0.422c0.188,0.125,0.343,0.281,0.468,0.467c0.124,0.188,0.249,0.375,0.374,0.563 c0.125,0.436,0.187,1.138,0.187,2.105c0,0.967-0.062,1.701-0.187,2.199c-0.25,0.874-0.78,1.357-1.592,1.451 c-0.811,0.094-1.777,0.14-2.9,0.14h-9.359h-15.722c-0.998,0-2.169-0.015-3.51-0.047c-1.343-0.03-2.325,0.139-2.949,0.514 c-0.624,0.374-0.951,1.03-0.982,1.966c-0.032,0.936-0.048,1.904-0.048,2.901v9.546c0,0.688-0.015,1.498-0.045,2.433 c-0.034,0.937,0.014,1.686,0.139,2.246c0.188,0.749,0.562,1.249,1.123,1.498c0.249,0.125,0.686,0.25,1.31,0.373h0.935 c0.249,0.063,0.515,0.08,0.796,0.047c0.281-0.03,0.577-0.047,0.89-0.047h3.181h18.343c0.561,0,1.184-0.014,1.872-0.046 c0.685-0.031,1.387-0.031,2.104,0c0.717,0.032,1.372,0.093,1.967,0.187c0.591,0.094,1.044,0.233,1.357,0.421 c0.186,0.126,0.341,0.407,0.467,0.842c0.125,0.438,0.218,0.905,0.282,1.404c0.061,0.5,0.077,1.016,0.045,1.545 c-0.031,0.531-0.109,0.983-0.233,1.357c-0.126,0.561-0.406,0.967-0.842,1.217c-0.127,0.126-0.297,0.203-0.515,0.235 c-0.22,0.032-0.422,0.079-0.608,0.139h-0.563c-0.312,0.063-0.623,0.079-0.935,0.047c-0.313-0.03-0.624-0.047-0.935-0.047h-3.463 h-19.747c-0.747,0-1.482-0.014-2.199-0.047c-0.719-0.03-1.39,0-2.012,0.094c-0.624,0.094-1.171,0.267-1.639,0.515 c-0.467,0.25-0.794,0.687-0.982,1.31c-0.126,0.375-0.173,0.811-0.139,1.31c0.03,0.5,0.045,0.999,0.045,1.498v5.239v8.892 c0,0.873,0.032,1.669,0.094,2.386c0.062,0.718,0.312,1.233,0.749,1.544c0.435,0.313,0.935,0.483,1.497,0.515 c0.561,0.032,1.185,0.046,1.871,0.046h5.99h17.314h5.334c0.499,0,0.998-0.014,1.497-0.046c0.498-0.032,0.936,0.016,1.31,0.139 c0.749,0.25,1.248,0.594,1.498,1.03c0.123,0.188,0.217,0.5,0.281,0.937c0.061,0.436,0.108,0.889,0.14,1.357 c0.03,0.467,0.015,0.935-0.047,1.402c-0.062,0.469-0.126,0.829-0.187,1.077c-0.25,0.5-0.531,0.842-0.842,1.029 c-0.375,0.25-0.858,0.375-1.45,0.375c-0.594,0-1.171,0-1.731,0h-6.552h-24.894h-6.551c-0.874,0-1.623-0.047-2.245-0.139 c-0.624-0.094-1.093-0.39-1.404-0.89c-0.25-0.374-0.374-0.857-0.374-1.451c0-0.591,0-1.168,0-1.729v-6.551v-25.082v-20.964 v-5.802c0-0.561,0-1.122,0-1.684c0-0.561,0.124-0.998,0.374-1.31c0.249-0.435,0.716-0.749,1.404-0.935 c0.125-0.062,0.248-0.077,0.375-0.047C187.632,330.4,187.756,330.383,187.883,330.321z"
></path>
</g>
<g>
<path
style={{ fill: '#98806E' }}
d="M41.051,330.321h28.918h7.581c0.686,0,1.357,0,2.012,0c0.655,0,1.171,0.126,1.545,0.375 c0.499,0.312,0.795,0.764,0.889,1.357c0.094,0.594,0.141,1.296,0.141,2.106c0,0.312,0.014,0.608,0.047,0.888 c0.031,0.281-0.016,0.547-0.14,0.796c-0.25,0.998-0.796,1.543-1.638,1.638c-0.843,0.094-1.888,0.14-3.136,0.14h-9.733H53.778 c-0.998,0-2.058-0.015-3.182-0.047c-1.122-0.03-1.965,0.173-2.526,0.608c-0.563,0.375-0.858,1.03-0.89,1.965 c-0.032,0.937-0.047,1.873-0.047,2.808v9.265c0,0.5-0.015,1.123-0.046,1.872c-0.033,0.749,0.014,1.373,0.139,1.871v0.748 c0.125,0.375,0.235,0.735,0.328,1.077c0.093,0.344,0.295,0.608,0.608,0.796c0.562,0.374,1.436,0.547,2.621,0.514 c1.184-0.03,2.214-0.047,3.088-0.047h16.845c0.561,0,1.171-0.014,1.825-0.046c0.655-0.031,1.31-0.031,1.965,0 c0.655,0.032,1.248,0.109,1.779,0.234c0.529,0.126,0.889,0.344,1.076,0.655c0.311,0.375,0.467,0.843,0.467,1.404 c0,0.56,0,1.186,0,1.871c0,0.375,0,0.719,0,1.03c0,0.313-0.062,0.594-0.186,0.842c-0.25,0.625-0.595,0.969-1.03,1.03 c-0.25,0.125-0.485,0.186-0.702,0.186c-0.219,0-0.483,0.033-0.796,0.094c-0.25,0.063-0.514,0.079-0.796,0.047 c-0.281-0.03-0.546-0.047-0.794-0.047h-3.277H54.433c-0.999,0-2.168-0.014-3.51-0.047c-1.342-0.03-2.292,0.172-2.853,0.609 c-0.314,0.25-0.547,0.64-0.702,1.169c-0.156,0.531-0.25,1.14-0.281,1.825c-0.033,0.687-0.033,1.389,0,2.106 c0.03,0.717,0.046,1.357,0.046,1.918v16.096c0,1.062,0.032,2.217,0.094,3.463c0.062,1.249-0.156,2.185-0.655,2.807 c-0.186,0.25-0.469,0.391-0.842,0.422c-0.374,0.032-0.749,0.109-1.123,0.234h-1.779c-0.875,0-1.684-0.03-2.433-0.093 c-0.749-0.062-1.279-0.375-1.59-0.937c-0.25-0.374-0.375-0.888-0.375-1.543c0-0.656,0-1.326,0-2.013v-7.488v-39.119v-10.107 c0-0.686-0.016-1.45-0.047-2.292c-0.032-0.842,0.107-1.512,0.422-2.012c0.249-0.374,0.715-0.685,1.404-0.935 c0.124-0.062,0.264-0.077,0.42-0.047C40.785,330.4,40.925,330.383,41.051,330.321z"
></path>
<path
style={{ fill: '#98806E' }}
d="M96.895,330.508c0.436,0,0.935-0.014,1.496-0.046c0.563-0.032,1.108-0.032,1.639,0 c0.529,0.032,1.013,0.109,1.45,0.234c0.437,0.125,0.749,0.312,0.937,0.561c0.374,0.438,0.576,1.046,0.608,1.825 c0.03,0.78,0.047,1.576,0.047,2.386v9.079v36.124v10.949c0,0.811,0,1.669,0,2.574c0,0.905-0.156,1.606-0.468,2.105 c-0.25,0.314-0.547,0.5-0.889,0.563c-0.344,0.062-0.765,0.156-1.264,0.28h-1.777c-0.874,0-1.684-0.034-2.433-0.094 c-0.749-0.061-1.28-0.374-1.592-0.935c-0.25-0.374-0.375-0.89-0.375-1.545c0-0.655,0-1.325,0-2.012v-7.487V345.95v-10.107 c0-0.686-0.015-1.451-0.045-2.292c-0.034-0.843,0.108-1.514,0.42-2.013c0.249-0.436,0.748-0.748,1.497-0.936 c0.125-0.061,0.25-0.077,0.375-0.047C96.645,330.587,96.769,330.571,96.895,330.508z"
></path>
<path
style={{ fill: '#98806E' }}
d="M126.093,330.321c0.436,0,0.935-0.015,1.498-0.047c0.561-0.03,1.107-0.03,1.638,0 c0.529,0.032,1.012,0.109,1.451,0.234c0.436,0.125,0.748,0.313,0.936,0.562c0.374,0.5,0.56,1.155,0.56,1.965 c0,0.811,0,1.654,0,2.528v9.826v30.042v8.609c0,0.687,0,1.436,0,2.245c0,0.813,0.094,1.468,0.281,1.967 c0.188,0.436,0.529,0.811,1.03,1.122c0.313,0.125,0.717,0.203,1.217,0.235c0.499,0.032,0.998,0.046,1.498,0.046h4.772h16.845 h5.24c0.436,0,0.89-0.014,1.359-0.046c0.467-0.032,0.888,0.016,1.263,0.139c0.81,0.25,1.34,0.594,1.591,1.03 c0.062,0.188,0.124,0.5,0.188,0.937c0.061,0.436,0.107,0.889,0.139,1.357c0.03,0.467,0.015,0.935-0.047,1.402 c-0.062,0.469-0.126,0.829-0.187,1.077c-0.126,0.437-0.406,0.782-0.843,1.029c-0.313,0.25-0.765,0.375-1.357,0.375 c-0.593,0-1.169,0-1.731,0h-6.177H133.58h-6.083c-0.874,0-1.623-0.047-2.246-0.139c-0.624-0.094-1.092-0.39-1.404-0.89 c-0.25-0.374-0.373-0.857-0.373-1.451c0-0.591,0-1.168,0-1.729v-6.74v-25.549v-20.308v-5.802c0-0.561,0.014-1.122,0.045-1.684 c0.032-0.561,0.141-0.998,0.328-1.31c0.249-0.374,0.717-0.685,1.404-0.935c0.124-0.062,0.265-0.077,0.422-0.047 C125.827,330.4,125.968,330.383,126.093,330.321z"
></path>
<path
style={{ fill: '#98806E' }}
d="M187.883,330.321h30.602h8.049c0.686,0,1.372-0.015,2.059-0.047 c0.685-0.03,1.248,0.109,1.684,0.422c0.188,0.125,0.343,0.281,0.468,0.467c0.124,0.188,0.249,0.375,0.374,0.563 c0.125,0.436,0.187,1.138,0.187,2.105c0,0.967-0.062,1.701-0.187,2.199c-0.25,0.874-0.78,1.357-1.592,1.451 c-0.811,0.094-1.777,0.14-2.9,0.14h-9.359h-15.722c-0.998,0-2.169-0.015-3.51-0.047c-1.343-0.03-2.325,0.139-2.949,0.514 c-0.624,0.374-0.951,1.03-0.982,1.966c-0.032,0.936-0.048,1.904-0.048,2.901v9.546c0,0.688-0.015,1.498-0.045,2.433 c-0.034,0.937,0.014,1.686,0.139,2.246c0.188,0.749,0.562,1.249,1.123,1.498c0.249,0.125,0.686,0.25,1.31,0.373h0.935 c0.249,0.063,0.515,0.08,0.796,0.047c0.281-0.03,0.577-0.047,0.89-0.047h3.181h18.343c0.561,0,1.184-0.014,1.872-0.046 c0.685-0.031,1.387-0.031,2.104,0c0.717,0.032,1.372,0.093,1.967,0.187c0.591,0.094,1.044,0.233,1.357,0.421 c0.186,0.126,0.341,0.407,0.467,0.842c0.125,0.438,0.218,0.905,0.282,1.404c0.061,0.5,0.077,1.016,0.045,1.545 c-0.031,0.531-0.109,0.983-0.233,1.357c-0.126,0.561-0.406,0.967-0.842,1.217c-0.127,0.126-0.297,0.203-0.515,0.235 c-0.22,0.032-0.422,0.079-0.608,0.139h-0.563c-0.312,0.063-0.623,0.079-0.935,0.047c-0.313-0.03-0.624-0.047-0.935-0.047h-3.463 h-19.747c-0.747,0-1.482-0.014-2.199-0.047c-0.719-0.03-1.39,0-2.012,0.094c-0.624,0.094-1.171,0.267-1.639,0.515 c-0.467,0.25-0.794,0.687-0.982,1.31c-0.126,0.375-0.173,0.811-0.139,1.31c0.03,0.5,0.045,0.999,0.045,1.498v5.239v8.892 c0,0.873,0.032,1.669,0.094,2.386c0.062,0.718,0.312,1.233,0.749,1.544c0.435,0.313,0.935,0.483,1.497,0.515 c0.561,0.032,1.185,0.046,1.871,0.046h5.99h17.314h5.334c0.499,0,0.998-0.014,1.497-0.046c0.498-0.032,0.936,0.016,1.31,0.139 c0.749,0.25,1.248,0.594,1.498,1.03c0.123,0.188,0.217,0.5,0.281,0.937c0.061,0.436,0.108,0.889,0.14,1.357 c0.03,0.467,0.015,0.935-0.047,1.402c-0.062,0.469-0.126,0.829-0.187,1.077c-0.25,0.5-0.531,0.842-0.842,1.029 c-0.375,0.25-0.858,0.375-1.45,0.375c-0.594,0-1.171,0-1.731,0h-6.552h-24.894h-6.551c-0.874,0-1.623-0.047-2.245-0.139 c-0.624-0.094-1.093-0.39-1.404-0.89c-0.25-0.374-0.374-0.857-0.374-1.451c0-0.591,0-1.168,0-1.729v-6.551v-25.082v-20.964 v-5.802c0-0.561,0-1.122,0-1.684c0-0.561,0.124-0.998,0.374-1.31c0.249-0.435,0.716-0.749,1.404-0.935 c0.125-0.062,0.248-0.077,0.375-0.047C187.632,330.4,187.756,330.383,187.883,330.321z"
></path>
</g>
</g>
<polygon
style={{ fill: '#BBAF98' }}
points="276.167,208.741 0,302.069 0,186.053 512,186.053 512,302.069 "
></polygon>
</g>
</g>
</svg>
)
export const LogIcon = (props: IconSvgProps) => (
<svg
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g
id="SVGRepo_tracerCarrier"
strokeLinecap="round"
strokeLinejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
<rect width="48" height="48" fill="white" fillOpacity="0.01"></rect>
<rect
x="13"
y="10"
width="28"
height="34"
fill="#2F88FF"
stroke="#000000"
strokeWidth="4"
strokeLinejoin="round"
></rect>
<path
d="M35 10V4H8C7.44772 4 7 4.44772 7 5V38H13"
stroke="#000000"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
></path>
<path
d="M21 22H33"
stroke="white"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
></path>
<path
d="M21 30H33"
stroke="white"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</g>
</svg>
)

View File

@@ -43,7 +43,7 @@ const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
onChange('')
if (inputRef.current) inputRef.current.value = ''
}}
color="danger"
color="primary"
variant="flat"
size="sm"
>

View File

@@ -16,13 +16,13 @@ const logLevelColor: {
| 'secondary'
| 'success'
| 'warning'
| 'danger'
| 'primary'
} = {
[LogLevel.DEBUG]: 'default',
[LogLevel.INFO]: 'primary',
[LogLevel.WARN]: 'warning',
[LogLevel.ERROR]: 'danger',
[LogLevel.FATAL]: 'danger'
[LogLevel.ERROR]: 'primary',
[LogLevel.FATAL]: 'primary'
}
const LogLevelSelect = (props: LogLevelSelectProps) => {
const { selectedKeys, onSelectionChange } = props

View File

@@ -47,7 +47,6 @@ const RealTimeLogs = () => {
}
return logLevel.has(log.level)
})
.slice(-100)
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
.join('\r\n')
Xterm.current?.clear()
@@ -65,7 +64,6 @@ const RealTimeLogs = () => {
useEffect(() => {
const subscribeLogs = () => {
try {
console.log('subscribeLogs')
const source = WebUIManager.getRealTimeLogs((data) => {
setDataArr((prev) => {
const newData = [...prev, ...data]

View File

@@ -13,12 +13,15 @@ export interface ModalProps {
content: React.ReactNode
title?: React.ReactNode
size?: React.ComponentProps<typeof NextUIModal>['size']
scrollBehavior?: React.ComponentProps<typeof NextUIModal>['scrollBehavior']
onClose?: () => void
onConfirm?: () => void
onCancel?: () => void
backdrop?: 'opaque' | 'blur' | 'transparent'
showCancel?: boolean
dismissible?: boolean
confirmText?: string
cancelText?: string
}
const Modal: React.FC<ModalProps> = React.memo((props) => {
@@ -26,12 +29,14 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
backdrop = 'blur',
title,
content,
size = 'md',
showCancel = true,
dismissible,
confirmText = '确定',
cancelText = '取消',
onClose,
onConfirm,
onCancel
onCancel,
...rest
} = props
const { onClose: onNativeClose } = useDisclosure()
@@ -44,11 +49,11 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
onClose?.()
onNativeClose()
}}
size={size}
classNames={{
backdrop: 'z-[99999999]',
wrapper: 'z-[999999999]'
backdrop: 'z-[99]',
wrapper: 'z-[99]'
}}
{...rest}
>
<ModalContent>
{(nativeClose) => (
@@ -56,18 +61,18 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
{title && (
<ModalHeader className="flex flex-col gap-1">{title}</ModalHeader>
)}
<ModalBody>{content}</ModalBody>
<ModalBody className="break-all">{content}</ModalBody>
<ModalFooter>
{showCancel && (
<Button
color="danger"
color="primary"
variant="light"
onPress={() => {
onCancel?.()
nativeClose()
}}
>
{cancelText}
</Button>
)}
<Button
@@ -77,7 +82,7 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
nativeClose()
}}
>
{confirmText}
</Button>
</ModalFooter>
</>

View File

@@ -28,7 +28,7 @@ import type {
function displayData(data: number, loading: boolean, error?: Error) {
if (error) {
return <MdError className="text-danger-400" />
return <MdError className="text-primary-400" />
}
if (loading) {
@@ -175,7 +175,7 @@ export default function NapCatRepoInfo() {
className="group h-auto py-3"
endContent={
releaseError ? (
<MdError className="text-danger-400" />
<MdError className="text-primary-400" />
) : releaseLoading ? (
<Spinner size="sm" />
) : (
@@ -229,7 +229,7 @@ export default function NapCatRepoInfo() {
</span>
}
startContent={
<IconWrapper className="bg-danger/10 text-danger dark:text-danger-500">
<IconWrapper className="bg-primary/10 text-primary dark:text-primary-500">
<BookIcon />
</IconWrapper>
}

View File

@@ -150,7 +150,7 @@ const GenericForm = <T extends keyof NetworkConfigType>({
</ModalBody>
<ModalFooter>
<Button
color="danger"
color="primary"
isDisabled={formState.isSubmitting}
variant="light"
onPress={onClose}

View File

@@ -20,7 +20,7 @@ const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
enable: false,
name: '',
host: '0.0.0.0',
port: 3000,
port: 3001,
reportSelfMessage: false,
enableForcePushEvent: true,
messagePostFormat: 'array',

View File

@@ -2,12 +2,14 @@ import { Button } from '@heroui/button'
import { Card, CardBody, CardHeader } from '@heroui/card'
import { Input } from '@heroui/input'
import { Snippet } from '@heroui/snippet'
import { useLocalStorage } from '@uidotdev/usehooks'
import { motion } from 'motion/react'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import { IoLink, IoSend } from 'react-icons/io5'
import { PiCatDuotone } from 'react-icons/pi'
import key from '@/const/key'
import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api'
import ChatInputModal from '@/components/chat_input/modal'
@@ -27,9 +29,10 @@ export interface OneBotApiDebugProps {
const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
const { path, data } = props
const url = new URL(window.location.origin).href
const defaultHttpUrl = url.replace(':6099', ':3000')
const [httpConfig, setHttpConfig] = useState({
const currentURL = new URL(window.location.origin)
currentURL.port = '3000'
const defaultHttpUrl = currentURL.href
const [httpConfig, setHttpConfig] = useLocalStorage(key.httpDebugConfig, {
url: defaultHttpUrl,
token: ''
})
@@ -38,6 +41,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
const [isCodeEditorOpen, setIsCodeEditorOpen] = useState(false)
const [isResponseOpen, setIsResponseOpen] = useState(false)
const [isFetching, setIsFetching] = useState(false)
const responseRef = useRef<HTMLDivElement>(null)
const parsedRequest = parse(data.request)
const parsedResponse = parse(data.response)
@@ -47,8 +51,10 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
const r = toast.loading('正在发送请求...')
try {
const parsedRequestBody = JSON.parse(requestBody)
const requestURL = new URL(httpConfig.url)
requestURL.pathname = path
request
.post(httpConfig.url + path, parsedRequestBody, {
.post(requestURL.href, parsedRequestBody, {
headers: {
Authorization: `Bearer ${httpConfig.token}`
},
@@ -64,6 +70,11 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
})
.finally(() => {
setIsFetching(false)
setIsResponseOpen(true)
responseRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start'
})
toast.dismiss(r)
})
} catch (error) {
@@ -79,8 +90,8 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
}, [path])
return (
<div className="flex-1 overflow-y-auto p-4 rounded-lg shadow-md">
<h1 className="text-2xl font-bold mb-4 flex items-center gap-1 text-danger-400 ">
<section className="p-4 pt-14 rounded-lg shadow-md">
<h1 className="text-2xl font-bold mb-4 flex items-center gap-1 text-primary-400">
<PiCatDuotone />
{data.description}
</h1>
@@ -88,6 +99,9 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
<Snippet
className="bg-default-50 bg-opacity-50 backdrop-blur-md"
symbol={<IoLink size={18} className="inline-block mr-1" />}
tooltipProps={{
content: '点击复制地址'
}}
>
{path}
</Snippet>
@@ -111,7 +125,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
/>
<Button
onPress={sendRequest}
color="danger"
color="primary"
size="lg"
radius="full"
isIconOnly
@@ -120,8 +134,11 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
<IoSend />
</Button>
</div>
<Card shadow="sm" className="my-4 bg-opacity-50 backdrop-blur-md">
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
<Card
shadow="sm"
className="my-4 bg-opacity-50 backdrop-blur-md overflow-visible z-20"
>
<CardHeader className="font-bold text-lg gap-1 pb-0">
<span className="mr-2"></span>
<Button
color="warning"
@@ -135,7 +152,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
</CardHeader>
<CardBody>
<motion.div
className="overflow-hidden"
ref={responseRef}
initial={{ opacity: 0, height: 0 }}
animate={{
opacity: isCodeEditorOpen ? 1 : 0,
@@ -169,7 +186,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
className="my-4 relative bg-opacity-50 backdrop-blur-md"
>
<PageLoading loading={isFetching} />
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
<CardHeader className="font-bold text-lg gap-1 pb-0">
<span className="mr-2"></span>
<Button
color="warning"
@@ -218,7 +235,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
<h2 className="text-xl font-semibold mt-4 mb-2"></h2>
<DisplayStruct schema={parsedResponse} />
</div>
</div>
</section>
)
}

View File

@@ -27,7 +27,7 @@ const SchemaType = ({
name = '固定值'
break
}
let chipColor: 'primary' | 'success' | 'danger' | 'warning' | 'secondary' =
let chipColor: 'primary' | 'success' | 'primary' | 'warning' | 'secondary' =
'primary'
switch (type) {
case 'enum':
@@ -37,7 +37,7 @@ const SchemaType = ({
chipColor = 'secondary'
break
case 'array':
chipColor = 'danger'
chipColor = 'primary'
break
case 'object':
chipColor = 'success'

View File

@@ -19,9 +19,8 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
return (
<motion.div
className={clsx(
'flex-shrink-0 absolute md:!top-0 md:bottom-0 left-0 !overflow-hidden md:relative md:w-auto z-20',
openSideBar &&
'bottom-8 z-10 bg-background bg-opacity-20 backdrop-blur-md top-14'
'h-[calc(100vh-3.5rem)] left-0 !overflow-hidden md:w-auto z-20 top-[3.3rem] md:top-[3rem] absolute md:sticky md:float-start',
openSideBar && 'bg-background bg-opacity-20 backdrop-blur-md'
)}
initial={{ width: 0 }}
transition={{
@@ -32,13 +31,13 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
animate={{ width: openSideBar ? '16rem' : '0rem' }}
style={{ overflowY: openSideBar ? 'auto' : 'hidden' }}
>
<div className="w-64 h-full overflow-y-auto px-2 float-right">
<div className="w-64 h-full overflow-y-auto px-2 pt-2 pb-10 md:pb-0">
<Input
className="sticky top-0 z-10 text-danger-600"
className="sticky top-0 z-10 text-primary-600"
classNames={{
inputWrapper:
'bg-opacity-30 bg-danger-50 backdrop-blur-sm border border-danger-300 mb-2',
input: 'bg-transparent !text-danger-400 !placeholder-danger-400'
'bg-opacity-30 bg-primary-50 backdrop-blur-sm border border-primary-300 mb-2',
input: 'bg-transparent !text-primary-400 !placeholder-primary-400'
}}
radius="full"
placeholder="搜索 API"
@@ -52,7 +51,7 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
key={apiName}
shadow="none"
className={clsx(
'w-full border border-danger-100 rounded-lg mb-1 bg-opacity-30 backdrop-blur-sm text-danger-400',
'w-full border border-primary-100 rounded-lg mb-1 bg-opacity-30 backdrop-blur-sm text-primary-400',
{
hidden: !(
apiName.includes(searchValue) ||
@@ -60,7 +59,7 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
)
},
{
'!bg-opacity-40 border border-danger-400 bg-danger-50 text-danger-600':
'!bg-opacity-40 border border-primary-400 bg-primary-50 text-primary-600':
apiName === selectedApi
}
)}
@@ -68,10 +67,10 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
>
<CardBody>
<h2 className="font-ubuntu font-bold">{api.description}</h2>
<h2 className="font-bold">{api.description}</h2>
<div
className={clsx('text-sm text-danger-200', {
'!text-danger-400': apiName === selectedApi
className={clsx('text-sm text-primary-200', {
'!text-primary-400': apiName === selectedApi
})}
>
{apiName}

View File

@@ -109,7 +109,7 @@ const OneBotItemRender = ({ data, index, style }: OneBotItemRenderProps) => {
<PopoverTrigger>
<Button
size="sm"
color="danger"
color="primary"
variant="flat"
radius="full"
isIconOnly

View File

@@ -30,7 +30,7 @@ const OneBotDisplayResponse: React.FC<OneBotDisplayResponseProps> = ({
<PopoverTrigger>
<Button
size="sm"
color="danger"
color="primary"
variant="flat"
radius="full"
className="text-medium"

View File

@@ -10,6 +10,7 @@ import {
import { useCallback, useRef } from 'react'
import toast from 'react-hot-toast'
import ChatInputModal from '@/components/chat_input/modal'
import CodeEditor from '@/components/code_editor'
import type { CodeEditorRef } from '@/components/code_editor'
@@ -42,7 +43,7 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
return (
<>
<Button onPress={onOpen} color="danger" radius="full" variant="flat">
<Button onPress={onOpen} color="primary" radius="full" variant="flat">
</Button>
<Modal
@@ -72,11 +73,13 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
</div>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="flat" onPress={onClose}>
<ChatInputModal />
<Button color="primary" variant="flat" onPress={onClose}>
</Button>
<Button
color="danger"
color="primary"
onPress={() => handleSendMessage(onClose)}
>

View File

@@ -10,7 +10,7 @@ function StatusTag({
color
}: {
title: string
color: 'success' | 'danger' | 'warning'
color: 'success' | 'primary' | 'warning'
}) {
const textClassName = `text-${color} text-sm`
const bgClassName = `bg-${color}`
@@ -27,7 +27,7 @@ export default function WSStatus({ state }: WSStatusProps) {
return <StatusTag title="已连接" color="success" />
}
if (state === ReadyState.CLOSED) {
return <StatusTag title="已关闭" color="danger" />
return <StatusTag title="已关闭" color="primary" />
}
if (state === ReadyState.CONNECTING) {
return <StatusTag title="连接中" color="warning" />

View File

@@ -16,23 +16,21 @@ export interface QQInfoCardProps {
const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
return (
<Card
className="relative bg-danger-100 bg-opacity-60 overflow-hidden flex-shrink-0 shadow-md shadow-danger-300 dark:shadow-danger-50"
className="relative bg-primary-100 bg-opacity-60 overflow-hidden flex-shrink-0 shadow-md shadow-primary-300 dark:shadow-primary-50"
shadow="none"
radius="lg"
>
<PageLoading loading={loading} />
{error ? (
<CardBody className="items-center gap-1 justify-center">
<div className="font-outfit flex-1 text-content1-foreground">
Error
</div>
<div className="flex-1 text-content1-foreground">Error</div>
<div className="whitespace-nowrap text-nowrap flex-shrink-0">
{error.message}
</div>
</CardBody>
) : (
<CardBody className="flex-row items-center gap-2 overflow-hidden relative">
<div className="absolute right-0 bottom-0 text-5xl text-danger-400">
<div className="absolute right-0 bottom-0 text-5xl text-primary-400">
<BsTencentQq />
</div>
<div className="relative flex-shrink-0 z-10">
@@ -45,16 +43,14 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
/>
<div
className={clsx(
'w-4 h-4 rounded-full absolute right-0.5 bottom-0 border-2 border-danger-100 z-10',
'w-4 h-4 rounded-full absolute right-0.5 bottom-0 border-2 border-primary-100 z-10',
data?.online ? 'bg-green-500' : 'bg-gray-500'
)}
></div>
</div>
<div className="flex-col justify-center">
<div className="font-outfit text-lg truncate">{data?.nick}</div>
<div className="font-ubuntu text-danger-500 text-sm">
{data?.uin}
</div>
<div className="text-lg truncate">{data?.nick}</div>
<div className="text-primary-500 text-sm">{data?.uin}</div>
</div>
</CardBody>
)}

View File

@@ -11,7 +11,7 @@ const QrCodeLogin: React.FC<QrCodeLoginProps> = ({ qrcode }) => {
<div className="bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden">
{!qrcode && (
<div className="absolute left-2 top-2 right-2 bottom-2 bg-white bg-opacity-50 backdrop-blur flex items-center justify-center">
<Spinner color="danger" />
<Spinner color="primary" />
</div>
)}
<QRCodeSVG size={180} value={qrcode} />

View File

@@ -0,0 +1,265 @@
import {
AnimatePresence,
HTMLMotionProps,
TargetAndTransition,
Transition,
motion
} from 'motion/react'
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState
} from 'react'
function cn(...classes: (string | undefined | null | boolean)[]): string {
return classes.filter(Boolean).join(' ')
}
export interface RotatingTextRef {
next: () => void
previous: () => void
jumpTo: (index: number) => void
reset: () => void
}
export interface RotatingTextProps
extends Omit<
HTMLMotionProps<'span'>,
'children' | 'transition' | 'initial' | 'animate' | 'exit'
> {
texts: string[]
transition?: Transition
initial?: TargetAndTransition
animate?: TargetAndTransition
exit?: TargetAndTransition
animatePresenceMode?: 'sync' | 'wait'
animatePresenceInitial?: boolean
rotationInterval?: number
staggerDuration?: number
staggerFrom?: 'first' | 'last' | 'center' | 'random' | number
loop?: boolean
auto?: boolean
splitBy?: string
onNext?: (index: number) => void
mainClassName?: string
splitLevelClassName?: string
elementLevelClassName?: string
}
const RotatingText = forwardRef<RotatingTextRef, RotatingTextProps>(
(
{
texts,
transition = { type: 'spring', damping: 25, stiffness: 300 },
initial = { y: '100%', opacity: 0 },
animate = { y: 0, opacity: 1 },
exit = { y: '-120%', opacity: 0 },
animatePresenceMode = 'wait',
animatePresenceInitial = false,
rotationInterval = 2000,
staggerDuration = 0,
staggerFrom = 'first',
loop = true,
auto = true,
splitBy = 'characters',
onNext,
mainClassName,
splitLevelClassName,
elementLevelClassName,
...rest
},
ref
) => {
const [currentTextIndex, setCurrentTextIndex] = useState<number>(0)
const splitIntoCharacters = (text: string): string[] => {
return Array.from(text)
}
const elements = useMemo(() => {
const currentText: string = texts[currentTextIndex]
if (splitBy === 'characters') {
const words = currentText.split(' ')
return words.map((word, i) => ({
characters: splitIntoCharacters(word),
needsSpace: i !== words.length - 1
}))
}
if (splitBy === 'words') {
return currentText.split(' ').map((word, i, arr) => ({
characters: [word],
needsSpace: i !== arr.length - 1
}))
}
if (splitBy === 'lines') {
return currentText.split('\n').map((line, i, arr) => ({
characters: [line],
needsSpace: i !== arr.length - 1
}))
}
return currentText.split(splitBy).map((part, i, arr) => ({
characters: [part],
needsSpace: i !== arr.length - 1
}))
}, [texts, currentTextIndex, splitBy])
const getStaggerDelay = useCallback(
(index: number, totalChars: number): number => {
const total = totalChars
if (staggerFrom === 'first') return index * staggerDuration
if (staggerFrom === 'last') return (total - 1 - index) * staggerDuration
if (staggerFrom === 'center') {
const center = Math.floor(total / 2)
return Math.abs(center - index) * staggerDuration
}
if (staggerFrom === 'random') {
const randomIndex = Math.floor(Math.random() * total)
return Math.abs(randomIndex - index) * staggerDuration
}
return Math.abs((staggerFrom as number) - index) * staggerDuration
},
[staggerFrom, staggerDuration]
)
const handleIndexChange = useCallback(
(newIndex: number) => {
setCurrentTextIndex(newIndex)
if (onNext) onNext(newIndex)
},
[onNext]
)
const next = useCallback(() => {
const nextIndex =
currentTextIndex === texts.length - 1
? loop
? 0
: currentTextIndex
: currentTextIndex + 1
if (nextIndex !== currentTextIndex) {
handleIndexChange(nextIndex)
}
}, [currentTextIndex, texts.length, loop, handleIndexChange])
const previous = useCallback(() => {
const prevIndex =
currentTextIndex === 0
? loop
? texts.length - 1
: currentTextIndex
: currentTextIndex - 1
if (prevIndex !== currentTextIndex) {
handleIndexChange(prevIndex)
}
}, [currentTextIndex, texts.length, loop, handleIndexChange])
const jumpTo = useCallback(
(index: number) => {
const validIndex = Math.max(0, Math.min(index, texts.length - 1))
if (validIndex !== currentTextIndex) {
handleIndexChange(validIndex)
}
},
[texts.length, currentTextIndex, handleIndexChange]
)
const reset = useCallback(() => {
if (currentTextIndex !== 0) {
handleIndexChange(0)
}
}, [currentTextIndex, handleIndexChange])
useImperativeHandle(
ref,
() => ({
next,
previous,
jumpTo,
reset
}),
[next, previous, jumpTo, reset]
)
useEffect(() => {
if (!auto) return
const intervalId = setInterval(next, rotationInterval)
return () => clearInterval(intervalId)
}, [next, rotationInterval, auto])
return (
<motion.span
className={cn(
'flex flex-wrap whitespace-pre-wrap relative',
mainClassName
)}
{...rest}
layout
transition={transition}
>
<span className="sr-only">{texts[currentTextIndex]}</span>
<AnimatePresence
mode={animatePresenceMode}
initial={animatePresenceInitial}
>
<motion.div
key={currentTextIndex}
className={cn(
splitBy === 'lines'
? 'flex flex-col w-full'
: 'flex flex-wrap whitespace-pre-wrap relative'
)}
layout
aria-hidden="true"
initial={initial as HTMLMotionProps<'div'>['initial']}
animate={animate as HTMLMotionProps<'div'>['animate']}
exit={exit as HTMLMotionProps<'div'>['exit']}
>
{elements.map((wordObj, wordIndex, array) => {
const previousCharsCount = array
.slice(0, wordIndex)
.reduce((sum, word) => sum + word.characters.length, 0)
return (
<span
key={wordIndex}
className={cn('inline-flex', splitLevelClassName)}
>
{wordObj.characters.map((char, charIndex) => (
<motion.span
key={charIndex}
initial={initial as HTMLMotionProps<'span'>['initial']}
animate={animate as HTMLMotionProps<'span'>['animate']}
exit={exit as HTMLMotionProps<'span'>['exit']}
transition={{
...transition,
delay: getStaggerDelay(
previousCharsCount + charIndex,
array.reduce(
(sum, word) => sum + word.characters.length,
0
)
)
}}
className={cn('inline-block', elementLevelClassName)}
>
{char}
</motion.span>
))}
{wordObj.needsSpace && (
<span className="whitespace-pre"> </span>
)}
</span>
)
})}
</motion.div>
</AnimatePresence>
</motion.span>
)
}
)
RotatingText.displayName = 'RotatingText'
export default RotatingText

View File

@@ -13,7 +13,6 @@ import { useTheme } from '@/hooks/use-theme'
import logo from '@/assets/images/logo.png'
import type { MenuItem } from '@/config/site'
import { title } from '../primitives'
import Menus from './menus'
interface SideBarProps {
@@ -48,19 +47,15 @@ const SideBar: React.FC<SideBarProps> = (props) => {
style={{ overflow: 'hidden' }}
>
<motion.div className="w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right">
<div className="flex justify-center items-center mt-2 gap-2">
<Image height={40} src={logo} className="mb-2" />
<div className="flex justify-center items-center my-2 gap-2">
<Image radius="none" height={40} src={logo} className="mb-2" />
<div
className={clsx(
'flex items-center hm-medium',
title({
shadow: true,
color: isDark ? 'violet' : 'pink'
}),
'!text-2xl'
'flex items-center font-bold',
'!text-2xl shiny-text'
)}
>
WebUI
NapCat
</div>
</div>
<div className="overflow-y-auto flex flex-col flex-1 px-4">
@@ -68,7 +63,7 @@ const SideBar: React.FC<SideBarProps> = (props) => {
<div className="mt-auto mb-10 md:mb-0">
<Button
className="w-full"
color="danger"
color="primary"
radius="full"
variant="light"
onPress={toggleTheme}
@@ -80,7 +75,7 @@ const SideBar: React.FC<SideBarProps> = (props) => {
</Button>
<Button
className="w-full mb-2"
color="danger"
color="primary"
radius="full"
variant="light"
onPress={onRevokeAuth}

View File

@@ -55,7 +55,7 @@ const renderItems = (items: MenuItem[], children = false) => {
isActive && 'bg-opacity-60',
b64img && 'backdrop-blur-md text-white'
)}
color="danger"
color="primary"
endContent={
canOpen ? (
// div实现箭头V效果
@@ -63,7 +63,9 @@ const renderItems = (items: MenuItem[], children = false) => {
className={clsx(
'ml-auto relative w-3 h-3 transition-transform',
open && 'transform rotate-180',
isActive ? 'text-danger-500' : 'text-red-300 dark:text-white',
isActive
? 'text-primary-500'
: 'text-red-300 dark:text-white',
'before:rounded-full',
'before:content-[""]',
'before:block',
@@ -95,7 +97,7 @@ const renderItems = (items: MenuItem[], children = false) => {
className={clsx(
'w-3 h-1.5 rounded-full ml-auto shadow-lg',
isActive
? 'bg-danger-500 animate-spinner-ease-spin'
? 'bg-primary-500 animate-spinner-ease-spin'
: 'bg-red-300 dark:bg-white'
)}
/>

View File

@@ -1,76 +1,247 @@
import { Button } from '@heroui/button'
import { Card, CardBody, CardHeader } from '@heroui/card'
import { Chip } from '@heroui/chip'
import { Spinner } from '@heroui/spinner'
import { Tooltip } from '@heroui/tooltip'
import { useRequest } from 'ahooks'
import { FaCircleInfo } from 'react-icons/fa6'
import { FaQq } from 'react-icons/fa6'
import { useEffect } from 'react'
import { BsStars } from 'react-icons/bs'
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6'
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io'
import { RiMacFill } from 'react-icons/ri'
import WebUIManager from '@/controllers/webui_manager'
import useDialog from '@/hooks/use-dialog'
import packageJson from '../../package.json'
import { request } from '@/utils/request'
import { compareVersion } from '@/utils/version'
import WebUIManager from '@/controllers/webui_manager'
import { GithubRelease } from '@/types/github'
import TailwindMarkdown from './tailwind_markdown'
export interface SystemInfoItemProps {
title: string
icon?: React.ReactNode
value?: React.ReactNode
endContent?: React.ReactNode
}
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
title,
value = '--',
icon
icon,
endContent
}) => {
return (
<div className="flex text-sm gap-1 p-2 items-center shadow-sm shadow-danger-50 dark:shadow-danger-100 rounded text-danger-400">
<div className="flex text-sm gap-1 p-2 items-center shadow-sm shadow-primary-50 dark:shadow-primary-100 rounded text-primary-400">
{icon}
<div className="w-24">{title}</div>
<div className="text-danger-200">{value}</div>
<div className="text-primary-200">{value}</div>
<div className="ml-auto">{endContent}</div>
</div>
)
}
export interface NewVersionTipProps {
currentVersion?: string
}
const NewVersionTip = (props: NewVersionTipProps) => {
const { currentVersion } = props
const dialog = useDialog()
const { data: releaseData, error } = useRequest(() =>
request.get<GithubRelease[]>(
'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
)
)
if (error) {
return (
<Tooltip content="检查新版本失败">
<Button
isIconOnly
radius="full"
color="primary"
variant="shadow"
className="!w-5 !h-5 !min-w-0 text-small shadow-md"
onPress={() => {
dialog.alert({
title: '检查新版本失败',
content: error.message
})
}}
>
<FaInfo />
</Button>
</Tooltip>
)
}
const latestVersion = releaseData?.data?.[0]?.tag_name
if (!latestVersion || !currentVersion) {
return null
}
if (compareVersion(latestVersion, currentVersion) <= 0) {
return null
}
const middleVersions: GithubRelease[] = []
for (let i = 0; i < releaseData.data.length; i++) {
const versionInfo = releaseData.data[i]
if (compareVersion(versionInfo.tag_name, currentVersion) > 0) {
middleVersions.push(versionInfo)
} else {
break
}
}
const AISummaryComponent = () => {
const {
data: aiSummaryData,
loading: aiSummaryLoading,
error: aiSummaryError,
run: runAiSummary
} = useRequest(
(version) =>
request.get<ServerResponse<string | null>>(
`https://release.nc.152710.xyz/?version=${version}`,
{
timeout: 30000
}
),
{
manual: true
}
)
useEffect(() => {
runAiSummary(currentVersion)
}, [currentVersion, runAiSummary])
if (aiSummaryLoading) {
return (
<div className="flex justify-center py-1">
<Spinner size="sm" />
</div>
)
}
if (aiSummaryError) {
return <div className="text-center text-primary-500">AI </div>
}
return <span className="text-default-700">{aiSummaryData?.data.data}</span>
}
return (
<Tooltip content="有新版本可用">
<Button
isIconOnly
radius="full"
color="primary"
variant="shadow"
className="!w-5 !h-5 !min-w-0 text-small shadow-md"
onPress={() => {
dialog.confirm({
title: '有新版本可用',
content: (
<div className="space-y-2">
<div className="text-sm space-x-2">
<span></span>
<Chip color="primary" variant="flat">
v{currentVersion}
</Chip>
</div>
<div className="text-sm space-x-2">
<span></span>
<Chip color="primary">{latestVersion}</Chip>
</div>
<div className="p-2 rounded-md bg-content2 text-sm">
<div className="text-primary-400 font-bold flex items-center gap-1 mb-1">
<BsStars />
<span>AI总结</span>
</div>
{<AISummaryComponent />}
</div>
<div className="text-sm space-y-2 !mt-4">
{middleVersions.map((versionInfo) => (
<div
key={versionInfo.tag_name}
className="p-4 bg-content1 rounded-md shadow-small"
>
<TailwindMarkdown content={versionInfo.body} />
</div>
))}
</div>
</div>
),
scrollBehavior: 'inside',
size: '3xl',
confirmText: '前往下载',
onConfirm() {
window.open(
'https://github.com/NapNeko/NapCatQQ/releases',
'_blank',
'noopener'
)
}
})
}}
>
<FaInfo />
</Button>
</Tooltip>
)
}
const NapCatVersion = () => {
const {
data: packageData,
loading: packageLoading,
error: packageError
} = useRequest(WebUIManager.getPackageInfo)
const currentVersion = packageData?.version
return (
<SystemInfoItem
title="NapCat 版本"
icon={<IoLogoOctocat className="text-xl" />}
value={
packageError ? (
`错误:${packageError.message}`
) : packageLoading ? (
<Spinner size="sm" />
) : (
currentVersion
)
}
endContent={<NewVersionTip currentVersion={currentVersion} />}
/>
)
}
export interface SystemInfoProps {
archInfo?: string
}
const SystemInfo: React.FC<SystemInfoProps> = (props) => {
const { archInfo } = props
const {
data: packageData,
loading: packageLoading,
error: packageError
} = useRequest(WebUIManager.getPackageInfo)
const {
data: qqVersionData,
loading: qqVersionLoading,
error: qqVersionError
} = useRequest(WebUIManager.getQQVersion)
return (
<Card className="bg-opacity-60 shadow-sm shadow-danger-50 dark:shadow-danger-100 overflow-visible flex-1">
<CardHeader className="pb-0 items-center gap-1 text-danger-500 font-extrabold">
<Card className="bg-opacity-60 shadow-sm shadow-primary-50 dark:shadow-primary-100 overflow-visible flex-1">
<CardHeader className="pb-0 items-center gap-1 text-primary-500 font-extrabold">
<FaCircleInfo className="text-lg" />
<span></span>
</CardHeader>
<CardBody className="flex-1">
<div className="flex flex-col justify-between h-full">
<SystemInfoItem
title="NapCat 版本"
icon={<IoLogoOctocat className="text-xl" />}
value={
packageError ? (
`错误:${packageError.message}`
) : packageLoading ? (
<Spinner size="sm" />
) : (
packageData?.version
)
}
/>
<SystemInfoItem
title="WebUI 版本"
icon={<IoLogoChrome className="text-xl" />}
value={packageJson.version}
/>
<NapCatVersion />
<SystemInfoItem
title="QQ 版本"
icon={<FaQq className="text-lg" />}
@@ -84,6 +255,11 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
)
}
/>
<SystemInfoItem
title="WebUI 版本"
icon={<IoLogoChrome className="text-xl" />}
value="Next"
/>
<SystemInfoItem
title="系统版本"
icon={<RiMacFill className="text-xl" />}

View File

@@ -55,7 +55,7 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
}
return (
<Card className="bg-opacity-60 shadow-sm shadow-danger-50 dark:shadow-danger-100 col-span-1 lg:col-span-2 relative overflow-hidden">
<Card className="bg-opacity-60 shadow-sm shadow-primary-50 dark:shadow-primary-100 col-span-1 lg:col-span-2 relative overflow-hidden">
<div className="absolute h-full right-0 top-0">
<Image
src={bkg}
@@ -69,7 +69,7 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
</div>
<CardBody className="overflow-visible md:flex-row gap-4 items-center justify-stretch z-10">
<div className="flex-1 w-full md:max-w-96">
<h2 className="text-lg font-semibold flex items-center gap-1 text-danger-400">
<h2 className="text-lg font-semibold flex items-center gap-1 text-primary-400">
<GiCpu className="text-xl" />
<span>CPU</span>
</h2>
@@ -88,7 +88,7 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
unit="%"
/>
</div>
<h2 className="text-lg font-semibold flex items-center gap-1 text-danger-400 mt-2">
<h2 className="text-lg font-semibold flex items-center gap-1 text-primary-400 mt-2">
<BiSolidMemoryCard className="text-xl" />
<span></span>
</h2>

View File

@@ -0,0 +1,89 @@
import clsx from 'clsx'
import { type ReactNode, createContext, forwardRef, useContext } from 'react'
export interface TabsContextValue {
activeKey: string
onChange: (key: string) => void
}
const TabsContext = createContext<TabsContextValue>({
activeKey: '',
onChange: () => {}
})
export interface TabsProps {
activeKey: string
onChange: (key: string) => void
children: ReactNode
className?: string
}
export function Tabs({ activeKey, onChange, children, className }: TabsProps) {
return (
<TabsContext.Provider value={{ activeKey, onChange }}>
<div className={clsx('flex flex-col gap-2', className)}>{children}</div>
</TabsContext.Provider>
)
}
export interface TabListProps {
children: ReactNode
className?: string
}
export function TabList({ children, className }: TabListProps) {
return (
<div className={clsx('flex items-center gap-1', className)}>{children}</div>
)
}
export interface TabProps extends React.ButtonHTMLAttributes<HTMLDivElement> {
value: string
className?: string
children: ReactNode
isSelected?: boolean
}
export const Tab = forwardRef<HTMLDivElement, TabProps>(
({ className, isSelected, value, ...props }, ref) => {
const { onChange } = useContext(TabsContext)
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
onChange(value)
props.onClick?.(e)
}
return (
<div
ref={ref}
role="tab"
aria-selected={isSelected}
onClick={handleClick}
className={clsx(
'px-2 py-1 flex items-center gap-1 text-sm font-medium border-b-2 transition-colors',
isSelected
? 'border-primary text-primary'
: 'border-transparent hover:border-default',
className
)}
{...props}
/>
)
}
)
Tab.displayName = 'Tab'
export interface TabPanelProps {
value: string
children: ReactNode
className?: string
}
export function TabPanel({ value, children, className }: TabPanelProps) {
const { activeKey } = useContext(TabsContext)
if (value !== activeKey) return null
return <div className={clsx('flex-1', className)}>{children}</div>
}

View File

@@ -0,0 +1,38 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Tab } from '@/components/tabs'
import type { TabProps } from '@/components/tabs'
interface SortableTabProps extends TabProps {
id: string
}
export function SortableTab({ id, ...props }: SortableTabProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 1 : 0,
position: 'relative' as const,
touchAction: 'none'
}
return (
<Tab
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
{...props}
/>
)
}

View File

@@ -0,0 +1,49 @@
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
return (
<Markdown
className="prose prose-sm sm:prose lg:prose-lg xl:prose-xl"
remarkPlugins={[remarkGfm]}
components={{
h1: ({ node, ...props }) => (
<h1 className="text-2xl font-bold" {...props} />
),
h2: ({ node, ...props }) => (
<h2 className="text-xl font-bold" {...props} />
),
h3: ({ node, ...props }) => (
<h3 className="text-lg font-bold" {...props} />
),
p: ({ node, ...props }) => <p className="m-0" {...props} />,
a: ({ node, ...props }) => (
<a
className="text-primary-500 inline-block hover:underline"
target="_blank"
{...props}
/>
),
ul: ({ node, ...props }) => (
<ul className="list-disc list-inside" {...props} />
),
ol: ({ node, ...props }) => (
<ol className="list-decimal list-inside" {...props} />
),
blockquote: ({ node, ...props }) => (
<blockquote
className="border-l-4 border-default-300 pl-4 italic"
{...props}
/>
),
code: ({ node, ...props }) => (
<code className="bg-default-100 p-1 rounded text-xs" {...props} />
)
}}
>
{content}
</Markdown>
)
}
export default TailwindMarkdown

View File

@@ -0,0 +1,56 @@
import { useEffect, useRef } from 'react'
import TerminalManager from '@/controllers/terminal_manager'
import XTerm, { XTermRef } from '../xterm'
interface TerminalInstanceProps {
id: string
}
export function TerminalInstance({ id }: TerminalInstanceProps) {
const termRef = useRef<XTermRef>(null)
const connected = useRef(false)
const handleData = (data: string) => {
try {
const parsed = JSON.parse(data)
if (parsed.data) {
termRef.current?.write(parsed.data)
}
} catch (e) {
termRef.current?.write(data)
}
}
useEffect(() => {
return () => {
if (connected.current) {
TerminalManager.disconnectTerminal(id, handleData)
}
}
}, [id])
const handleInput = (data: string) => {
TerminalManager.sendInput(id, data)
}
const handleResize = (cols: number, rows: number) => {
if (!connected.current) {
connected.current = true
console.log('instance', rows, cols)
TerminalManager.connectTerminal(id, handleData, { rows, cols })
} else {
TerminalManager.sendResize(id, cols, rows)
}
}
return (
<XTerm
ref={termRef}
onInput={handleInput}
onResize={handleResize} // 使用 fitAddon 改变后触发的 resize 回调
className="w-full h-full"
/>
)
}

View File

@@ -0,0 +1,12 @@
export default function UnderConstruction() {
return (
<div className="flex flex-col items-center justify-center h-full pt-4">
<div className="flex flex-col items-center justify-center space-y-4">
<div className="text-6xl font-bold text-gray-500">🚧</div>
<div className="text-2xl font-bold text-gray-500">
Under Construction
</div>
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { CanvasAddon } from '@xterm/addon-canvas'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
// import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
import clsx from 'clsx'
@@ -8,8 +9,6 @@ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { useTheme } from '@/hooks/use-theme'
import { gradientText } from '@/utils/terminal'
export type XTermRef = {
write: (
...args: Parameters<Terminal['write']>
@@ -20,118 +19,174 @@ export type XTermRef = {
) => ReturnType<Terminal['writeln']>
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
clear: () => void
terminalRef: React.RefObject<Terminal | null>
}
const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
(props, ref) => {
const domRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
const { className, ...rest } = props
const { theme } = useTheme()
useEffect(() => {
if (!domRef.current) {
return
}
const terminal = new Terminal({
allowTransparency: true,
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace'
export interface XTermProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput' | 'onResize'> {
onInput?: (data: string) => void
onKey?: (key: string, event: KeyboardEvent) => void
onResize?: (cols: number, rows: number) => void // 新增属性
}
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
const domRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
const { className, onInput, onKey, onResize, ...rest } = props
const { theme } = useTheme()
useEffect(() => {
const terminal = new Terminal({
allowTransparency: true,
fontFamily:
'"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace',
cursorInactiveStyle: 'outline',
drawBoldTextInBrightColors: false,
fontSize: 14,
lineHeight: 1.2
})
terminalRef.current = terminal
const fitAddon = new FitAddon()
terminal.loadAddon(
new WebLinksAddon((event, uri) => {
if (event.ctrlKey || event.metaKey) {
window.open(uri, '_blank')
}
})
terminalRef.current = terminal
const fitAddon = new FitAddon()
terminal.loadAddon(new WebLinksAddon())
terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebglAddon())
terminal.open(domRef.current)
)
terminal.loadAddon(fitAddon)
terminal.open(domRef.current!)
terminal.loadAddon(new CanvasAddon())
terminal.onData((data) => {
if (onInput) {
onInput(data)
}
})
terminal.onKey((event) => {
if (onKey) {
onKey(event.key, event.domEvent)
}
})
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit()
// 获取当前终端尺寸
const cols = terminal.cols
const rows = terminal.rows
if (onResize) {
onResize(cols, rows)
}
})
// 字体加载完成后重新调整终端大小
document.fonts.ready.then(() => {
fitAddon.fit()
terminal.writeln(
gradientText(
'Welcome to NapCat WebUI',
[255, 0, 0],
[0, 255, 0],
true,
true,
true
)
)
resizeObserver.observe(domRef.current!)
})
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit()
})
resizeObserver.observe(domRef.current)
return () => {
resizeObserver.disconnect()
setTimeout(() => {
terminal.dispose()
}, 0)
}
}, [])
const handleFontLoad = () => {
terminal.refresh(0, terminal.rows - 1)
}
document.fonts.addEventListener('loadingdone', handleFontLoad)
return () => {
resizeObserver.disconnect()
document.fonts.removeEventListener('loadingdone', handleFontLoad)
setTimeout(() => {
terminal.dispose()
}, 0)
}
}, [])
useEffect(() => {
if (terminalRef.current) {
useEffect(() => {
if (terminalRef.current) {
if (theme === 'dark') {
terminalRef.current.options.theme = {
background:
theme === 'dark' ? 'rgba(0, 0, 0, 0)' : 'rgba(255, 255, 255, 0)',
foreground: theme === 'dark' ? '#fff' : '#000',
selectionBackground: theme === 'dark' ? '#666' : '#ddd',
cursor: theme === 'dark' ? '#fff' : '#000',
cursorAccent: theme === 'dark' ? '#000' : '#fff',
black: theme === 'dark' ? '#fff' : '#000'
background: '#00000000',
black: '#ffffff',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightCyan: '#29b8db',
brightWhite: '#e5e5e5',
foreground: '#cccccc',
selectionBackground: '#3a3d41',
cursor: '#ffffff'
}
} else {
terminalRef.current.options.theme = {
background: '#ffffff00',
black: '#000000',
red: '#aa3731',
green: '#448c27',
yellow: '#cb9000',
blue: '#325cc0',
cyan: '#0083b2',
white: '#7f7f7f',
brightBlack: '#777777',
brightRed: '#f05050',
brightGreen: '#60cb00',
brightYellow: '#ffbc5d',
brightBlue: '#007acc',
brightCyan: '#00aacb',
brightWhite: '#b0b0b0',
foreground: '#000000',
selectionBackground: '#bfdbfe',
cursor: '#007acc'
}
}
}, [theme])
}
}, [theme])
useImperativeHandle(
ref,
() => ({
write: (...args) => {
return terminalRef.current?.write(...args)
},
writeAsync: async (data) => {
return new Promise((resolve) => {
terminalRef.current?.write(data, resolve)
})
},
writeln: (...args) => {
return terminalRef.current?.writeln(...args)
},
writelnAsync: async (data) => {
return new Promise((resolve) => {
terminalRef.current?.writeln(data, resolve)
})
},
clear: () => {
terminalRef.current?.clear()
}
}),
[]
)
useImperativeHandle(
ref,
() => ({
write: (...args) => {
return terminalRef.current?.write(...args)
},
writeAsync: async (data) => {
return new Promise((resolve) => {
terminalRef.current?.write(data, resolve)
})
},
writeln: (...args) => {
return terminalRef.current?.writeln(...args)
},
writelnAsync: async (data) => {
return new Promise((resolve) => {
terminalRef.current?.writeln(data, resolve)
})
},
clear: () => {
terminalRef.current?.clear()
},
terminalRef: terminalRef
}),
[]
)
return (
return (
<div
className={clsx(
'p-2 rounded-md shadow-sm border border-default-200 w-full h-full overflow-hidden bg-opacity-50 backdrop-blur-sm',
theme === 'dark' ? 'bg-black' : 'bg-white',
className
)}
{...rest}
>
<div
className={clsx(
'p-2 rounded-md shadow-sm border border-default-200 w-full h-full overflow-hidden bg-opacity-50 backdrop-blur-sm',
theme === 'dark' ? 'bg-black' : 'bg-white',
className
)}
{...rest}
>
<div
style={{
width: '100%',
height: '100%'
}}
ref={domRef}
></div>
</div>
)
}
)
style={{
width: '100%',
height: '100%'
}}
ref={domRef}
></div>
</div>
)
})
export default XTerm

View File

@@ -1,6 +1,8 @@
import {
BugIcon2,
FileIcon,
InfoIcon,
LogIcon,
RouteIcon,
SettingsIcon,
SignalTowerIcon,
@@ -49,10 +51,10 @@ export const siteConfig = {
href: '/config'
},
{
label: '系统日志',
label: '猫猫日志',
icon: (
<div className="w-5 h-5">
<TerminalIcon />
<LogIcon />
</div>
),
href: '/logs'
@@ -75,6 +77,24 @@ export const siteConfig = {
}
]
},
{
label: '文件管理',
icon: (
<div className="w-5 h-5">
<FileIcon />
</div>
),
href: '/file_manager'
},
{
label: '系统终端',
icon: (
<div className="w-5 h-5">
<TerminalIcon />
</div>
),
href: '/terminal'
},
{
label: '关于我们',
icon: (

View File

@@ -7,7 +7,9 @@ enum key {
autoPlay = 'auto-play',
customIcons = 'custom-icons',
isCollapsedMusicPlayer = 'is-collapsed-music-player',
sideBarOpen = 'side-bar-open'
sideBarOpen = 'side-bar-open',
httpDebugConfig = 'http-debug-config',
wsDebugConfig = 'ws-debug-config'
}
export default key

View File

@@ -1,22 +1,15 @@
// Dialog Context
import React from 'react'
import type { ModalProps } from '@/components/modal'
import Modal from '@/components/modal'
import type { ModalProps } from '@/components/modal'
export interface AlertProps {
title?: React.ReactNode
content: React.ReactNode
size?: ModalProps['size']
dismissible?: boolean
export interface AlertProps
extends Omit<ModalProps, 'onCancel' | 'showCancel' | 'cancelText'> {
onConfirm?: () => void
}
export interface ConfirmProps {
title?: React.ReactNode
content: React.ReactNode
size?: ModalProps['size']
dismissible?: boolean
export interface ConfirmProps extends ModalProps {
onConfirm?: () => void
onCancel?: () => void
}
@@ -42,7 +35,7 @@ export const DialogContext = React.createContext<DialogContextProps>({
const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
const [dialogs, setDialogs] = React.useState<ModalItem[]>([])
const alert = (config: AlertProps) => {
const { title, content, dismissible, onConfirm, size = 'md' } = config
const { onConfirm, size = 'md', ...rest } = config
setDialogs((before) => {
const id = before[before.length - 1]?.id + 1 || 0
@@ -51,32 +44,23 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
...before,
{
id,
content,
size,
title,
backdrop: 'blur',
showCancel: false,
dismissible: dismissible,
onConfirm: () => {
onConfirm?.()
setTimeout(() => {
setDialogs((before) => before.filter((item) => item.id !== id))
}, 300)
}
},
...rest
}
]
})
}
const confirm = (config: ConfirmProps) => {
const {
title,
content,
dismissible,
onConfirm,
onCancel,
size = 'md'
} = config
const { onConfirm, onCancel, size = 'md', ...rest } = config
setDialogs((before) => {
const id = before[before.length - 1]?.id + 1 || 0
@@ -85,12 +69,9 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
...before,
{
id,
content,
size,
title,
backdrop: 'blur',
showCancel: true,
dismissible: dismissible,
onConfirm: () => {
onConfirm?.()
setTimeout(() => {
@@ -102,7 +83,8 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
setTimeout(() => {
setDialogs((before) => before.filter((item) => item.id !== id))
}, 300)
}
},
...rest
}
]
})

View File

@@ -35,6 +35,7 @@ const AudioProvider: React.FC<MusicProviderProps> = ({ children }) => {
const [musicId, setMusicId] = useState<number>(0)
const [playMode, setPlayMode] = useState<PlayMode>(PlayMode.Loop)
const music = musicList.find((music) => music.id === musicId)
const [token] = useLocalStorage(key.token, '')
const onNext = () => {
const nextID = getNextMusic(musicList, musicId, playMode)
setMusicId(nextID)
@@ -60,8 +61,8 @@ const AudioProvider: React.FC<MusicProviderProps> = ({ children }) => {
setMusicId(res[0].id)
}
useEffect(() => {
fetchMusicList(listId)
}, [listId])
if (listId && token) fetchMusicList(listId)
}, [listId, token])
return (
<AudioContext.Provider
value={{

View File

@@ -0,0 +1,199 @@
import toast from 'react-hot-toast'
import { serverRequest } from '@/utils/request'
export interface FileInfo {
name: string
isDirectory: boolean
size: number
mtime: Date
}
export default class FileManager {
public static async listFiles(path: string = '/') {
const { data } = await serverRequest.get<ServerResponse<FileInfo[]>>(
`/File/list?path=${encodeURIComponent(path)}`
)
return data.data
}
// 新增:按目录获取
public static async listDirectories(path: string = '/') {
const { data } = await serverRequest.get<ServerResponse<FileInfo[]>>(
`/File/list?path=${encodeURIComponent(path)}&onlyDirectory=true`
)
return data.data
}
public static async createDirectory(path: string): Promise<boolean> {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/mkdir',
{ path }
)
return data.data
}
public static async delete(path: string) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/delete',
{ path }
)
return data.data
}
public static async readFile(path: string) {
const { data } = await serverRequest.get<ServerResponse<string>>(
`/File/read?path=${encodeURIComponent(path)}`
)
return data.data
}
public static async writeFile(path: string, content: string) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/write',
{ path, content }
)
return data.data
}
public static async createFile(path: string): Promise<boolean> {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/create',
{ path }
)
return data.data
}
public static async batchDelete(paths: string[]) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/batchDelete',
{ paths }
)
return data.data
}
public static async rename(oldPath: string, newPath: string) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/rename',
{ oldPath, newPath }
)
return data.data
}
public static async move(sourcePath: string, targetPath: string) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/move',
{ sourcePath, targetPath }
)
return data.data
}
public static async batchMove(
items: { sourcePath: string; targetPath: string }[]
) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/batchMove',
{ items }
)
return data.data
}
public static download(path: string) {
const downloadUrl = `/File/download?path=${encodeURIComponent(path)}`
toast
.promise(
serverRequest
.post(downloadUrl, void 0, {
responseType: 'blob'
})
.catch((e) => {
console.error(e)
throw new Error('下载失败')
}),
{
loading: '正在下载文件...',
success: '下载成功',
error: '下载失败'
}
)
.then((response) => {
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
let fileName = path.split('/').pop() || ''
if (path.split('.').length === 1) {
fileName += '.zip'
}
link.setAttribute('download', fileName)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
})
.catch((e) => {
console.error(e)
})
}
public static async batchDownload(paths: string[]) {
const downloadUrl = `/File/batchDownload`
toast
.promise(
serverRequest
.post(
downloadUrl,
{ paths },
{
responseType: 'blob'
}
)
.catch((e) => {
console.error(e)
throw new Error('下载失败')
}),
{
loading: '正在下载文件...',
success: '下载成功',
error: '下载失败'
}
)
.then((response) => {
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
const fileName = 'files.zip'
link.setAttribute('download', fileName)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
})
.catch((e) => {
console.error(e)
})
}
public static async downloadToURL(path: string) {
const downloadUrl = `/File/download?path=${encodeURIComponent(path)}`
const response = await serverRequest.post(downloadUrl, void 0, {
responseType: 'blob'
})
return window.URL.createObjectURL(new Blob([response.data]))
}
public static async upload(path: string, files: File[]) {
const formData = new FormData()
files.forEach((file) => {
formData.append('files', file)
})
const { data } = await serverRequest.post<ServerResponse<boolean>>(
`/File/upload?path=${encodeURIComponent(path)}`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
)
return data.data
}
}

View File

@@ -0,0 +1,133 @@
import { serverRequest } from '@/utils/request'
type TerminalCallback = (data: string) => void
interface TerminalConnection {
ws: WebSocket
callbacks: Set<TerminalCallback>
isConnected: boolean
buffer: string[] // 添加缓存数组
}
export interface TerminalSession {
id: string
}
export interface TerminalInfo {
id: string
}
class TerminalManager {
private connections: Map<string, TerminalConnection> = new Map()
private readonly MAX_BUFFER_SIZE = 1000 // 限制缓存大小
async createTerminal(cols: number, rows: number): Promise<TerminalSession> {
const { data } = await serverRequest.post<ServerResponse<TerminalSession>>(
'/Log/terminal/create',
{ cols, rows }
)
return data.data
}
async closeTerminal(id: string): Promise<void> {
await serverRequest.post(`/Log/terminal/${id}/close`)
}
async getTerminalList(): Promise<TerminalInfo[]> {
const { data } =
await serverRequest.get<ServerResponse<TerminalInfo[]>>(
'/Log/terminal/list'
)
return data.data
}
connectTerminal(
id: string,
callback: TerminalCallback,
config?: {
cols?: number
rows?: number
}
): WebSocket {
let conn = this.connections.get(id)
const { cols = 80, rows = 24 } = config || {}
if (!conn) {
const url = new URL(window.location.href)
url.protocol = url.protocol.replace('http', 'ws')
url.pathname = `/api/ws/terminal`
url.searchParams.set('id', id)
const token = JSON.parse(localStorage.getItem('token') || '')
if (!token) {
throw new Error('No token found')
}
url.searchParams.set('token', token)
const ws = new WebSocket(url.toString())
conn = {
ws,
callbacks: new Set([callback]),
isConnected: false,
buffer: [] // 初始化缓存
}
ws.onmessage = (event) => {
const data = event.data
// 保存到缓存
conn?.buffer.push(data)
if ((conn?.buffer.length ?? 0) > this.MAX_BUFFER_SIZE) {
conn?.buffer.shift()
}
conn?.callbacks.forEach((cb) => cb(data))
}
ws.onopen = () => {
if (conn) conn.isConnected = true
this.sendResize(id, cols, rows)
}
ws.onclose = () => {
if (conn) conn.isConnected = false
}
this.connections.set(id, conn)
} else {
conn.callbacks.add(callback)
// 恢复历史内容
conn.buffer.forEach((data) => callback(data))
}
return conn.ws
}
disconnectTerminal(id: string, callback: TerminalCallback) {
const conn = this.connections.get(id)
if (!conn) return
conn.callbacks.delete(callback)
}
removeTerminal(id: string) {
const conn = this.connections.get(id)
if (conn?.ws.readyState === WebSocket.OPEN) {
conn.ws.close()
}
this.connections.delete(id)
}
sendInput(id: string, data: string) {
const conn = this.connections.get(id)
if (conn?.ws.readyState === WebSocket.OPEN) {
conn.ws.send(JSON.stringify({ type: 'input', data }))
}
}
sendResize(id: string, cols: number, rows: number) {
const conn = this.connections.get(id)
if (conn?.ws.readyState === WebSocket.OPEN) {
conn.ws.send(JSON.stringify({ type: 'resize', cols, rows }))
}
}
}
const terminalManager = new TerminalManager()
export default terminalManager

View File

@@ -9,6 +9,14 @@ export interface Log {
message: string
}
export interface TerminalSession {
id: string
}
export interface TerminalInfo {
id: string
}
export default class WebUIManager {
public static async checkWebUiLogined() {
const { data } =
@@ -24,6 +32,22 @@ export default class WebUIManager {
return data.data.Credential
}
public static async changePassword(oldToken: string, newToken: string) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/auth/update_token',
{ oldToken, newToken }
)
return data.data
}
public static async proxy<T>(url = '') {
const data = await serverRequest.get<ServerResponse<string>>(
'/base/proxy?url=' + encodeURIComponent(url)
)
data.data.data = JSON.parse(data.data.data)
return data.data as ServerResponse<T>
}
public static async getPackageInfo() {
const { data } =
await serverRequest.get<ServerResponse<PackageInfo>>('/base/PackageInfo')

View File

@@ -14,10 +14,12 @@ const useConfig = () => {
key: T,
value: OneBotConfig['network'][T][0]
) => {
if (
value.name &&
config.network[key].some((item) => item.name === value.name)
) {
const allNetworkNames = Object.keys(config.network).reduce((acc, key) => {
const _key = key as keyof OneBotConfig['network']
return acc.concat(config.network[_key].map((item) => item.name))
}, [] as string[])
if (value.name && allNetworkNames.includes(value.name)) {
throw new Error('已经存在相同的配置项名')
}

View File

@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from 'react'
// 全局图片缓存
const imageCache = new Map<string, HTMLImageElement>()
export function usePreloadImages(urls: string[]) {
const [loadedUrls, setLoadedUrls] = useState<Record<string, boolean>>({})
const [isLoading, setIsLoading] = useState(true)
const isMounted = useRef(true)
useEffect(() => {
isMounted.current = true
// 检查是否所有图片都已缓存
const allCached = urls.every((url) => imageCache.has(url))
if (allCached) {
setLoadedUrls(urls.reduce((acc, url) => ({ ...acc, [url]: true }), {}))
setIsLoading(false)
return
}
setIsLoading(true)
const loadedImages: Record<string, boolean> = {}
let pendingCount = urls.length
urls.forEach((url) => {
// 如果已经缓存,直接标记为已加载
if (imageCache.has(url)) {
loadedImages[url] = true
pendingCount--
if (pendingCount === 0) {
setLoadedUrls(loadedImages)
setIsLoading(false)
}
return
}
const img = new Image()
img.onload = () => {
if (!isMounted.current) return
loadedImages[url] = true
imageCache.set(url, img)
pendingCount--
if (pendingCount === 0) {
setLoadedUrls(loadedImages)
setIsLoading(false)
}
}
img.onerror = () => {
if (!isMounted.current) return
loadedImages[url] = false
pendingCount--
if (pendingCount === 0) {
setLoadedUrls(loadedImages)
setIsLoading(false)
}
}
img.src = url
})
return () => {
isMounted.current = false
}
}, [urls])
return { loadedUrls, isLoading }
}

View File

@@ -2,7 +2,7 @@ import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs'
import { Button } from '@heroui/button'
import { useLocalStorage } from '@uidotdev/usehooks'
import clsx from 'clsx'
import { motion } from 'motion/react'
import { AnimatePresence, motion } from 'motion/react'
import { useEffect, useMemo, useRef } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { MdMenu, MdMenuOpen } from 'react-icons/md'
@@ -79,7 +79,7 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
}, [location.pathname])
return (
<div
className="h-screen relative flex bg-danger-50 dark:bg-black"
className="h-screen relative flex bg-primary-50 dark:bg-black items-stretch"
style={{
backgroundImage: `url(${b64img})`,
backgroundSize: 'cover'
@@ -89,13 +89,22 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
<div
ref={contentRef}
className={clsx(
'overflow-y-auto relative flex-1 rounded-md m-1 bg-content1 pb-10 md:pb-0',
'overflow-y-auto flex-1 rounded-md m-1 bg-content1 pb-10 md:pb-0',
openSideBar ? 'ml-0' : 'ml-1',
!b64img && 'shadow-inner',
b64img && '!bg-opacity-50 backdrop-blur-none dark:bg-background'
b64img && '!bg-opacity-50 backdrop-blur-none dark:bg-background',
'overflow-x-hidden'
)}
>
<div className="h-10 flex items-center hm-medium text-xl sticky top-2 left-0 backdrop-blur-lg z-20 shadow-sm bg-background dark:bg-background shadow-danger-50 dark:shadow-danger-100 m-2 rounded-full !bg-opacity-50">
<div
className={clsx(
'h-10 flex items-center font-bold text-xl backdrop-blur-lg rounded-full',
'dark:bg-background dark:shadow-primary-100',
'bg-background !bg-opacity-50',
'shadow-sm shadow-primary-50',
'z-30 m-2 mb-0 sticky top-2 left-0'
)}
>
<motion.div
className={clsx(
'mr-1 ease-in-out ml-0 md:relative',
@@ -117,7 +126,19 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
</motion.div>
<Breadcrumbs isDisabled size="lg">
{title?.map((item, index) => (
<BreadcrumbItem key={index}>{item}</BreadcrumbItem>
<BreadcrumbItem key={index}>
<AnimatePresence mode="wait">
<motion.div
key={item}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.3 }}
>
{item}
</motion.div>
</AnimatePresence>
</BreadcrumbItem>
))}
</Breadcrumbs>
</div>

View File

@@ -1,4 +1,5 @@
import ReactDOM from 'react-dom/client'
import 'react-photo-view/dist/react-photo-view.css'
import { BrowserRouter } from 'react-router-dom'
import App from '@/App.tsx'

View File

@@ -1,98 +1,197 @@
import { Chip } from '@heroui/chip'
import { Card, CardBody } from '@heroui/card'
import { Image } from '@heroui/image'
import { Link } from '@heroui/link'
import { Skeleton } from '@heroui/skeleton'
import { Spinner } from '@heroui/spinner'
import { Tooltip } from '@heroui/tooltip'
import { useRequest } from 'ahooks'
import clsx from 'clsx'
import { useMemo } from 'react'
import { BsTelegram, BsTencentQq } from 'react-icons/bs'
import { IoDocument } from 'react-icons/io5'
import { BietiaopIcon, GithubIcon, WebUIIcon } from '@/components/icons'
import HoverTiltedCard from '@/components/hover_titled_card'
import NapCatRepoInfo from '@/components/napcat_repo_info'
import { title } from '@/components/primitives'
import RotatingText from '@/components/rotating_text'
import { usePreloadImages } from '@/hooks/use-preload-images'
import { useTheme } from '@/hooks/use-theme'
import logo from '@/assets/images/logo.png'
import WebUIManager from '@/controllers/webui_manager'
import packageJson from '../../../package.json'
function VersionInfo() {
const { data, loading, error } = useRequest(WebUIManager.getPackageInfo)
return (
<div className="flex items-center gap-2 mb-5">
<Chip
startContent={
<Chip color="danger" size="sm" className="-ml-0.5 select-none">
WebUI
</Chip>
}
>
{packageJson.version}
</Chip>
<Chip
startContent={
<Chip color="warning" size="sm" className="-ml-0.5 select-none">
NapCat
</Chip>
}
>
<div className="flex items-center gap-2 text-2xl font-bold">
<div className="flex items-center gap-2">
<div className="text-primary-500 drop-shadow-md">NapCat</div>
{error ? (
error.message
) : loading ? (
<Spinner size="sm" />
) : (
data?.version
<RotatingText
texts={['WebUI', data?.version ?? '']}
mainClassName="overflow-hidden flex items-center bg-primary-500 px-2 rounded-lg text-default-50 shadow-md"
staggerFrom={'last'}
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '-120%' }}
staggerDuration={0.025}
splitLevelClassName="overflow-hidden"
transition={{ type: 'spring', damping: 30, stiffness: 400 }}
rotationInterval={2000}
/>
)}
</Chip>
<Tooltip content="查看WebUI源码" placement="bottom" showArrow>
<Link isExternal href="https://github.com/bietiaop/NextNapCatWebUI">
<GithubIcon className="text-default-900 hover:text-default-600 w-8 h-8 hover:drop-shadow-lg transition-all" />
</Link>
</Tooltip>
</div>
</div>
)
}
export default function AboutPage() {
const { isDark } = useTheme()
const imageUrls = useMemo(
() => [
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=dark',
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=dark'
],
[]
)
const { loadedUrls, isLoading } = usePreloadImages(imageUrls)
const getImageUrl = useMemo(
() => (baseUrl: string) => {
const theme = isDark ? 'dark' : 'light'
const fullUrl = baseUrl.replace(
/color_scheme=(?:light|dark)/,
`color_scheme=${theme}`
)
return isLoading ? null : loadedUrls[fullUrl] ? fullUrl : null
},
[isDark, isLoading, loadedUrls]
)
const renderImage = useMemo(
() => (baseUrl: string, alt: string) => {
const imageUrl = getImageUrl(baseUrl)
if (!imageUrl) {
return <Skeleton className="h-16 rounded-lg" />
}
return (
<Image
className="flex-1 pointer-events-none select-none rounded-none"
src={imageUrl}
alt={alt}
/>
)
},
[getImageUrl]
)
return (
<>
<title> NapCat WebUI</title>
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10">
<div className="max-w-full w-[1000px] px-5 flex flex-col items-center">
<section className="max-w-7xl py-8 md:py-10 px-5 mx-auto space-y-10">
<div className="w-full flex flex-col md:flex-row gap-4">
<div className="flex flex-col md:flex-row items-center">
<Image
alt="logo"
className="flex-shrink-0 w-52 md:w-48 mr-2"
src={logo}
/>
<div className="flex -mt-9 md:mt-0">
<WebUIIcon />
<HoverTiltedCard imageSrc={logo} overlayContent="" />
</div>
<div className="flex-1 flex flex-col gap-2 py-2">
<VersionInfo />
<div className="space-y-1">
<p className="font-bold text-primary-400">NapCat ?</p>
<p className="text-default-800">
TypeScript构建的Bot框架,,QQ
Node模块提供给客户端的接口,Bot的功能.
</p>
<p className="font-bold text-primary-400"></p>
<p className="text-default-800">
QQ
便使 OneBot HTTP /
WebSocket
QQ发送接口之类的接口
</p>
</div>
</div>
<div className="flex opacity-60 items-center gap-2 mb-2 font-ubuntu">
Created By
<div className="flex scale-80 -ml-5 -mr-5">
<BietiaopIcon />
</div>
</div>
<VersionInfo />
<div className="mb-6 flex flex-col items-center gap-4">
<p
className={clsx(
title({
color: 'cyan',
shadow: true
}),
'!text-3xl'
)}
>
NapCat Contributors
</p>
<Image
className="w-[600px] max-w-full pointer-events-none select-none"
src="https://contrib.rocks/image?repo=bietiaop/NapCatQQ"
alt="Contributors"
/>
</div>
<div className="flex flex-row gap-2 flex-wrap justify-around">
<Card
as={Link}
shadow="sm"
isPressable
isExternal
href="https://qm.qq.com/q/F9cgs1N3Mc"
>
<CardBody className="flex-row items-center gap-2">
<span className="p-2 rounded-small bg-primary-50 text-primary-500">
<BsTencentQq size={16} />
</span>
<span>1</span>
</CardBody>
</Card>
<Card
as={Link}
shadow="sm"
isPressable
isExternal
href="https://qm.qq.com/q/hSt0u9PVn"
>
<CardBody className="flex-row items-center gap-2">
<span className="p-2 rounded-small bg-primary-50 text-primary-500">
<BsTencentQq size={16} />
</span>
<span>2</span>
</CardBody>
</Card>
<Card
as={Link}
shadow="sm"
isPressable
isExternal
href="https://t.me/MelodicMoonlight"
>
<CardBody className="flex-row items-center gap-2">
<span className="p-2 rounded-small bg-primary-50 text-primary-500">
<BsTelegram size={16} />
</span>
<span>Telegram</span>
</CardBody>
</Card>
<Card
as={Link}
shadow="sm"
isPressable
isExternal
href="https://napcat.napneko.icu/"
>
<CardBody className="flex-row items-center gap-2">
<span className="p-2 rounded-small bg-primary-50 text-primary-500">
<IoDocument size={16} />
</span>
<span>使</span>
</CardBody>
</Card>
</div>
<div className="flex flex-col md:flex-row md:items-start gap-4">
<div className="w-full flex flex-col gap-4">
{renderImage(
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
'Contributors'
)}
{renderImage(
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
'Activity Trends'
)}
</div>
<NapCatRepoInfo />
</div>
</section>

View File

@@ -0,0 +1,81 @@
import { Input } from '@heroui/input'
import { useLocalStorage } from '@uidotdev/usehooks'
import { Controller, useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import { useNavigate } from 'react-router-dom'
import key from '@/const/key'
import SaveButtons from '@/components/button/save_buttons'
import WebUIManager from '@/controllers/webui_manager'
const ChangePasswordCard = () => {
const {
control,
handleSubmit: handleWebuiSubmit,
formState: { isSubmitting },
reset
} = useForm<{
oldToken: string
newToken: string
}>({
defaultValues: {
oldToken: '',
newToken: ''
}
})
const navigate = useNavigate()
const [_, setToken] = useLocalStorage(key.token, '')
const onSubmit = handleWebuiSubmit(async (data) => {
try {
await WebUIManager.changePassword(data.oldToken, data.newToken)
toast.success('修改成功')
setToken('')
localStorage.removeItem(key.token)
navigate('/web_login')
} catch (error) {
const msg = (error as Error).message
toast.error(`修改失败: ${msg}`)
}
})
return (
<>
<title> - NapCat WebUI</title>
<Controller
control={control}
name="oldToken"
render={({ field }) => (
<Input
{...field}
label="旧密码"
placeholder="请输入旧密码"
type="password"
/>
)}
/>
<Controller
control={control}
name="newToken"
render={({ field }) => (
<Input
{...field}
label="新密码"
placeholder="请输入新密码"
type="password"
/>
)}
/>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
/>
</>
)
}
export default ChangePasswordCard

View File

@@ -1,111 +1,27 @@
import { Card, CardBody } from '@heroui/card'
import { Tab, Tabs } from '@heroui/tabs'
import { useLocalStorage } from '@uidotdev/usehooks'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import { useMediaQuery } from 'react-responsive'
import key from '@/const/key'
import PageLoading from '@/components/page_loading'
import useConfig from '@/hooks/use-config'
import useMusic from '@/hooks/use-music'
import ChangePasswordCard from './change_password'
import OneBotConfigCard from './onebot'
import WebUIConfigCard from './webui'
export default function ConfigPage() {
const { config, saveConfigWithoutNetwork, refreshConfig } = useConfig()
const [loading, setLoading] = useState(false)
const {
control: onebotControl,
handleSubmit: handleOnebotSubmit,
formState: { isSubmitting: isOnebotSubmitting },
setValue: setOnebotValue
} = useForm<IConfig['onebot']>({
defaultValues: {
musicSignUrl: '',
enableLocalFile2Url: false,
parseMultMsg: false
}
})
export interface ConfigPageProps {
children?: React.ReactNode
}
const {
control: webuiControl,
handleSubmit: handleWebuiSubmit,
formState: { isSubmitting: isWebuiSubmitting },
setValue: setWebuiValue
} = useForm<IConfig['webui']>({
defaultValues: {
background: '',
musicListID: '',
customIcons: {}
}
})
const isMediumUp = useMediaQuery({ minWidth: 768 })
const [b64img, setB64img] = useLocalStorage(key.backgroundImage, '')
const [customIcons, setCustomIcons] = useLocalStorage<Record<string, string>>(
key.customIcons,
{}
const ConfingPageItem: React.FC<ConfigPageProps> = ({ children }) => {
return (
<Card className="bg-opacity-50 backdrop-blur-sm">
<CardBody className="items-center py-5">
<div className="w-96 max-w-full flex flex-col gap-2">{children}</div>
</CardBody>
</Card>
)
const { setListId, listId } = useMusic()
const resetOneBot = () => {
setOnebotValue('musicSignUrl', config.musicSignUrl)
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url)
setOnebotValue('parseMultMsg', config.parseMultMsg)
}
}
const resetWebUI = () => {
setWebuiValue('musicListID', listId)
setWebuiValue('customIcons', customIcons)
setWebuiValue('background', b64img)
}
const onOneBotSubmit = handleOnebotSubmit((data) => {
try {
saveConfigWithoutNetwork(data)
toast.success('保存成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`保存失败: ${msg}`)
}
})
const onWebuiSubmit = handleWebuiSubmit((data) => {
try {
setListId(data.musicListID)
setCustomIcons(data.customIcons)
setB64img(data.background)
toast.success('保存成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`保存失败: ${msg}`)
}
})
const onRefresh = async (shotTip = true) => {
try {
setLoading(true)
await refreshConfig()
if (shotTip) toast.success('刷新成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`刷新失败: ${msg}`)
} finally {
setLoading(false)
}
}
useEffect(() => {
resetOneBot()
resetWebUI()
}, [config])
useEffect(() => {
onRefresh(false)
}, [])
export default function ConfigPage() {
const isMediumUp = useMediaQuery({ minWidth: 768 })
return (
<section className="w-[1000px] max-w-full md:mx-auto gap-4 py-8 px-2 md:py-10">
@@ -122,23 +38,20 @@ export default function ConfigPage() {
}}
>
<Tab title="OneBot配置" key="onebot">
<PageLoading loading={loading} />
<OneBotConfigCard
isSubmitting={isOnebotSubmitting}
onRefresh={onRefresh}
onSubmit={onOneBotSubmit}
control={onebotControl}
reset={resetOneBot}
/>
<ConfingPageItem>
<OneBotConfigCard />
</ConfingPageItem>
</Tab>
<Tab title="WebUI配置" key="webui">
<WebUIConfigCard
isSubmitting={isWebuiSubmitting}
onRefresh={onRefresh}
onSubmit={onWebuiSubmit}
control={webuiControl}
reset={resetWebUI}
/>
<ConfingPageItem>
<WebUIConfigCard />
</ConfingPageItem>
</Tab>
<Tab title="修改密码" key="token">
<ConfingPageItem>
<ChangePasswordCard />
</ConfingPageItem>
</Tab>
</Tabs>
</section>

View File

@@ -1,68 +1,110 @@
import { Card, CardBody } from '@heroui/card'
import { Input } from '@heroui/input'
import { Controller } from 'react-hook-form'
import type { Control } from 'react-hook-form'
import { useEffect, useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import SaveButtons from '@/components/button/save_buttons'
import PageLoading from '@/components/page_loading'
import SwitchCard from '@/components/switch_card'
export interface OneBotConfigCardProps {
control: Control<IConfig['onebot']>
onSubmit: () => void
reset: () => void
isSubmitting: boolean
onRefresh: () => void
}
const OneBotConfigCard: React.FC<OneBotConfigCardProps> = (props) => {
const { control, onSubmit, reset, isSubmitting, onRefresh } = props
import useConfig from '@/hooks/use-config'
const OneBotConfigCard = () => {
const { config, saveConfigWithoutNetwork, refreshConfig } = useConfig()
const [loading, setLoading] = useState(false)
const {
control,
handleSubmit: handleOnebotSubmit,
formState: { isSubmitting },
setValue: setOnebotValue
} = useForm<IConfig['onebot']>({
defaultValues: {
musicSignUrl: '',
enableLocalFile2Url: false,
parseMultMsg: false
}
})
const reset = () => {
setOnebotValue('musicSignUrl', config.musicSignUrl)
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url)
setOnebotValue('parseMultMsg', config.parseMultMsg)
}
const onSubmit = handleOnebotSubmit((data) => {
try {
saveConfigWithoutNetwork(data)
toast.success('保存成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`保存失败: ${msg}`)
}
})
const onRefresh = async (shotTip = true) => {
try {
setLoading(true)
await refreshConfig()
if (shotTip) toast.success('刷新成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`刷新失败: ${msg}`)
} finally {
setLoading(false)
}
}
useEffect(() => {
reset()
}, [config])
useEffect(() => {
onRefresh(false)
}, [])
if (loading) return <PageLoading loading={true} />
return (
<>
<title>OneBot配置 - NapCat WebUI</title>
<Card className="bg-opacity-50 backdrop-blur-sm">
<CardBody className="items-center py-5">
<div className="w-96 max-w-full flex flex-col gap-2">
<Controller
control={control}
name="musicSignUrl"
render={({ field }) => (
<Input
{...field}
label="音乐签名地址"
placeholder="请输入音乐签名地址"
/>
)}
/>
<Controller
control={control}
name="enableLocalFile2Url"
render={({ field }) => (
<SwitchCard
{...field}
description="启用本地文件到URL"
label="启用本地文件到URL"
/>
)}
/>
<Controller
control={control}
name="parseMultMsg"
render={({ field }) => (
<SwitchCard
{...field}
description="启用上报解析合并消息"
label="启用上报解析合并消息"
/>
)}
/>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
refresh={onRefresh}
/>
</div>
</CardBody>
</Card>
<Controller
control={control}
name="musicSignUrl"
render={({ field }) => (
<Input
{...field}
label="音乐签名地址"
placeholder="请输入音乐签名地址"
/>
)}
/>
<Controller
control={control}
name="enableLocalFile2Url"
render={({ field }) => (
<SwitchCard
{...field}
description="启用本地文件到URL"
label="启用本地文件到URL"
/>
)}
/>
<Controller
control={control}
name="parseMultMsg"
render={({ field }) => (
<SwitchCard
{...field}
description="启用上报解析合并消息"
label="启用上报解析合并消息"
/>
)}
/>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
refresh={onRefresh}
/>
</>
)
}

View File

@@ -1,69 +1,99 @@
import { Card, CardBody } from '@heroui/card'
import { Input } from '@heroui/input'
import { Controller } from 'react-hook-form'
import type { Control } from 'react-hook-form'
import { useLocalStorage } from '@uidotdev/usehooks'
import { useEffect } from 'react'
import { Controller, useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import key from '@/const/key'
import SaveButtons from '@/components/button/save_buttons'
import ImageInput from '@/components/input/image_input'
import useMusic from '@/hooks/use-music'
import { siteConfig } from '@/config/site'
export interface WebUIConfigCardProps {
control: Control<IConfig['webui']>
onSubmit: () => void
reset: () => void
isSubmitting: boolean
onRefresh: () => void
}
const WebUIConfigCard: React.FC<WebUIConfigCardProps> = (props) => {
const { control, onSubmit, reset, isSubmitting, onRefresh } = props
const WebUIConfigCard = () => {
const {
control,
handleSubmit: handleWebuiSubmit,
formState: { isSubmitting },
setValue: setWebuiValue
} = useForm<IConfig['webui']>({
defaultValues: {
background: '',
musicListID: '',
customIcons: {}
}
})
const [b64img, setB64img] = useLocalStorage(key.backgroundImage, '')
const [customIcons, setCustomIcons] = useLocalStorage<Record<string, string>>(
key.customIcons,
{}
)
const { setListId, listId } = useMusic()
const reset = () => {
setWebuiValue('musicListID', listId)
setWebuiValue('customIcons', customIcons)
setWebuiValue('background', b64img)
}
const onSubmit = handleWebuiSubmit((data) => {
try {
setListId(data.musicListID)
setCustomIcons(data.customIcons)
setB64img(data.background)
toast.success('保存成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`保存失败: ${msg}`)
}
})
useEffect(() => {
reset()
}, [listId, customIcons, b64img])
return (
<>
<title>WebUI配置 - NapCat WebUI</title>
<Card className="bg-opacity-50 backdrop-blur-sm">
<CardBody className="items-center py-5">
<div className="w-96 max-w-full flex flex-col gap-2">
<Controller
control={control}
name="musicListID"
render={({ field }) => (
<Input
{...field}
label="网易云音乐歌单ID网页内音乐播放器"
placeholder="请输入歌单ID"
/>
)}
/>
<div className="flex flex-col gap-2">
<div className="flex-shrink-0 w-full"></div>
<Controller
control={control}
name="background"
render={({ field }) => <ImageInput {...field} />}
/>
</div>
<div className="flex flex-col gap-2">
<div></div>
{siteConfig.navItems.map((item) => (
<Controller
key={item.label}
control={control}
name={`customIcons.${item.label}`}
render={({ field }) => (
<ImageInput {...field} label={item.label} />
)}
/>
))}
</div>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
refresh={onRefresh}
/>
</div>
</CardBody>
</Card>
<Controller
control={control}
name="musicListID"
render={({ field }) => (
<Input
{...field}
label="网易云音乐歌单ID网页内音乐播放器"
placeholder="请输入歌单ID"
/>
)}
/>
<div className="flex flex-col gap-2">
<div className="flex-shrink-0 w-full"></div>
<Controller
control={control}
name="background"
render={({ field }) => <ImageInput {...field} />}
/>
</div>
<div className="flex flex-col gap-2">
<div></div>
{siteConfig.navItems.map((item) => (
<Controller
key={item.label}
control={control}
name={`customIcons.${item.label}`}
render={({ field }) => <ImageInput {...field} label={item.label} />}
/>
))}
</div>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
/>
</>
)
}

View File

@@ -27,41 +27,36 @@ export default function HttpDebug() {
return (
<>
<title>HTTP调试 - NapCat WebUI</title>
<div className="w-full h-[calc(100%-3.6rem)] flex items-stretch">
<OneBotApiNavList
data={oneBotHttpApi}
selectedApi={selectedApi}
onSelect={setSelectedApi}
openSideBar={openSideBar}
/>
<div
ref={contentRef}
className="flex-1 h-full overflow-x-hidden relative"
<OneBotApiNavList
data={oneBotHttpApi}
selectedApi={selectedApi}
onSelect={setSelectedApi}
openSideBar={openSideBar}
/>
<div ref={contentRef} className="flex-1 h-full overflow-x-hidden">
<motion.div
className="absolute top-16 z-30 md:!ml-4"
animate={{ marginLeft: openSideBar ? '16rem' : '1rem' }}
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
>
<motion.div
className="sticky top-0 z-20 md:!ml-4"
animate={{ marginLeft: openSideBar ? '16rem' : '1rem' }}
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
<Button
isIconOnly
color="primary"
radius="md"
variant="shadow"
size="sm"
onPress={() => setOpenSideBar(!openSideBar)}
>
<Button
isIconOnly
color="danger"
radius="md"
variant="shadow"
size="sm"
onPress={() => setOpenSideBar(!openSideBar)}
>
<TbSquareRoundedChevronLeftFilled
size={24}
className={clsx(
'transition-transform',
openSideBar ? '' : 'transform rotate-180'
)}
/>
</Button>
</motion.div>
<OneBotApiDebug path={selectedApi} data={data} />
</div>
<TbSquareRoundedChevronLeftFilled
size={24}
className={clsx(
'transition-transform',
openSideBar ? '' : 'transform rotate-180'
)}
/>
</Button>
</motion.div>
<OneBotApiDebug path={selectedApi} data={data} />
</div>
</>
)

Some files were not shown because too many files have changed in this diff Show More