Compare commits

..

54 Commits

Author SHA1 Message Date
手瓜一十雪
cc23599776 Enhance HTTP debug UI with command palette and UI improvements
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
Added a new CommandPalette component for quick API selection and execution (Ctrl/Cmd+K). Refactored the HTTP debug page to use the command palette, improved tab and panel UI, and enhanced the code editor's appearance and theme integration. Updated OneBotApiDebug to support imperative methods for request body and sending, improved response panel resizing, and made various UI/UX refinements across related components.
2026-01-04 20:38:08 +08:00
手瓜一十雪
c6ec2126e0 Refactor theme font handling and preview logic
Moved font configuration to be managed via theme.css, eliminating the need for separate font initialization and caching. Updated backend to generate @font-face rules and font variables in theme.css. Frontend now uses a dedicated style tag for real-time font preview in the theme config page, and removes legacy font cache logic for improved consistency.
2026-01-04 18:48:16 +08:00
手瓜一十雪
f1756c4d1c Optimize version fetching and update logic
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Introduces lazy loading for release and action artifact versions, adds support for nightly.link mirrors, and improves artifact retrieval reliability. Removes unused loginService references, refactors update logic to handle action artifacts, and streamlines frontend/backend API parameters for version selection.
2026-01-04 12:41:21 +08:00
手瓜一十雪
4940d72867 Update release workflow
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Updates the release workflow to download and include NapCat.Shell.Windows.OneKey.zip in the release artifacts.
2026-01-03 18:37:17 +08:00
手瓜一十雪
91e0839ed5 Add upload_file option for file upload actions
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Introduces an 'upload_file' boolean option to group and private file upload actions, allowing control over whether files are uploaded to group storage or sent directly. Updates the NTQQFileApi and OneBotFileApi to support this option and adjusts file handling logic accordingly.
2026-01-03 16:25:38 +08:00
手瓜一十雪
334c4233e6 Update message retrieval and parsing logic
Changed the protocol fallback logic to pass an additional argument to parseMessageV2 and updated message retrieval to use getMsgHistory instead of getMsgsByMsgId. This improves compatibility and ensures correct message fetching.
2026-01-03 16:05:03 +08:00
手瓜一十雪
71bb4f68f3 Improve senderUin handling in sendMsg method
If senderUin is missing or '0', attempt to retrieve it using senderUid before returning. This ensures messages are not dropped when senderUid is available but senderUin is not.
2026-01-03 16:01:24 +08:00
手瓜一十雪
47983e2915 Add PTT element type to message element filters
Updated the filtering logic in SendMsgBase to include ElementType.PTT alongside FILE, VIDEO, and ARK types. This ensures PTT elements are handled consistently with other single-element message types.
2026-01-03 15:38:13 +08:00
手瓜一十雪
ae42eed6e2 Fix font reset on unmount with unsaved changes
Added a ref to track unsaved changes and updated the cleanup logic to only restore the saved font settings if there are unsaved changes. This prevents the font from being unintentionally reset when the page is refreshed or the component is unmounted without changes.
2026-01-03 15:36:42 +08:00
手瓜一十雪
cb061890d3 Enhance artifact handling and display for action builds
Extended artifact metadata to include workflow run ID and head SHA. Updated backend to filter artifacts by environment and provide additional metadata. Improved frontend to display new artifact details and adjusted UI for better clarity.
2026-01-03 15:28:18 +08:00
手瓜一十雪
31feec26b5 Update release.yml 2026-01-03 15:11:58 +08:00
手瓜一十雪
e93cd3529f Update pr-build.yml 2026-01-03 15:10:03 +08:00
手瓜一十雪
1ad700b935 Update release workflow and documentation prompts
Refactored the release workflow to add semantic version validation, improved commit and file diff collection, and enhanced release note generation with more context and formatting. Updated release note and default documentation prompts for clarity, conciseness, and better user guidance. Fixed owner typo in workflow and improved error handling for missing tags.
2026-01-03 15:01:10 +08:00
手瓜一十雪
68c8b984ad Refactor update logging to use logger interface
Replaces all console logging in the update process with the ILogWrapper logger interface for consistent logging. Updates applyPendingUpdates to require a logger parameter and propagates this change to all relevant initialization code. Also removes duplicate and unnecessary lines in workflow YAML files.
2026-01-03 14:51:56 +08:00
手瓜一十雪
8eb1aa2fb4 Refactor GitHub tag fetching and mirror management
Replaces legacy tag fetching logic in napcat-common with a new mirror.ts module that centralizes GitHub mirror configuration, selection, and tag retrieval. Updates helper.ts to use the new mirror system and semver comparison, and exports compareSemVer for broader use. Updates workflows and scripts to generate and propagate build version information, and improves build status comment formatting for PRs. Also updates release workflow to use a new OpenAI key and model.
2026-01-03 14:42:24 +08:00
Makoto
2d3f4e696b
feat: Add OB11GroupGrayTipEvent for detecting forged gray tip attacks (#1492)
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
* feat: Add OB11GroupGrayTipEvent for detecting forged gray tip attacks

- Add new OB11GroupGrayTipEvent class to report unknown gray tip messages
- Modify parseOtherJsonEvent to detect forged gray tips by checking senderUin
- Real system gray tips have senderUin='0', forged ones expose attacker's QQ
- Include message_id in event for downstream recall capability
- Add try/catch for JSON.parse to handle malformed content
- Use Number() for consistent type conversion

* fix: simplify logWarn to match upstream style

* fix: remove extra closing brace that broke class structure

* fix: add validation for malformed title gray tip events
2026-01-02 20:55:24 +08:00
时瑾
b241881c74
fix: 修复用户ID类型转换错误并移除不必要的标签渲染 2026-01-02 20:50:13 +08:00
时瑾
aecf33f4dc
fix: close #1488 2026-01-02 17:07:39 +08:00
时瑾
dd4374389b
fix: close #1435 (#1485)
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
* fix: close #1435

* fix: 优化视频缩略图生成和清理逻辑,处理文件不存在的情况
2026-01-01 21:41:01 +08:00
时瑾
100efb03ab
fix: close #1477 (#1484) 2026-01-01 21:40:49 +08:00
时瑾
ce9482f19d
feat: 优化webui界面和文件管理器 (#1472) 2026-01-01 21:40:39 +08:00
手瓜一十雪
4e37b002f9 Add support for version 9.9.26-44175 and fix import type
Added entries for version 9.9.26-44175 in appid.json, napi2native.json, and packet.json to support the new version. Also updated the import of createActionMap in napcat-plugin/index.ts to use a type-only import.
2026-01-01 10:32:59 +08:00
Nepenthe
7e7262415b
更新插件示例,修复插件打包问题 (#1486)
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
* fix: 修复打包错误

* fix: 完善插件模板

* Update index.ts
2025-12-31 13:58:55 +08:00
时瑾
3365211507
ci: 添加构建结果评论中的下载链接和获取 artifacts 列表功能
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
2025-12-29 03:14:17 +08:00
时瑾
05b38825c0
ci: 使用 type 导入 PullRequest 和 BuildStatus 类型 2025-12-29 03:01:21 +08:00
时瑾
95f4a4d37e
ci: pr build 2025-12-29 02:55:11 +08:00
时瑾
cd495fc7a0
fix: close #1467 close #1471
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
2025-12-27 16:27:54 +08:00
时瑾
656279d74b
fix: close #1463
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
2025-12-27 00:20:59 +08:00
手瓜一十雪
377c780d1a Comment out manual chunking for @heroui in Vite config
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
The manual chunking logic for '@heroui' modules in the Vite configuration has been commented out. This may be to simplify chunk splitting or address build issues related to custom chunking.
2025-12-24 19:31:52 +08:00
手瓜一十雪
aefa8985b1 Remove vite-plugin-font and related dependencies
This update removes vite-plugin-font and its associated dependencies from pnpm-lock.yaml. This likely reflects a cleanup of unused packages or a migration away from font-related build tooling.
2025-12-24 18:30:55 +08:00
手瓜一十雪
b034940dfd Remove vite-plugin-font from dependencies
Deleted the vite-plugin-font package from both the root and frontend package.json files as it is no longer required.
2025-12-24 18:29:17 +08:00
手瓜一十雪
cb8e10cc7e Add sw_template.js to build and improve service worker loading
Updated the Vite config to copy sw_template.js to the static assets during build. Modified backend to load sw_template.js from the static directory if available, falling back to the source assets if not. This ensures the service worker template is correctly served in production builds.
2025-12-24 18:20:51 +08:00
手瓜一十雪
afed164ba1 Add background-aware styling to sidebar and usage pie
Updated sidebar, navigation list, and usage pie components to adjust their styles based on the presence of a custom background image. This improves visual integration when a background image is set, ensuring text and UI elements remain readable and aesthetically consistent.
2025-12-24 18:14:04 +08:00
手瓜一十雪
a34a86288b Refactor font handling and theme config, switch to CodeMirror editor
Replaces Monaco editor with CodeMirror in the frontend, removing related dependencies and configuration. Refactors font management to support multiple formats (woff, woff2, ttf, otf) and dynamic font switching, including backend API and frontend theme config UI. Adds gzip compression middleware to backend. Updates theme config to allow font selection and custom font upload, and improves theme preview and color customization UI. Cleans up unused code and improves sidebar and terminal font sizing responsiveness.
2025-12-24 18:02:54 +08:00
手瓜一十雪
50bcd71144 Remove unused dependencies and optimize Monaco workers
Removed @simplewebauthn/browser, framer-motion, and react-responsive from dependencies as they are no longer used. Updated Monaco editor configuration to only load the JSON worker for improved performance, falling back to the basic editor worker for other languages. Refactored the new version tip UI to use Chip and Spinner instead of Button and removed unused react-icons import. Also updated Vite config to stop sharing react-icons.
2025-12-24 15:32:21 +08:00
手瓜一十雪
fa3a229827 Refactor dashboard and components, remove echarts
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Replaces echarts-based usage pie chart with a custom SVG implementation, removing the echarts dependency. Improves caching for version and system info requests, simplifies page background to static elements, and switches dashboard state to use localStorage for persistence. Also removes polling from hitokoto and updates button styling in system info.
2025-12-24 13:56:34 +08:00
手瓜一十雪
e56b912bbd Remove debug log from emoji_like event handler
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
Eliminated a console.log statement in the 'event:emoji_like' event handler to clean up debug output.
2025-12-22 17:08:32 +08:00
手瓜一十雪
da0dd01460 Remove debug console.log statements from DebugAdapter
Eliminated several console.log statements used for debugging in the DebugAdapter and DebugAdapterManager classes to clean up console output.
2025-12-22 16:29:05 +08:00
手瓜一十雪
578dda2f17 feat: 支持免配置调试 2025-12-22 16:27:06 +08:00
手瓜一十雪
649165bf00 Redesign OneBot API debug UI and improve usability
Refactored the OneBot API debug interface for a more modern, tabbed layout with improved sidebar navigation, request/response panels, and better mobile support. Enhanced code editor, response display, and message construction modal. Updated system info and status display for cleaner visuals. Improved xterm font sizing and rendering logic for mobile. WebSocket debug page now features a unified header, status bar, and clearer connection controls. Overall, this commit provides a more user-friendly and visually consistent debugging experience.
2025-12-22 15:21:45 +08:00
手瓜一十雪
c4f7107038 Refactor update dialog for new version notification
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Replaces the old update confirmation and toast logic with a new dialog-based update flow, including detailed status feedback (idle, updating, success, error) and improved user guidance. Removes react-hot-toast dependency and introduces a dedicated UpdateDialogContent component for clearer update progress and error handling.
2025-12-22 14:10:23 +08:00
手瓜一十雪
7f81bf45ee Revert "Refactor UI components for consistent styling"
This reverts commit 7e6035d98b.
2025-12-22 14:04:26 +08:00
手瓜一十雪
7e6035d98b Refactor UI components for consistent styling
Unified card and component styles across the frontend by removing background image logic and related conditional classes. Updated color schemes, shadows, and spacing for a more consistent appearance. Improved error handling and response structure in the backend update handler.
2025-12-22 13:34:59 +08:00
手瓜一十雪
2405cb03d8 Improve background and text styling in NetworkItemDisplay
Adjusted background opacity and hover effects for better visual consistency. Updated text color logic to enhance readability based on background presence.
2025-12-22 13:07:08 +08:00
手瓜一十雪
32d3ff6998 Add showType prop and remove ScrollShadow usage
Added an optional showType prop to NetworkDisplayCardProps in common_card.tsx. Removed the ScrollShadow component and replaced it with a standard div in nav_list.tsx to simplify the layout.
2025-12-22 12:32:48 +08:00
手瓜一十雪
84f0e0f9a0 Refactor UI for network cards and improve theming
Redesigned network display cards and related components for a more modern, consistent look, including improved button styles, card layouts, and responsive design. Added support for background images and dynamic theming across cards, tables, and log views. Enhanced input and select components with unified styling. Improved file table responsiveness and log display usability. Refactored OneBot API debug and navigation UI for better usability and mobile support.
2025-12-22 12:27:56 +08:00
手瓜一十雪
8697061a90 Refactor UI styles for improved consistency and clarity
Unified card backgrounds, borders, and shadows across components for a more consistent look. Enhanced table, tab, and button styles for clarity and accessibility. Improved layout and modal structure in OneBot API debug, added modal for struct display, and optimized WebSocket debug connection logic. Updated file manager, logs, network, and terminal pages for visual consistency. Refactored interface definitions for stricter typing and readability.
2025-12-22 10:38:23 +08:00
手瓜一十雪
872a3e0100 Add @heroui/divider to frontend dependencies
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
Added the @heroui/divider package (version ^2.2.21) to the napcat-webui-frontend dependencies in package.json and updated pnpm-lock.yaml accordingly.
2025-12-20 18:10:32 +08:00
手瓜一十雪
4fcbdc4d89 Remove music player and related context/hooks
Deleted the audio player component, songs context, and use-music hook, along with all related code and configuration. Updated affected components and pages to remove music player dependencies and UI. Also improved sidebar, background, and about page UI, and refactored site config icons to use react-icons.
2025-12-20 18:07:16 +08:00
手瓜一十雪
176af14915 Add 42941 version mappings to external JSON files
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
Added new entries for version 42941 to appid.json, napi2native.json, and packet.json, including mappings for x64 and arm64 architectures. This update ensures support for the latest client versions and their corresponding identifiers and packet mappings.
2025-12-05 18:29:10 +08:00
手瓜一十雪
81cf1fd98e Update wording in usage instructions in README
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
Clarified the instructions regarding support for integration, basic, and underlying framework issues to improve user understanding.
2025-12-01 13:28:18 +08:00
手瓜一十雪
5189099146 Add pnpm-lock.yaml and update .gitignore
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Added pnpm-lock.yaml to track dependencies and removed it from .gitignore in the napcat-webui-frontend package to enable version control of the lock file.
2025-11-30 18:08:22 +08:00
手瓜一十雪
7fc17d45ba Add support for 9.9.25-42905 and 6.9.86-42905 versions
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Updated appid.json, napi2native.json, and packet.json to include entries for versions 9.9.25-42905 (x64/Win) and 6.9.86-42905 (arm64/Mac), adding corresponding appid, qua, send, and recv values.
2025-11-30 12:56:24 +08:00
手瓜一十雪
c54f74609e Update version keys from 9.9.23-42744 to 9.9.25-42744
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
Renamed version keys in appid.json, napi2native.json, and packet.json from 9.9.23-42744(-x64) to 9.9.25-42744(-x64) to reflect the new version. Associated values remain unchanged.
2025-11-28 17:25:28 +08:00
149 changed files with 22866 additions and 3154 deletions

View File

@ -1,4 +1,4 @@
# V?.?.?
# {VERSION}
[使用文档](https://napneko.github.io/)
## Windows 一键包
@ -24,4 +24,20 @@ NapCat.Shell.Windows.OneKey.zip (无头)
## 如果WinX64缺少运行库或者xxx.dll
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
## 更新
## 更新
### 🐛 修复
1. 修复 WebUI 主题配置在有未保存更改时卸载组件导致字体重置的问题 (ae42eed6)
### ✨ 新增
1. 文件上传相关接口UploadGroupFile/UploadPrivateFile新增 `upload_file` 参数支持 (91e0839e)
2. 消息发送逻辑支持 PTT语音元素过滤确保语音消息正确独立发送 (47983e29)
### 🔧 优化
1. 优化合并转发消息GetForwardMsg的获取与解析逻辑提高兼容性 (334c4233)
2. 改进消息发送方法中发送者 UIN 的处理逻辑 (71bb4f68)
3. 增强 WebUI 系统信息界面中对构建产物的处理与展示 (cb061890)
---
**完整更新日志**: [v4.10.6...v4.10.7](https://github.com/NapNeko/NapCatQQ/compare/v4.10.6...v4.10.7)

View File

@ -1,34 +1,33 @@
注意:输出必须严格使用 NapCat 的发布说明格式,严格保证示例格式,并用简体中文。
# NapCat Release Note Generator
格式规则:
1. 第一行:# V{TAG}
2. 第二行:[使用文档](https://napneko.github.io/)
3. 空行后,按下面的节顺序输出(存在则输出,不存在则省略该节):
你是 NapCat 项目的发布说明生成器。请根据提供的 commit 列表生成标准格式的发布说明。
## Windows 一键包
- 简短一句话介绍一键包用途
- 列出可下载的文件名(只列文件名,不写下载链接)
## 核心规则
## 警告
- 如果有需要特别提醒的兼容/运行库/版本要求,写成加粗警告句
1. **版本号**:第一行必须是 `# {VERSION}`,使用用户提供的版本号,如果版本号是小写 v 开头(如 v4.10.2),必须转换为大写 V如 V4.10.2
2. **语言**:全部使用简体中文
3. **格式**:严格按照下方模板输出,不要添加额外的 markdown 格式
## 如果WinX64缺少运行库或者xxx.dll
- 常见运行库建议
## Commit 分析规则
## 更新
按数字序列列出主要变更项,每条尽量一句话
- 前缀短 commit id例如1. 修复 get_essence_msg_list 崩溃 (a1b2c3d)
- 保持 4-18 条要点
将 commit 分类为以下类型:
- 🐛 **修复**bug fix、修复、fix 相关
- ✨ **新增**新功能、feat、add 相关
- 🔧 **优化**优化、重构、refactor、improve、perf 相关
- 📦 **依赖**deps、依赖更新通常可以忽略或合并
- 🔨 **构建**ci、build、workflow 相关(通常可以忽略)
## 开发者注意
- 列出迁移/接口断裂/配置变更;若无则省略
## 合并和筛选
额外约束:
- 语言简体中文,面向最终用户
- **合并相似项**:同一功能的多个 commit 合并为一条
- **忽略琐碎项**合并冲突、格式化、typo 等可忽略
- **控制数量**:最终保持 5-15 条更新要点
- **保留 commit hash**:每条末尾附上短 hash格式 `(a1b2c3d)`
下面为真实示例,请完全参考(第一行版本号必须使用用户提供的版本号,例如 v4.9.5
## 输出模板 - 必须严格遵守以下格式
# V4.9.0
```
# {VERSION}
[使用文档](https://napneko.github.io/)
## Windows 一键包
@ -37,7 +36,7 @@
你可以下载
NapCat.Shell.Windows.OneKey.zip (无头)
NapCat.Shell.Windows.OneKey.zip (无头)
启动后可自动化部署一键包,教程参考使用文档安装部分
@ -55,6 +54,58 @@ NapCat.Shell.Windows.OneKey.zip (无头)
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
## 更新
1. 修改了XXXXX
2. 新增了XXXX
3. 重构了XXXX
### 🐛 修复
1. 修复 xxx 问题 (a1b2c3d)
2. 修复 yyy 崩溃 (b2c3d4e)
### ✨ 新增
1. 新增 xxx 功能 (c3d4e5f)
2. 支持 yyy 特性 (d4e5f6g)
### 🔧 优化
1. 优化 xxx 性能 (e5f6g7h)
2. 重构 yyy 模块 (f6g7h8i)
---
**完整更新日志**: [{PREV_VERSION}...{VERSION}](https://github.com/NapNeko/NapCatQQ/compare/{PREV_VERSION}...{VERSION})
```
**格式要求 - 务必严格遵守:**
- "Windows 一键包"部分的文本必须完全一致,不要修改任何措辞
- "警告"部分必须包含所有 QQ 版本下载链接,保持原有格式
- "如果WinX64缺少运行库或者xxx.dll"这一行必须保持原样
- QQ 版本号和下载链接保持不变40990 版本)
- 只有"## 更新"部分下面的内容需要根据实际 commit 生成
## 重要约束
1. 如果某个分类没有内容,则完全省略该分类
2. 不要编造不存在的更新
3. 保持简洁,每条更新控制在一行内
4. 使用用户友好的语言,避免过于技术化的描述
5. 重大变更Breaking Changes需要在注意事项中加粗提示
## 文件变化分析
用户会提供文件变化统计和具体代码diff帮助你理解变更内容
### 目录含义
- `packages/napcat-core/` → 核心功能、消息处理、QQ接口
- `packages/napcat-onebot/` → OneBot 协议实现、API、事件
- `packages/napcat-webui-backend/` → WebUI 后端接口
- `packages/napcat-webui-frontend/` → WebUI 前端界面
- `packages/napcat-shell/` → Shell 启动器
### 代码diff阅读指南
- `+` 开头的行是新增代码
- `-` 开头的行是删除代码
- 关注函数名、类名的变化来理解功能变更
- 关注 `fix`、`bug`、`error` 等关键词识别修复项
- 关注 `add`、`new`、`feature` 等关键词识别新功能
- 忽略纯重构(代码移动但功能不变)和格式化变更
### 截断说明
- 如果看到 `[... 已截断 ...]`,表示内容过长被截断
- 根据已有信息推断完整变更意图即可

231
.github/scripts/lib/comment.ts vendored Normal file
View File

@ -0,0 +1,231 @@
/**
*
*/
export const COMMENT_MARKER = '<!-- napcat-pr-build -->';
export type BuildStatus = 'success' | 'failure' | 'cancelled' | 'pending' | 'unknown';
export interface BuildTarget {
name: string;
status: BuildStatus;
error?: string;
downloadUrl?: string; // Artifact 直接下载链接
}
// ============== 辅助函数 ==============
function formatSha (sha: string): string {
return sha && sha.length >= 7 ? sha.substring(0, 7) : sha || 'unknown';
}
function escapeCodeBlock (text: string): string {
// 替换 ``` 为转义形式,避免破坏 Markdown 代码块
return text.replace(/```/g, '\\`\\`\\`');
}
function getTimeString (): string {
return new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
}
// ============== 状态图标 ==============
export function getStatusIcon (status: BuildStatus): string {
switch (status) {
case 'success':
return '✅ 成功';
case 'pending':
return '⏳ 构建中...';
case 'cancelled':
return '⚪ 已取消';
case 'failure':
return '❌ 失败';
default:
return '❓ 未知';
}
}
function getStatusEmoji (status: BuildStatus): string {
switch (status) {
case 'success': return '✅';
case 'pending': return '⏳';
case 'cancelled': return '⚪';
case 'failure': return '❌';
default: return '❓';
}
}
// ============== 构建中评论 ==============
export function generateBuildingComment (prSha: string, targets: string[]): string {
const time = getTimeString();
const shortSha = formatSha(prSha);
const lines: string[] = [
COMMENT_MARKER,
'',
'<div align="center">',
'',
'# 🔨 NapCat 构建中',
'',
'![Building](https://img.shields.io/badge/状态-构建中-yellow?style=for-the-badge&logo=github-actions&logoColor=white)',
'',
'</div>',
'',
'---',
'',
'## 📦 构建目标',
'',
'| 包名 | 状态 | 说明 |',
'| :--- | :---: | :--- |',
...targets.map(name => `| \`${name}\` | ⏳ | 正在构建... |`),
'',
'---',
'',
'## 📋 构建信息',
'',
`| 项目 | 值 |`,
`| :--- | :--- |`,
`| 📝 提交 | \`${shortSha}\` |`,
`| 🕐 开始时间 | ${time} |`,
'',
'---',
'',
'<div align="center">',
'',
'> ⏳ **构建进行中,请稍候...**',
'>',
'> 构建完成后将自动更新此评论',
'',
'</div>',
];
return lines.join('\n');
}
// ============== 构建结果评论 ==============
export function generateResultComment (
targets: BuildTarget[],
prSha: string,
runId: string,
repository: string,
version?: string
): string {
const runUrl = `https://github.com/${repository}/actions/runs/${runId}`;
const shortSha = formatSha(prSha);
const time = getTimeString();
const allSuccess = targets.every(t => t.status === 'success');
const anyCancelled = targets.some(t => t.status === 'cancelled');
const anyFailure = targets.some(t => t.status === 'failure');
// 状态徽章
let statusBadge: string;
let headerTitle: string;
if (allSuccess) {
statusBadge = '![Success](https://img.shields.io/badge/状态-构建成功-success?style=for-the-badge&logo=github-actions&logoColor=white)';
headerTitle = '# ✅ NapCat 构建成功';
} else if (anyCancelled && !anyFailure) {
statusBadge = '![Cancelled](https://img.shields.io/badge/状态-已取消-lightgrey?style=for-the-badge&logo=github-actions&logoColor=white)';
headerTitle = '# ⚪ NapCat 构建已取消';
} else {
statusBadge = '![Failed](https://img.shields.io/badge/状态-构建失败-critical?style=for-the-badge&logo=github-actions&logoColor=white)';
headerTitle = '# ❌ NapCat 构建失败';
}
const downloadLink = (target: BuildTarget) => {
if (target.status !== 'success') return '—';
if (target.downloadUrl) {
return `[📥 下载](${target.downloadUrl})`;
}
return `[📥 下载](${runUrl}#artifacts)`;
};
const lines: string[] = [
COMMENT_MARKER,
'',
'<div align="center">',
'',
headerTitle,
'',
statusBadge,
'',
'</div>',
'',
'---',
'',
'## 📦 构建产物',
'',
'| 包名 | 状态 | 下载 |',
'| :--- | :---: | :---: |',
...targets.map(t => `| \`${t.name}\` | ${getStatusEmoji(t.status)} ${t.status === 'success' ? '成功' : t.status === 'failure' ? '失败' : t.status === 'cancelled' ? '已取消' : '未知'} | ${downloadLink(t)} |`),
'',
'---',
'',
'## 📋 构建信息',
'',
`| 项目 | 值 |`,
`| :--- | :--- |`,
...(version ? [`| 🏷️ 版本号 | \`${version}\` |`] : []),
`| 📝 提交 | \`${shortSha}\` |`,
`| 🔗 构建日志 | [查看详情](${runUrl}) |`,
`| 🕐 完成时间 | ${time} |`,
];
// 添加错误详情
const failedTargets = targets.filter(t => t.status === 'failure' && t.error);
if (failedTargets.length > 0) {
lines.push('', '---', '', '## ⚠️ 错误详情', '');
for (const target of failedTargets) {
lines.push(
`<details>`,
`<summary>🔴 <b>${target.name}</b> 构建错误</summary>`,
'',
'```',
escapeCodeBlock(target.error!),
'```',
'',
'</details>',
''
);
}
}
// 添加底部提示
lines.push('---', '');
if (allSuccess) {
lines.push(
'<div align="center">',
'',
'> 🎉 **所有构建均已成功完成!**',
'>',
'> 点击上方下载链接获取构建产物进行测试',
'',
'</div>'
);
} else if (anyCancelled && !anyFailure) {
lines.push(
'<div align="center">',
'',
'> ⚪ **构建已被取消**',
'>',
'> 可能是由于新的提交触发了新的构建',
'',
'</div>'
);
} else {
lines.push(
'<div align="center">',
'',
'> ⚠️ **部分构建失败**',
'>',
'> 请查看上方错误详情或点击构建日志查看完整输出',
'',
'</div>'
);
}
return lines.join('\n');
}

189
.github/scripts/lib/github.ts vendored Normal file
View File

@ -0,0 +1,189 @@
/**
* GitHub API
*/
import { appendFileSync } from 'node:fs';
// ============== 类型定义 ==============
export interface PullRequest {
number: number;
state: string;
head: {
sha: string;
ref: string;
repo: {
full_name: string;
};
};
}
export interface Repository {
owner: {
type: string;
};
}
export interface Artifact {
id: number;
name: string;
size_in_bytes: number;
archive_download_url: string;
}
// ============== GitHub API Client ==========================
export class GitHubAPI {
private token: string;
private baseUrl = 'https://api.github.com';
constructor (token: string) {
this.token = token;
}
private async request<T> (endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
...options.headers,
},
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<T>;
}
async getPullRequest (owner: string, repo: string, pullNumber: number): Promise<PullRequest> {
return this.request<PullRequest>(`/repos/${owner}/${repo}/pulls/${pullNumber}`);
}
async getCollaboratorPermission (owner: string, repo: string, username: string): Promise<string> {
const data = await this.request<{ permission: string; }>(
`/repos/${owner}/${repo}/collaborators/${username}/permission`
);
return data.permission;
}
async getRepository (owner: string, repo: string): Promise<Repository> {
return this.request(`/repos/${owner}/${repo}`);
}
async checkOrgMembership (org: string, username: string): Promise<boolean> {
try {
await this.request(`/orgs/${org}/members/${username}`);
return true;
} catch {
return false;
}
}
async getRunArtifacts (owner: string, repo: string, runId: string): Promise<Artifact[]> {
const data = await this.request<{ artifacts: Artifact[]; }>(
`/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`
);
return data.artifacts;
}
async createComment (owner: string, repo: string, issueNumber: number, body: string): Promise<void> {
await this.request(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
method: 'POST',
body: JSON.stringify({ body }),
headers: { 'Content-Type': 'application/json' },
});
}
async findComment (owner: string, repo: string, issueNumber: number, marker: string): Promise<number | null> {
let page = 1;
const perPage = 100;
while (page <= 10) { // 最多检查 1000 条评论
const comments = await this.request<Array<{ id: number, body: string; }>>(
`/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=${perPage}&page=${page}`
);
if (comments.length === 0) {
return null;
}
const found = comments.find(c => c.body.includes(marker));
if (found) {
return found.id;
}
if (comments.length < perPage) {
return null;
}
page++;
}
return null;
}
async updateComment (owner: string, repo: string, commentId: number, body: string): Promise<void> {
await this.request(`/repos/${owner}/${repo}/issues/comments/${commentId}`, {
method: 'PATCH',
body: JSON.stringify({ body }),
headers: { 'Content-Type': 'application/json' },
});
}
async createOrUpdateComment (
owner: string,
repo: string,
issueNumber: number,
body: string,
marker: string
): Promise<void> {
const existingId = await this.findComment(owner, repo, issueNumber, marker);
if (existingId) {
await this.updateComment(owner, repo, existingId, body);
console.log(`✓ Updated comment #${existingId}`);
} else {
await this.createComment(owner, repo, issueNumber, body);
console.log('✓ Created new comment');
}
}
}
// ============== Output 工具 ==============
export function setOutput (name: string, value: string): void {
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
appendFileSync(outputFile, `${name}=${value}\n`);
}
console.log(` ${name}=${value}`);
}
export function setMultilineOutput (name: string, value: string): void {
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
const delimiter = `EOF_${Date.now()}`;
appendFileSync(outputFile, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
}
}
// ============== 环境变量工具 ==============
export function getEnv (name: string, required: true): string;
export function getEnv (name: string, required?: false): string | undefined;
export function getEnv (name: string, required = false): string | undefined {
const value = process.env[name];
if (required && !value) {
throw new Error(`Environment variable ${name} is required`);
}
return value;
}
export function getRepository (): { owner: string, repo: string; } {
const repository = getEnv('GITHUB_REPOSITORY', true);
const [owner, repo] = repository.split('/');
return { owner, repo };
}

36
.github/scripts/pr-build-building.ts vendored Normal file
View File

@ -0,0 +1,36 @@
/**
* PR Build -
*
* :
* - GITHUB_TOKEN: GitHub API Token
* - PR_NUMBER: PR
* - PR_SHA: PR SHA
*/
import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
import { generateBuildingComment, COMMENT_MARKER } from './lib/comment.ts';
const BUILD_TARGETS = ['NapCat.Framework', 'NapCat.Shell'];
async function main (): Promise<void> {
console.log('🔨 Updating building status comment\n');
const token = getEnv('GITHUB_TOKEN', true);
const prNumber = parseInt(getEnv('PR_NUMBER', true), 10);
const prSha = getEnv('PR_SHA', true);
const { owner, repo } = getRepository();
console.log(`PR: #${prNumber}`);
console.log(`SHA: ${prSha}`);
console.log(`Repo: ${owner}/${repo}\n`);
const github = new GitHubAPI(token);
const comment = generateBuildingComment(prSha, BUILD_TARGETS);
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
}
main().catch((error) => {
console.error('❌ Error:', error);
process.exit(1);
});

206
.github/scripts/pr-build-check.ts vendored Normal file
View File

@ -0,0 +1,206 @@
/**
* PR Build Check Script
* PR
*
* :
* - GITHUB_TOKEN: GitHub API Token
* - GITHUB_EVENT_NAME: 事件名称
* - GITHUB_EVENT_PATH: 事件 payload
* - GITHUB_REPOSITORY: 仓库名称 (owner/repo)
* - GITHUB_OUTPUT: 输出文件路径
*/
import { readFileSync } from 'node:fs';
import { GitHubAPI, getEnv, getRepository, setOutput } from './lib/github.ts';
import type { PullRequest } from './lib/github.ts';
// ============== 类型定义 ==============
interface GitHubPayload {
pull_request?: PullRequest;
issue?: {
number: number;
pull_request?: object;
};
comment?: {
body: string;
user: { login: string; };
};
}
interface CheckResult {
should_build: boolean;
pr_number?: number;
pr_sha?: string;
pr_head_repo?: string;
pr_head_ref?: string;
}
// ============== 权限检查 ==============
async function checkUserPermission (
github: GitHubAPI,
owner: string,
repo: string,
username: string
): Promise<boolean> {
// 方法1检查仓库协作者权限
try {
const permission = await github.getCollaboratorPermission(owner, repo, username);
if (['admin', 'write', 'maintain'].includes(permission)) {
console.log(`✓ User ${username} has ${permission} permission`);
return true;
}
console.log(`✗ User ${username} has ${permission} permission (insufficient)`);
} catch (e) {
console.log(`✗ Failed to get collaborator permission: ${(e as Error).message}`);
}
// 方法2检查组织成员身份
try {
const repoInfo = await github.getRepository(owner, repo);
if (repoInfo.owner.type === 'Organization') {
const isMember = await github.checkOrgMembership(owner, username);
if (isMember) {
console.log(`✓ User ${username} is organization member`);
return true;
}
console.log(`✗ User ${username} is not organization member`);
}
} catch (e) {
console.log(`✗ Failed to check org membership: ${(e as Error).message}`);
}
return false;
}
// ============== 事件处理 ==============
function handlePullRequestTarget (payload: GitHubPayload): CheckResult {
const pr = payload.pull_request;
if (!pr) {
console.log('✗ No pull_request in payload');
return { should_build: false };
}
if (pr.state !== 'open') {
console.log(`✗ PR is not open (state: ${pr.state})`);
return { should_build: false };
}
console.log(`✓ PR #${pr.number} is open, triggering build`);
return {
should_build: true,
pr_number: pr.number,
pr_sha: pr.head.sha,
pr_head_repo: pr.head.repo.full_name,
pr_head_ref: pr.head.ref,
};
}
async function handleIssueComment (
payload: GitHubPayload,
github: GitHubAPI,
owner: string,
repo: string
): Promise<CheckResult> {
const { issue, comment } = payload;
if (!issue || !comment) {
console.log('✗ No issue or comment in payload');
return { should_build: false };
}
// 检查是否是 PR 的评论
if (!issue.pull_request) {
console.log('✗ Comment is not on a PR');
return { should_build: false };
}
// 检查是否是 /build 命令
if (!comment.body.trim().startsWith('/build')) {
console.log('✗ Comment is not a /build command');
return { should_build: false };
}
console.log(`→ /build command from @${comment.user.login}`);
// 获取 PR 详情
const pr = await github.getPullRequest(owner, repo, issue.number);
// 检查 PR 状态
if (pr.state !== 'open') {
console.log(`✗ PR is not open (state: ${pr.state})`);
await github.createComment(owner, repo, issue.number, '⚠️ 此 PR 已关闭,无法触发构建。');
return { should_build: false };
}
// 检查用户权限
const username = comment.user.login;
const hasPermission = await checkUserPermission(github, owner, repo, username);
if (!hasPermission) {
console.log(`✗ User ${username} has no permission`);
await github.createComment(
owner,
repo,
issue.number,
`⚠️ @${username} 您没有权限使用 \`/build\` 命令,仅仓库协作者或组织成员可使用。`
);
return { should_build: false };
}
console.log(`✓ Build triggered by @${username}`);
return {
should_build: true,
pr_number: issue.number,
pr_sha: pr.head.sha,
pr_head_repo: pr.head.repo.full_name,
pr_head_ref: pr.head.ref,
};
}
// ============== 主函数 ==============
async function main (): Promise<void> {
console.log('🔍 PR Build Check\n');
const token = getEnv('GITHUB_TOKEN', true);
const eventName = getEnv('GITHUB_EVENT_NAME', true);
const eventPath = getEnv('GITHUB_EVENT_PATH', true);
const { owner, repo } = getRepository();
console.log(`Event: ${eventName}`);
console.log(`Repository: ${owner}/${repo}\n`);
const payload = JSON.parse(readFileSync(eventPath, 'utf-8')) as GitHubPayload;
const github = new GitHubAPI(token);
let result: CheckResult;
switch (eventName) {
case 'pull_request_target':
result = handlePullRequestTarget(payload);
break;
case 'issue_comment':
result = await handleIssueComment(payload, github, owner, repo);
break;
default:
console.log(`✗ Unsupported event: ${eventName}`);
result = { should_build: false };
}
// 输出结果
console.log('\n=== Outputs ===');
setOutput('should_build', String(result.should_build));
setOutput('pr_number', String(result.pr_number ?? ''));
setOutput('pr_sha', result.pr_sha ?? '');
setOutput('pr_head_repo', result.pr_head_repo ?? '');
setOutput('pr_head_ref', result.pr_head_ref ?? '');
}
main().catch((error) => {
console.error('❌ Error:', error);
process.exit(1);
});

90
.github/scripts/pr-build-result.ts vendored Normal file
View File

@ -0,0 +1,90 @@
/**
* PR Build -
*
* :
* - GITHUB_TOKEN: GitHub API Token
* - PR_NUMBER: PR
* - PR_SHA: PR SHA
* - RUN_ID: GitHub Actions Run ID
* - NAPCAT_VERSION: 构建版本号
* - FRAMEWORK_STATUS: Framework
* - FRAMEWORK_ERROR: Framework
* - SHELL_STATUS: Shell
* - SHELL_ERROR: Shell
*/
import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
import { generateResultComment, COMMENT_MARKER } from './lib/comment.ts';
import type { BuildTarget, BuildStatus } from './lib/comment.ts';
function parseStatus (value: string | undefined): BuildStatus {
if (value === 'success' || value === 'failure' || value === 'cancelled') {
return value;
}
return 'unknown';
}
async function main (): Promise<void> {
console.log('📝 Updating build result comment\n');
const token = getEnv('GITHUB_TOKEN', true);
const prNumber = parseInt(getEnv('PR_NUMBER', true), 10);
const prSha = getEnv('PR_SHA') || 'unknown';
const runId = getEnv('RUN_ID', true);
const version = getEnv('NAPCAT_VERSION') || '';
const { owner, repo } = getRepository();
const frameworkStatus = parseStatus(getEnv('FRAMEWORK_STATUS'));
const frameworkError = getEnv('FRAMEWORK_ERROR');
const shellStatus = parseStatus(getEnv('SHELL_STATUS'));
const shellError = getEnv('SHELL_ERROR');
console.log(`PR: #${prNumber}`);
console.log(`SHA: ${prSha}`);
console.log(`Version: ${version}`);
console.log(`Run: ${runId}`);
console.log(`Framework: ${frameworkStatus}${frameworkError ? ` (${frameworkError})` : ''}`);
console.log(`Shell: ${shellStatus}${shellError ? ` (${shellError})` : ''}\n`);
const github = new GitHubAPI(token);
const repository = `${owner}/${repo}`;
// 获取 artifacts 列表,生成直接下载链接
const artifactMap: Record<string, string> = {};
try {
const artifacts = await github.getRunArtifacts(owner, repo, runId);
console.log(`Found ${artifacts.length} artifacts`);
for (const artifact of artifacts) {
// 生成直接下载链接https://github.com/{owner}/{repo}/actions/runs/{run_id}/artifacts/{artifact_id}
const downloadUrl = `https://github.com/${repository}/actions/runs/${runId}/artifacts/${artifact.id}`;
artifactMap[artifact.name] = downloadUrl;
console.log(` - ${artifact.name}: ${downloadUrl}`);
}
} catch (e) {
console.log(`Warning: Failed to get artifacts: ${(e as Error).message}`);
}
const targets: BuildTarget[] = [
{
name: 'NapCat.Framework',
status: frameworkStatus,
error: frameworkError,
downloadUrl: artifactMap['NapCat.Framework'],
},
{
name: 'NapCat.Shell',
status: shellStatus,
error: shellError,
downloadUrl: artifactMap['NapCat.Shell'],
},
];
const comment = generateResultComment(targets, prSha, runId, repository, version);
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
}
main().catch((error) => {
console.error('❌ Error:', error);
process.exit(1);
});

149
.github/scripts/pr-build-run.ts vendored Normal file
View File

@ -0,0 +1,149 @@
/**
* PR Build Runner
*
*
* 用法: node pr-build-run.ts <target>
* target: framework | shell
*/
import { execSync } from 'node:child_process';
import { existsSync, renameSync, unlinkSync } from 'node:fs';
import { setOutput } from './lib/github.ts';
type BuildTarget = 'framework' | 'shell';
interface BuildStep {
name: string;
command: string;
errorMessage: string;
}
// ============== 构建步骤 ==============
function getCommonSteps (): BuildStep[] {
return [
{
name: 'Install pnpm',
command: 'npm i -g pnpm',
errorMessage: 'Failed to install pnpm',
},
{
name: 'Install dependencies',
command: 'pnpm i',
errorMessage: 'Failed to install dependencies',
},
{
name: 'Type check',
command: 'pnpm run typecheck',
errorMessage: 'Type check failed',
},
{
name: 'Test',
command: 'pnpm test',
errorMessage: 'Tests failed',
},
{
name: 'Build WebUI',
command: 'pnpm --filter napcat-webui-frontend run build',
errorMessage: 'WebUI build failed',
},
];
}
function getTargetSteps (target: BuildTarget): BuildStep[] {
if (target === 'framework') {
return [
{
name: 'Build Framework',
command: 'pnpm run build:framework',
errorMessage: 'Framework build failed',
},
];
}
return [
{
name: 'Build Shell',
command: 'pnpm run build:shell',
errorMessage: 'Shell build failed',
},
];
}
// ============== 执行器 ==============
function runStep (step: BuildStep): boolean {
console.log(`\n::group::${step.name}`);
console.log(`> ${step.command}\n`);
try {
execSync(step.command, {
stdio: 'inherit',
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash',
});
console.log('::endgroup::');
console.log(`${step.name}`);
return true;
} catch (_error) {
console.log('::endgroup::');
console.log(`${step.name}`);
setOutput('error', step.errorMessage);
return false;
}
}
function postBuild (target: BuildTarget): void {
const srcDir = target === 'framework'
? 'packages/napcat-framework/dist'
: 'packages/napcat-shell/dist';
const destDir = target === 'framework' ? 'framework-dist' : 'shell-dist';
console.log(`\n→ Moving ${srcDir} to ${destDir}`);
if (!existsSync(srcDir)) {
throw new Error(`Build output not found: ${srcDir}`);
}
renameSync(srcDir, destDir);
// Install production dependencies
console.log('→ Installing production dependencies');
execSync('npm install --omit=dev', {
cwd: destDir,
stdio: 'inherit',
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash',
});
// Remove package-lock.json
const lockFile = `${destDir}/package-lock.json`;
if (existsSync(lockFile)) {
unlinkSync(lockFile);
}
console.log(`✓ Build output ready at ${destDir}`);
}
// ============== 主函数 ==============
function main (): void {
const target = process.argv[2] as BuildTarget;
if (!target || !['framework', 'shell'].includes(target)) {
console.error('Usage: node pr-build-run.ts <framework|shell>');
process.exit(1);
}
console.log(`🔨 Building NapCat.${target === 'framework' ? 'Framework' : 'Shell'}\n`);
const steps = [...getCommonSteps(), ...getTargetSteps(target)];
for (const step of steps) {
if (!runStep(step)) {
process.exit(1);
}
}
postBuild(target);
console.log('\n✅ Build completed successfully!');
}
main();

View File

@ -13,11 +13,27 @@ jobs:
steps:
- name: Clone Main Repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整历史来获取 tags
- name: Use Node.js 20.X
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Generate Version
run: |
# 获取最近的 release tag (格式: vX.X.X)
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
# 去掉 v 前缀
BASE_VERSION="${LATEST_TAG#v}"
SHORT_SHA="${GITHUB_SHA::7}"
VERSION="${BASE_VERSION}-main.${{ github.run_number }}+${SHORT_SHA}"
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
echo "Latest tag: ${LATEST_TAG}"
echo "Build version: ${VERSION}"
- name: Build NapCat.Framework
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: |
npm i -g pnpm
pnpm i
@ -39,11 +55,27 @@ jobs:
steps:
- name: Clone Main Repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整历史来获取 tags
- name: Use Node.js 20.X
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Generate Version
run: |
# 获取最近的 release tag (格式: vX.X.X)
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
# 去掉 v 前缀
BASE_VERSION="${LATEST_TAG#v}"
SHORT_SHA="${GITHUB_SHA::7}"
VERSION="${BASE_VERSION}-main.${{ github.run_number }}+${SHORT_SHA}"
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
echo "Latest tag: ${LATEST_TAG}"
echo "Build version: ${VERSION}"
- name: Build NapCat.Shell
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: |
npm i -g pnpm
pnpm i

303
.github/workflows/pr-build.yml vendored Normal file
View File

@ -0,0 +1,303 @@
# =============================================================================
# PR 构建工作流
# =============================================================================
# 功能:
# 1. 在 PR 提交时自动构建 Framework 和 Shell 包
# 2. 支持通过 /build 命令手动触发构建(仅协作者/组织成员)
# 3. 在 PR 中发布构建状态评论,并持续更新(不会重复创建)
# 4. 支持 Fork PR 的构建(使用 pull_request_target 获取写权限)
#
# 安全说明:
# - 使用 pull_request_target 事件,在 base 分支上下文运行
# - 构建脚本始终从 base 分支 checkout避免恶意 PR 篡改脚本
# - PR 代码单独 checkout 到 workspace 目录
# =============================================================================
name: PR Build
# =============================================================================
# 触发条件
# =============================================================================
on:
# PR 事件:打开、同步(新推送)、重新打开时触发
# 注意:使用 pull_request_target 而非 pull_request以便对 Fork PR 有写权限
pull_request_target:
types: [opened, synchronize, reopened]
# Issue 评论事件:用于响应 /build 命令
issue_comment:
types: [created]
# =============================================================================
# 权限配置
# =============================================================================
permissions:
contents: read # 读取仓库内容
pull-requests: write # 写入 PR 评论
issues: write # 写入 Issue 评论(/build 命令响应)
actions: read # 读取 Actions 信息(获取构建日志链接)
# =============================================================================
# 并发控制
# =============================================================================
# 同一 PR 的多次构建会取消之前未完成的构建,避免资源浪费
# 注意:只有在 should_build=true 时才会进入实际构建流程,
# issue_comment 事件如果不是 /build 命令,会在 check-build 阶段快速退出,
# 不会取消正在进行的构建(因为 cancel-in-progress 只影响同 group 的后续任务)
concurrency:
# 使用不同的 group 策略:
# - pull_request_target: 使用 PR 号
# - issue_comment: 只有确认是 /build 命令时才使用 PR 号,否则使用 run_id不冲突
group: pr-build-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '/build') && github.event.issue.number || github.run_id }}
cancel-in-progress: true
# =============================================================================
# 任务定义
# =============================================================================
jobs:
# ---------------------------------------------------------------------------
# Job 1: 检查构建条件
# ---------------------------------------------------------------------------
# 判断是否应该触发构建:
# - pull_request_target 事件:总是触发
# - issue_comment 事件:检查是否为 /build 命令,且用户有权限
# ---------------------------------------------------------------------------
check-build:
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.check.outputs.should_build }} # 是否应该构建
pr_number: ${{ steps.check.outputs.pr_number }} # PR 编号
pr_sha: ${{ steps.check.outputs.pr_sha }} # PR 最新提交 SHA
pr_head_repo: ${{ steps.check.outputs.pr_head_repo }} # PR 源仓库(用于 Fork
pr_head_ref: ${{ steps.check.outputs.pr_head_ref }} # PR 源分支
steps:
# 仅 checkout 脚本目录,加快速度
- name: Checkout scripts
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
# 使用 Node.js 24 以支持原生 TypeScript 执行
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 执行检查脚本,判断是否触发构建
- name: Check trigger condition
id: check
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node --experimental-strip-types .github/scripts/pr-build-check.ts
# ---------------------------------------------------------------------------
# Job 2: 更新评论为"构建中"状态
# ---------------------------------------------------------------------------
# 在 PR 中创建或更新评论,显示构建正在进行中
# ---------------------------------------------------------------------------
update-comment-building:
needs: check-build
if: needs.check-build.outputs.should_build == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout scripts
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 更新 PR 评论,显示构建中状态
- name: Update building comment
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ needs.check-build.outputs.pr_number }}
PR_SHA: ${{ needs.check-build.outputs.pr_sha }}
run: node --experimental-strip-types .github/scripts/pr-build-building.ts
# ---------------------------------------------------------------------------
# Job 3: 构建 Framework 包
# ---------------------------------------------------------------------------
# 执行 napcat-framework 的构建流程
# ---------------------------------------------------------------------------
build-framework:
needs: [check-build, update-comment-building]
if: needs.check-build.outputs.should_build == 'true'
runs-on: ubuntu-latest
outputs:
status: ${{ steps.build.outcome }} # 构建结果success/failure
error: ${{ steps.build.outputs.error }} # 错误信息(如有)
version: ${{ steps.version.outputs.version }} # 构建版本号
steps:
# 【安全】先从 base 分支 checkout 构建脚本
# 这样即使 PR 中修改了脚本,也不会被执行
- name: Checkout scripts from base
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
path: _scripts
# 将 PR 代码 checkout 到单独的 workspace 目录
- name: Checkout PR code
uses: actions/checkout@v4
with:
repository: ${{ needs.check-build.outputs.pr_head_repo }}
ref: ${{ needs.check-build.outputs.pr_sha }}
path: workspace
fetch-depth: 0 # 需要完整历史来获取 tags
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 获取最新 release tag 并生成版本号
- name: Generate Version
id: version
working-directory: workspace
run: |
# 获取最近的 release tag (格式: vX.X.X)
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
# 去掉 v 前缀
BASE_VERSION="${LATEST_TAG#v}"
SHORT_SHA="${{ needs.check-build.outputs.pr_sha }}"
SHORT_SHA="${SHORT_SHA::7}"
VERSION="${BASE_VERSION}-pr.${{ needs.check-build.outputs.pr_number }}.${{ github.run_number }}+${SHORT_SHA}"
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
echo "Latest tag: ${LATEST_TAG}"
echo "Build version: ${VERSION}"
# 执行构建,使用 base 分支的脚本处理 workspace 中的代码
- name: Build
id: build
working-directory: workspace
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts framework
continue-on-error: true # 允许失败,后续更新评论时处理
# 构建成功时上传产物
- name: Upload Artifact
if: steps.build.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: NapCat.Framework
path: workspace/framework-dist
retention-days: 7 # 保留 7 天
# ---------------------------------------------------------------------------
# Job 4: 构建 Shell 包
# ---------------------------------------------------------------------------
# 执行 napcat-shell 的构建流程(与 Framework 并行执行)
# ---------------------------------------------------------------------------
build-shell:
needs: [check-build, update-comment-building]
if: needs.check-build.outputs.should_build == 'true'
runs-on: ubuntu-latest
outputs:
status: ${{ steps.build.outcome }} # 构建结果success/failure
error: ${{ steps.build.outputs.error }} # 错误信息(如有)
version: ${{ steps.version.outputs.version }} # 构建版本号
steps:
# 【安全】先从 base 分支 checkout 构建脚本
- name: Checkout scripts from base
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
path: _scripts
# 将 PR 代码 checkout 到单独的 workspace 目录
- name: Checkout PR code
uses: actions/checkout@v4
with:
repository: ${{ needs.check-build.outputs.pr_head_repo }}
ref: ${{ needs.check-build.outputs.pr_sha }}
path: workspace
fetch-depth: 0 # 需要完整历史来获取 tags
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 获取最新 release tag 并生成版本号
- name: Generate Version
id: version
working-directory: workspace
run: |
# 获取最近的 release tag (格式: vX.X.X)
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
# 去掉 v 前缀
BASE_VERSION="${LATEST_TAG#v}"
SHORT_SHA="${{ needs.check-build.outputs.pr_sha }}"
SHORT_SHA="${SHORT_SHA::7}"
VERSION="${BASE_VERSION}-pr.${{ needs.check-build.outputs.pr_number }}.${{ github.run_number }}+${SHORT_SHA}"
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "Latest tag: ${LATEST_TAG}"
echo "Build version: ${VERSION}"
# 执行构建
- name: Build
id: build
working-directory: workspace
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts shell
continue-on-error: true
# 构建成功时上传产物
- name: Upload Artifact
if: steps.build.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: NapCat.Shell
path: workspace/shell-dist
retention-days: 7 # 保留 7 天
# ---------------------------------------------------------------------------
# Job 5: 更新评论为构建结果
# ---------------------------------------------------------------------------
# 汇总所有构建结果,更新 PR 评论显示最终状态
# 使用 always() 确保即使构建失败/取消也会执行
# ---------------------------------------------------------------------------
update-comment-result:
needs: [check-build, update-comment-building, build-framework, build-shell]
if: always() && needs.check-build.outputs.should_build == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout scripts
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 更新评论,显示构建结果和下载链接
- name: Update result comment
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ needs.check-build.outputs.pr_number }}
PR_SHA: ${{ needs.check-build.outputs.pr_sha }}
RUN_ID: ${{ github.run_id }}
# 构建版本号
NAPCAT_VERSION: ${{ needs.build-framework.outputs.version || needs.build-shell.outputs.version || '' }}
# 获取构建状态,如果 job 被跳过则标记为 cancelled
FRAMEWORK_STATUS: ${{ needs.build-framework.outputs.status || 'cancelled' }}
FRAMEWORK_ERROR: ${{ needs.build-framework.outputs.error }}
SHELL_STATUS: ${{ needs.build-shell.outputs.status || 'cancelled' }}
SHELL_ERROR: ${{ needs.build-shell.outputs.error }}
run: node --experimental-strip-types .github/scripts/pr-build-result.ts

View File

@ -4,17 +4,48 @@ on:
workflow_dispatch:
push:
tags:
- '*'
- 'v*'
permissions: write-all
env:
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
OPENROUTER_MODEL: "kimi-k2-0905-turbo"
OPENROUTER_MODEL: "Antigravity/gemini-3-flash-preview"
RELEASE_NAME: "NapCat"
jobs:
# 验证版本号格式
validate-version:
runs-on: ubuntu-latest
outputs:
valid: ${{ steps.check.outputs.valid }}
version: ${{ steps.check.outputs.version }}
steps:
- name: Validate semantic version
id: check
run: |
TAG="${GITHUB_REF#refs/tags/}"
echo "Checking tag: $TAG"
# 语义化版本正则表达式
# 支持: v1.0.0, v1.0.0-beta, v1.0.0-rc.1, v1.0.0-alpha.1+build.123
SEMVER_REGEX="^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?$"
if [[ "$TAG" =~ $SEMVER_REGEX ]]; then
echo "✅ Valid semantic version: $TAG"
echo "valid=true" >> $GITHUB_OUTPUT
echo "version=$TAG" >> $GITHUB_OUTPUT
else
echo "❌ Invalid version format: $TAG"
echo "Expected format: vX.Y.Z or vX.Y.Z-prerelease"
echo "Examples: v1.0.0, v1.2.3-beta, v2.0.0-rc.1"
echo "valid=false" >> $GITHUB_OUTPUT
exit 1
fi
Build-Framework:
needs: validate-version
if: needs.validate-version.outputs.valid == 'true'
runs-on: ubuntu-latest
steps:
- name: Clone Main Repository
@ -24,6 +55,8 @@ jobs:
with:
node-version: 20.x
- name: Build NapCat.Framework
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm i -g pnpm
pnpm i
@ -40,6 +73,8 @@ jobs:
path: framework-dist
Build-Shell:
needs: validate-version
if: needs.validate-version.outputs.valid == 'true'
runs-on: ubuntu-latest
steps:
- name: Clone Main Repository
@ -49,6 +84,8 @@ jobs:
with:
node-version: 20.x
- name: Build NapCat.Shell
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm i -g pnpm
pnpm i
@ -161,6 +198,10 @@ jobs:
with:
path: ./artifacts
- name: Download NapCat.Shell.Windows.OneKey.zip
run: |
curl -L -o NapCat.Shell.Windows.OneKey.zip https://github.com/NapNeko/NapCatResource/raw/main/NapCat.Shell.Windows.OneKey.zip
- name: Zip Artifacts
run: |
cd artifacts
@ -171,10 +212,10 @@ jobs:
- name: Generate release note via OpenRouter
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENROUTER_API_URL: ${{ env.OPENROUTER_API_URL }}
OPENROUTER_MODEL: ${{ env.OPENROUTER_MODEL }}
GITHUB_OWNER: "NapNeKo" # 替换成你的 repo owner
GITHUB_OWNER: "NapNeko" # 替换成你的 repo owner
GITHUB_REPO: "NapCatQQ" # 替换成你的 repo 名
run: |
set -euo pipefail
@ -199,41 +240,162 @@ jobs:
done
if [ -z "$PREV_TAG" ]; then
echo "❌ Could not find previous tag for $CURRENT_TAG, aborting."
exit 1
echo "⚠️ Could not find previous tag for $CURRENT_TAG, using first commit"
PREV_TAG=$(git rev-list --max-parents=0 HEAD | head -1)
fi
echo "Previous tag: $PREV_TAG"
# 强制拉取上一个 tag 和当前 tag
git fetch origin "refs/tags/$PREV_TAG:refs/tags/$PREV_TAG" --force
git fetch origin "refs/tags/$CURRENT_TAG:refs/tags/$CURRENT_TAG" --force
git fetch origin "refs/tags/$PREV_TAG:refs/tags/$PREV_TAG" --force || true
git fetch origin "refs/tags/$CURRENT_TAG:refs/tags/$CURRENT_TAG" --force || true
# 获取 commit title + body + 作者,保留换行
COMMITS=$(git log --pretty=format:'%h %B (%an)' "$PREV_TAG".."$CURRENT_TAG" | sed 's/$/\\n/')
# 获取 commit使用更清晰的格式
# 格式: <type>: <subject> (<hash>)
COMMITS=$(git log --pretty=format:'- %s (%h)' "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null || git log --pretty=format:'- %s (%h)' -20)
echo "Commit list from $PREV_TAG to $CURRENT_TAG:"
echo -e "$COMMITS"
echo "$COMMITS"
# 获取文件变化统计
echo "Getting file change statistics..."
FILE_STATS=$(git diff --stat "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null || echo "")
# 获取总体统计(最后一行)
SUMMARY_LINE=$(echo "$FILE_STATS" | tail -1)
echo "Summary: $SUMMARY_LINE"
# 获取每个文件的变化(去掉最后一行汇总)
# 截断过长的输出最多50个文件每行最多80字符
FILE_CHANGES=$(echo "$FILE_STATS" | head -n -1 | head -50 | cut -c1-80)
# 如果文件变化太多,进一步精简:只保留主要目录的变化
FILE_COUNT=$(echo "$FILE_STATS" | head -n -1 | wc -l)
if [ "$FILE_COUNT" -gt 50 ]; then
echo "Too many files ($FILE_COUNT), grouping by directory..."
# 按目录分组统计
DIR_STATS=$(git diff --stat "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | head -n -1 | \
sed 's/|.*//g' | \
awk -F'/' '{if(NF>1) print $1"/"$2; else print $1}' | \
sort | uniq -c | sort -rn | head -20)
FILE_CHANGES="[按目录分组统计 - 共 $FILE_COUNT 个文件变更]
$DIR_STATS"
fi
echo "File changes:"
echo "$FILE_CHANGES"
# 获取具体代码变化关键文件的diff
echo "Getting code diff for key files..."
# 定义关键目录(优先展示这些目录的变化)
KEY_DIRS="packages/napcat-core packages/napcat-onebot packages/napcat-webui-backend"
# 获取变更的关键文件列表(排除测试、配置等)
# 使用 || true 防止 grep 无匹配时返回非零退出码
KEY_FILES=$(git diff --name-only "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | \
grep -E "^packages/napcat-(core|onebot|webui-backend|shell)/" || true | \
grep -E "\.(ts|js)$" || true | \
grep -v -E "(test|spec|\.d\.ts|config)" || true | \
head -15) || true
CODE_DIFF=""
DIFF_CHAR_LIMIT=6000 # 总diff字符限制
CURRENT_CHARS=0
if [ -n "$KEY_FILES" ]; then
for file in $KEY_FILES; do
if [ "$CURRENT_CHARS" -ge "$DIFF_CHAR_LIMIT" ]; then
CODE_DIFF="$CODE_DIFF
[... 更多文件变化已截断 ...]"
break
fi
# 获取单个文件的diff限制每个文件最多50行
FILE_DIFF=$(git diff "$PREV_TAG".."$CURRENT_TAG" -- "$file" 2>/dev/null | head -50) || true
FILE_DIFF_LEN=${#FILE_DIFF}
# 如果单个文件diff超过1500字符截断
if [ "$FILE_DIFF_LEN" -gt 1500 ]; then
FILE_DIFF=$(echo "$FILE_DIFF" | head -c 1500)
FILE_DIFF="$FILE_DIFF
[... 文件 $file 变化已截断 ...]"
fi
if [ -n "$FILE_DIFF" ]; then
CODE_DIFF="$CODE_DIFF
### $file
\`\`\`diff
$FILE_DIFF
\`\`\`"
CURRENT_CHARS=$((CURRENT_CHARS + FILE_DIFF_LEN))
fi
done
fi
# 如果没有关键文件变化获取前5个变更文件的diff
if [ -z "$CODE_DIFF" ]; then
echo "No key files changed, getting top changed files..."
TOP_FILES=$(git diff --name-only "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | \
grep -E "\.(ts|js|yml|md)$" | head -5) || true
if [ -n "$TOP_FILES" ]; then
for file in $TOP_FILES; do
FILE_DIFF=$(git diff "$PREV_TAG".."$CURRENT_TAG" -- "$file" 2>/dev/null | head -30) || true
if [ -n "$FILE_DIFF" ] && [ ${#FILE_DIFF} -lt 1000 ]; then
CODE_DIFF="$CODE_DIFF
### $file
\`\`\`diff
$FILE_DIFF
\`\`\`"
fi
done
fi
fi
# 如果仍然没有代码变化,添加说明
if [ -z "$CODE_DIFF" ]; then
CODE_DIFF="[本次更新主要涉及配置文件和文档变更,无核心代码变化]"
fi
echo "Code diff preview:"
echo "$CODE_DIFF" | head -50
# 读取 prompt
PROMPT_FILE=".github/prompt/release_note_prompt.txt"
SYSTEM_PROMPT=$(<"$PROMPT_FILE")
# 构建用户内容
USER_CONTENT="当前真正的版本: $CURRENT_TAG\n提交列表:\n$COMMITS"
# 构建用户内容传递更多上下文包含文件变化和代码diff
USER_CONTENT="当前版本: $CURRENT_TAG
上一版本: $PREV_TAG
## 提交列表
$COMMITS
## 文件变化统计
$SUMMARY_LINE
## 变更文件列表
$FILE_CHANGES
## 关键代码变化
$CODE_DIFF"
# 构建请求 JSON
# 构建请求 JSON,增加 max_tokens 以获取更完整的输出
BODY=$(jq -n \
--arg system "$SYSTEM_PROMPT" \
--arg user "$USER_CONTENT" \
'{model: env.OPENROUTER_MODEL, messages:[{role:"system", content:$system},{role:"user", content:$user}], temperature:0.3, max_tokens:800}')
--arg model "$OPENROUTER_MODEL" \
'{model: $model, messages:[{role:"system", content:$system},{role:"user", content:$user}], temperature:0.2, max_tokens:1500}')
echo "=== OpenRouter request body ==="
echo "$BODY" | jq .
# 调用 OpenRouter
if RESPONSE=$(curl -s -X POST "$OPENROUTER_API_URL" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Authorization: Bearer $OPENAI_KEY" \
-H "Content-Type: application/json" \
-d "$BODY"); then
echo "=== raw response ==="
@ -250,13 +412,18 @@ jobs:
if [ -z "$RELEASE_BODY" ]; then
echo "❌ OpenRouter failed to generate release note, using default.md"
cp .github/prompt/default.md CHANGELOG.md
# 替换默认模板中的版本占位符
sed "s/{VERSION}/$CURRENT_TAG/g" .github/prompt/default.md > CHANGELOG.md
else
# 后处理:确保版本号正确,并添加比较链接
echo -e "$RELEASE_BODY" > CHANGELOG.md
# 替换可能的占位符
sed -i "s/{VERSION}/$CURRENT_TAG/g" CHANGELOG.md
sed -i "s/{PREV_VERSION}/$PREV_TAG/g" CHANGELOG.md
fi
else
echo "❌ Curl failed, using default.md"
cp .github/prompt/default.md CHANGELOG.md
sed "s/{VERSION}/$CURRENT_TAG/g" .github/prompt/default.md > CHANGELOG.md
fi
echo "=== generated release note ==="
cat CHANGELOG.md
@ -271,4 +438,5 @@ jobs:
NapCat.Shell.Windows.Node.zip
NapCat.Framework.zip
NapCat.Shell.zip
NapCat.Shell.Windows.OneKey.zip
draft: true

View File

@ -43,7 +43,7 @@ _Modern protocol-side framework implemented based on NTQQ._
**首次使用**请务必查看如下文档看使用教程
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
> 项目非盈利,涉及 对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
## Link

View File

@ -2,7 +2,11 @@ import path from 'node:path';
import fs from 'fs';
import os from 'node:os';
import { QQVersionConfigType, QQLevel } from './types';
import { RequestUtil } from './request';
import { compareSemVer } from './version';
import { getAllGitHubTags as getAllTagsFromMirror } from './mirror';
// 导出 compareSemVer 供其他模块使用
export { compareSemVer } from './version';
export async function solveProblem<T extends (...arg: any[]) => any> (func: T, ...args: Parameters<T>): Promise<ReturnType<T> | undefined> {
return new Promise<ReturnType<T> | undefined>((resolve) => {
@ -213,56 +217,19 @@ export function parseAppidFromMajor (nodeMajor: string): string | undefined {
return undefined;
}
const baseUrl = 'https://github.com/NapNeko/NapCatQQ.git/info/refs?service=git-upload-pack';
const urls = [
'https://j.1win.ggff.net/' + baseUrl,
'https://git.yylx.win/' + baseUrl,
'https://ghfile.geekertao.top/' + baseUrl,
'https://gh-proxy.net/' + baseUrl,
'https://ghm.078465.xyz/' + baseUrl,
'https://gitproxy.127731.xyz/' + baseUrl,
'https://jiashu.1win.eu.org/' + baseUrl,
baseUrl,
];
// ============== GitHub Tags 获取 ==============
// 使用 mirror 模块统一管理镜像
async function testUrl (url: string): Promise<boolean> {
try {
await PromiseTimer(RequestUtil.HttpGetText(url), 5000);
return true;
} catch {
return false;
}
}
async function findAvailableUrl (): Promise<string | null> {
for (const url of urls) {
if (await testUrl(url)) {
return url;
}
}
return null;
}
export async function getAllTags (): Promise<string[]> {
const availableUrl = await findAvailableUrl();
if (!availableUrl) {
throw new Error('No available URL for fetching tags');
}
const raw = await RequestUtil.HttpGetText(availableUrl);
return raw
.split('\n')
.map(line => {
const match = line.match(/refs\/tags\/(.+)$/);
return match ? match[1] : null;
})
.filter(tag => tag !== null && !tag!.endsWith('^{}')) as string[];
export async function getAllTags (): Promise<{ tags: string[], mirror: string; }> {
return getAllTagsFromMirror('NapNeko', 'NapCatQQ');
}
export async function getLatestTag (): Promise<string> {
const tags = await getAllTags();
const { tags } = await getAllTags();
tags.sort((a, b) => compareVersion(a, b));
// 使用 SemVer 规范排序
tags.sort((a, b) => compareSemVer(a, b));
const latest = tags.at(-1);
if (!latest) {
@ -271,22 +238,3 @@ export async function getLatestTag (): Promise<string> {
// 去掉开头的 v
return latest.replace(/^v/, '');
}
function compareVersion (a: string, b: string): number {
const normalize = (v: string) =>
v.replace(/^v/, '') // 去掉开头的 v
.split('.')
.map(n => parseInt(n) || 0);
const pa = normalize(a);
const pb = normalize(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const na = pa[i] || 0;
const nb = pb[i] || 0;
if (na !== nb) return na - nb;
}
return 0;
}

File diff suppressed because it is too large Load Diff

View File

@ -3,11 +3,11 @@ import http from 'node:http';
export class RequestUtil {
// 适用于获取服务器下发cookies时获取仅GET
static async HttpsGetCookies (url: string): Promise<{ [key: string]: string }> {
static async HttpsGetCookies (url: string): Promise<{ [key: string]: string; }> {
const client = url.startsWith('https') ? https : http;
return new Promise((resolve, reject) => {
const req = client.get(url, (res) => {
const cookies: { [key: string]: string } = {};
const cookies: { [key: string]: string; } = {};
res.on('data', () => { }); // Necessary to consume the stream
res.on('end', () => {
@ -27,7 +27,7 @@ export class RequestUtil {
});
}
private static async handleRedirect (res: http.IncomingMessage, url: string, cookies: { [key: string]: string }): Promise<{ [key: string]: string }> {
private static async handleRedirect (res: http.IncomingMessage, url: string, cookies: { [key: string]: string; }): Promise<{ [key: string]: string; }> {
if (res.statusCode === 301 || res.statusCode === 302) {
if (res.headers.location) {
const redirectUrl = new URL(res.headers.location, url);
@ -39,7 +39,7 @@ export class RequestUtil {
return cookies;
}
private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string }) {
private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string; }) {
setCookieHeaders.forEach((cookie) => {
const parts = cookie.split(';')[0]?.split('=');
if (parts) {
@ -53,9 +53,10 @@ export class RequestUtil {
}
// 请求和回复都是JSON data传原始内容 自动编码json
static async HttpGetJson<T>(url: string, method: string = 'GET', data?: any, headers: {
[key: string]: string
} = {}, isJsonRet: boolean = true, isArgJson: boolean = true): Promise<T> {
// 支持 301/302 重定向(最多 5 次)
static async HttpGetJson<T> (url: string, method: string = 'GET', data?: any, headers: {
[key: string]: string;
} = {}, isJsonRet: boolean = true, isArgJson: boolean = true, maxRedirects: number = 5): Promise<T> {
const option = new URL(url);
const protocol = url.startsWith('https://') ? https : http;
const options = {
@ -71,6 +72,20 @@ export class RequestUtil {
// },
return new Promise((resolve, reject) => {
const req = protocol.request(options, (res: http.IncomingMessage) => {
// 处理重定向
if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) {
if (maxRedirects <= 0) {
reject(new Error('Too many redirects'));
return;
}
const redirectUrl = new URL(res.headers.location, url).href;
// 递归跟随重定向
this.HttpGetJson<T>(redirectUrl, method, data, headers, isJsonRet, isArgJson, maxRedirects - 1)
.then(resolve)
.catch(reject);
return;
}
let responseBody = '';
res.on('data', (chunk: string | Buffer) => {
responseBody += chunk.toString();
@ -109,7 +124,7 @@ export class RequestUtil {
}
// 请求返回都是原始内容
static async HttpGetText (url: string, method: string = 'GET', data?: any, headers: { [key: string]: string } = {}) {
static async HttpGetText (url: string, method: string = 'GET', data?: any, headers: { [key: string]: string; } = {}) {
return this.HttpGetJson<string>(url, method, data, headers, false, false);
}
}

View File

@ -1,2 +1,118 @@
// @ts-ignore
export const napCatVersion = (typeof import.meta?.env !== 'undefined' && import.meta.env.VITE_NAPCAT_VERSION) || 'alpha';
export const napCatVersion = (typeof import.meta?.env !== 'undefined' && import.meta.env.VITE_NAPCAT_VERSION) || '1.0.0-dev';
/**
* SemVer 2.0
* 格式: 主版本号.次版本号.修订号[-][+]
* 参考: https://semver.org/lang/zh-CN/
*/
const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
export interface SemVerInfo {
valid: boolean;
normalized: string;
major: number;
minor: number;
patch: number;
prerelease: string | null;
buildmetadata: string | null;
}
/**
* SemVer 2.0
* @param version - ( v )
* @returns SemVer
*/
export function parseSemVer (version: string | undefined | null): SemVerInfo {
if (!version || typeof version !== 'string') {
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
}
const match = version.trim().match(SEMVER_REGEX);
if (match) {
const major = parseInt(match[1]!, 10);
const minor = parseInt(match[2]!, 10);
const patch = parseInt(match[3]!, 10);
const prerelease = match[4] || null;
const buildmetadata = match[5] || null;
// 构建标准化版本号(不带 v 前缀)
let normalized = `${major}.${minor}.${patch}`;
if (prerelease) normalized += `-${prerelease}`;
if (buildmetadata) normalized += `+${buildmetadata}`;
return { valid: true, normalized, major, minor, patch, prerelease, buildmetadata };
}
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
}
/**
* SemVer 2.0
* @param version -
* @returns
*/
export function isValidSemVer (version: string | undefined | null): boolean {
return parseSemVer(version).valid;
}
/**
* SemVer
* @param v1 - 1
* @param v2 - 2
* @returns -1 (v1 < v2), 0 (v1 == v2), 1 (v1 > v2)
*/
export function compareSemVer (v1: string, v2: string): -1 | 0 | 1 {
const a = parseSemVer(v1);
const b = parseSemVer(v2);
if (!a.valid || !b.valid) {
return 0;
}
// 比较主版本号
if (a.major !== b.major) return a.major > b.major ? 1 : -1;
// 比较次版本号
if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1;
// 比较修订号
if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1;
// 有先行版本号的版本优先级较低
if (a.prerelease && !b.prerelease) return -1;
if (!a.prerelease && b.prerelease) return 1;
// 两者都有先行版本号时,按字典序比较
if (a.prerelease && b.prerelease) {
const aParts = a.prerelease.split('.');
const bParts = b.prerelease.split('.');
const len = Math.max(aParts.length, bParts.length);
for (let i = 0; i < len; i++) {
const aPart = aParts[i];
const bPart = bParts[i];
if (aPart === undefined) return -1;
if (bPart === undefined) return 1;
const aNum = /^\d+$/.test(aPart) ? parseInt(aPart, 10) : NaN;
const bNum = /^\d+$/.test(bPart) ? parseInt(bPart, 10) : NaN;
// 数字 vs 数字
if (!isNaN(aNum) && !isNaN(bNum)) {
if (aNum !== bNum) return aNum > bNum ? 1 : -1;
continue;
}
// 数字优先级低于字符串
if (!isNaN(aNum)) return -1;
if (!isNaN(bNum)) return 1;
// 字符串 vs 字符串
if (aPart !== bPart) return aPart > bPart ? 1 : -1;
}
}
return 0;
}
/**
*
*/
export const napCatVersionInfo = parseSemVer(napCatVersion);

View File

@ -33,7 +33,7 @@ export class NTQQFileApi {
'http://ss.xingzhige.com/music_card/rkey',
'https://secret-service.bietiaop.com/rkeys',
],
this.context.logger
this.context.logger
);
}
@ -138,7 +138,7 @@ export class NTQQFileApi {
})).urlResult.domainUrl;
}
async uploadFile (filePath: string, elementType: ElementType = ElementType.PIC, elementSubType: number = 0) {
async uploadFile (filePath: string, elementType: ElementType = ElementType.PIC, elementSubType: number = 0, uploadGroupFile = true) {
const fileMd5 = await calculateFileMD5(filePath);
const extOrEmpty = await fileTypeFromFile(filePath).then(e => e?.ext ?? '').catch(() => '');
const ext = extOrEmpty ? `.${extOrEmpty}` : '';
@ -146,24 +146,33 @@ export class NTQQFileApi {
if (fileName.indexOf('.') === -1) {
fileName += ext;
}
const mediaPath = this.context.session.getMsgService().getRichMediaFilePathForGuild({
md5HexStr: fileMd5,
fileName,
elementType,
elementSubType,
thumbSize: 0,
needCreate: true,
downloadType: 1,
file_uuid: '',
});
await this.copyFile(filePath, mediaPath);
const fileSize = await this.getFileSize(filePath);
if (uploadGroupFile) {
const mediaPath = this.context.session.getMsgService().getRichMediaFilePathForGuild({
md5HexStr: fileMd5,
fileName,
elementType,
elementSubType,
thumbSize: 0,
needCreate: true,
downloadType: 1,
file_uuid: '',
});
await this.copyFile(filePath, mediaPath);
return {
md5: fileMd5,
fileName,
path: mediaPath,
fileSize,
ext,
};
}
return {
md5: fileMd5,
fileName,
path: mediaPath,
path: filePath,
fileSize,
ext,
};

View File

@ -471,12 +471,36 @@
"appid": 537320212,
"qua": "V1_WIN_NQ_9.9.23_42430_GW_B"
},
"9.9.23-42744": {
"9.9.25-42744": {
"appid": 537328470,
"qua": "V1_WIN_NQ_9.9.23_42744_GW_B"
},
"6.9.86-42744": {
"appid": 537328495,
"qua": "V1_MAC_NQ_6.9.85_42744_GW_B"
},
"9.9.25-42905": {
"appid": 537328521,
"qua": "V1_WIN_NQ_9.9.25_42905_GW_B"
},
"6.9.86-42905": {
"appid": 537328546,
"qua": "V1_MAC_NQ_6.9.86_42905_GW_B"
},
"3.2.22-42941": {
"appid": 537328659,
"qua": "V1_LNX_NQ_3.2.22_42941_GW_B"
},
"9.9.25-42941": {
"appid": 537328623,
"qua": "V1_WIN_NQ_9.9.25_42941_GW_B"
},
"6.9.86-42941": {
"appid": 537328648,
"qua": "V1_MAC_NQ_6.9.86_42941_GW_B"
},
"9.9.26-44175": {
"appid": 537336450,
"qua": "V1_WIN_NQ_9.9.26_44175_GW_B"
}
}

View File

@ -95,12 +95,40 @@
"send": "0A01A34",
"recv": "1D1CFF9"
},
"9.9.23-42744-x64": {
"9.9.25-42744-x64": {
"send": "0A0D104",
"recv": "1D3E7F9"
},
"6.9.85-42744-arm64": {
"send": "23DFEF0",
"recv": "095FD80"
},
"9.9.25-42905-x64": {
"send": "0A12E74",
"recv": "1D450FD"
},
"6.9.86-42905-arm64": {
"send": "2342408",
"recv": "09639B8"
},
"3.2.22-42941-x64": {
"send": "5BC1630",
"recv": "3011E00"
},
"3.2.22-42941-arm64": {
"send": "3DC90AC",
"recv": "1497A70"
},
"9.9.25-42941-x64": {
"send": "0A131D4",
"recv": "1D4547D"
},
"6.9.86-42941-arm64": {
"send": "2346108",
"recv": "09675F0"
},
"9.9.26-44175-x64": {
"send": "0A0F2EC",
"recv": "1D3AD4D"
}
}

View File

@ -607,12 +607,40 @@
"send": "2C9A4A0",
"recv": "2C9DA20"
},
"9.9.23-42744-x64": {
"9.9.25-42744-x64": {
"send": "2CD8E40",
"recv": "2CDC3C0"
},
"6.9.86-42744-arm64": {
"send": "3DCC840",
"recv": "3DCF150"
},
"9.9.25-42905-x64": {
"send": "2CE46A0",
"recv": "2CE7C20"
},
"6.9.86-42905-arm64": {
"send": "3DD6098",
"recv": "3DD89A8"
},
"3.2.22-42941-x64": {
"send": "A8AD8A0",
"recv": "A8B1320"
},
"9.9.25-42941-x64": {
"send": "2CE4DA0",
"recv": "2CE8320"
},
"3.2.22-42941-arm64": {
"send": "6BC95E8",
"recv": "6BCCF78"
},
"6.9.86-42941-arm64": {
"send": "3DDDAD0",
"recv": "3DE03E0"
},
"9.9.26-44175-x64": {
"send": "2CD84A0",
"recv": "2CDBA20"
}
}

View File

@ -17,7 +17,6 @@ import {
WrapperSessionInitConfig,
} from '@/napcat-core/wrapper';
import { LogLevel, LogWrapper } from '@/napcat-core/helper/log';
import { NodeIKernelLoginService } from '@/napcat-core/services';
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
import { NapCatPathWrapper } from 'napcat-common/src/path';
import path from 'node:path';
@ -126,7 +125,7 @@ export class NapCatCore {
container.bind(TypedEventEmitter).toConstantValue(this.event);
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
container.bind(ServiceClass).toSelf();
console.log(`Registering service handler for: ${serviceName}`);
//console.log(`Registering service handler for: ${serviceName}`);
this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => {
const serviceInstance = container.get(ServiceClass);
return serviceInstance.handler(seq, hex_data);
@ -278,7 +277,6 @@ export interface InstanceContext {
readonly wrapper: WrapperNodeApi;
readonly session: NodeIQQNTWrapperSession;
readonly logger: LogWrapper;
readonly loginService: NodeIKernelLoginService;
readonly basicInfoWrapper: QQBasicInfoWrapper;
readonly pathWrapper: NapCatPathWrapper;
readonly packetHandler: NativePacketHandler;

View File

@ -15,8 +15,12 @@ import { NapProtoMsg } from 'napcat-protobuf';
import * as proto from '@/napcat-core/packet/transformer/proto';
import * as trans from '@/napcat-core/packet/transformer';
import fs from 'fs';
import path from 'path';
import { NapCoreContext } from '@/napcat-core/packet/context/napCoreContext';
import { PacketClientContext } from '@/napcat-core/packet/context/clientContext';
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
import { defaultVideoThumbB64 } from '@/napcat-core/helper/ffmpeg/video';
import { calculateFileMD5 } from 'napcat-common/src/file';
export const BlockSize = 1024 * 1024;
@ -105,13 +109,89 @@ export class PacketHighwayContext {
if (+(video.fileSize ?? 0) > 1024 * 1024 * 100) {
throw new Error(`[Highway] 视频文件过大: ${(+(video.fileSize ?? 0) / (1024 * 1024)).toFixed(2)} MB > 100 MB请使用文件上传`);
}
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
await this.uploadGroupVideo(+peer.peerUid, video);
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
await this.uploadC2CVideo(peer.peerUid, video);
} else {
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
// 如果缺少视频缩略图,自动生成一个
let tempThumbPath: string | null = null;
let thumbExists = false;
if (video.thumbPath) {
try {
await fs.promises.access(video.thumbPath, fs.constants.F_OK);
thumbExists = true;
} catch {
thumbExists = false;
}
}
if (!video.thumbPath || !thumbExists) {
tempThumbPath = await this.ensureVideoThumb(video);
}
try {
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
await this.uploadGroupVideo(+peer.peerUid, video);
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
await this.uploadC2CVideo(peer.peerUid, video);
} else {
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
}
} finally {
// 清理临时生成的缩略图文件
if (tempThumbPath) {
const thumbToClean = tempThumbPath;
fs.promises.unlink(thumbToClean)
.then(() => this.logger.debug(`[Highway] Cleaned up temp thumbnail: ${thumbToClean}`))
.catch((e) => {
// 文件不存在时忽略错误
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
this.logger.warn(`[Highway] Failed to clean up temp thumbnail: ${thumbToClean}, reason: ${e instanceof Error ? e.message : e}`);
}
});
}
}
}
/**
*
* @returns
*/
private async ensureVideoThumb (video: PacketMsgVideoElement): Promise<string> {
if (!video.filePath) {
throw new Error('video.filePath is empty, cannot generate thumbnail');
}
// 生成缩略图路径
const videoDir = path.dirname(video.filePath);
const videoBasename = path.basename(video.filePath, path.extname(video.filePath));
const thumbPath = path.join(videoDir, `${videoBasename}_thumb.png`);
this.logger.debug(`[Highway] Video thumb missing, generating at: ${thumbPath}`);
try {
// 尝试使用 FFmpeg 提取视频缩略图
await FFmpegService.extractThumbnail(video.filePath, thumbPath);
try {
await fs.promises.access(thumbPath, fs.constants.F_OK);
this.logger.debug('[Highway] Video thumbnail generated successfully using FFmpeg');
} catch {
throw new Error('FFmpeg failed to generate thumbnail');
}
} catch (e) {
// FFmpeg 失败时(包括未初始化的情况)使用默认缩略图
this.logger.warn(`[Highway] Failed to extract thumbnail, using default. Reason: ${e instanceof Error ? e.message : e}`);
await fs.promises.writeFile(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
}
// 更新视频元素的缩略图信息
video.thumbPath = thumbPath;
const thumbStat = await fs.promises.stat(thumbPath);
video.thumbSize = thumbStat.size;
video.thumbMd5 = await calculateFileMD5(thumbPath);
// 默认缩略图尺寸(与 defaultVideoThumbB64 匹配的尺寸)
if (!video.thumbWidth) video.thumbWidth = 240;
if (!video.thumbHeight) video.thumbHeight = 383;
this.logger.debug(`[Highway] Video thumb info set: path=${thumbPath}, size=${video.thumbSize}, md5=${video.thumbMd5}`);
return thumbPath;
}
async uploadPtt (peer: Peer, ptt: PacketMsgPttElement): Promise<void> {

View File

@ -14,7 +14,7 @@ export class PacketMsgBuilder {
buildFakeMsg (selfUid: string, element: PacketMsg[]): NapProtoEncodeStructType<typeof PushMsgBody>[] {
return element.map((node): NapProtoEncodeStructType<typeof PushMsgBody> => {
const avatar = `https://q.qlogo.cn/headimg_dl?dst_uin=${node.senderUin}&spec=640&img_type=jpg`;
const avatar = `https://q.qlogo.cn/headimg_dl?dst_uin=${node.senderUin}&spec=0&img_type=jpg`;
const msgContent = node.msg.reduceRight((acc: undefined | Uint8Array, msg: IPacketMsgElement<PacketSendMsgElement>) => {
return acc ?? msg.buildContent();
}, undefined);

View File

@ -510,15 +510,15 @@ export class PacketMsgPttElement extends IPacketMsgElement<SendPttElement> {
}
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
return [];
// if (!this.msgInfo) return [];
// return [{
// commonElem: {
// serviceType: 48,
// pbElem: new NapProtoMsg(MsgInfo).encode(this.msgInfo),
// businessType: 22,
// }
// }];
//return [];
if (!this.msgInfo) return [];
return [{
commonElem: {
serviceType: 48,
pbElem: new NapProtoMsg(MsgInfo).encode(this.msgInfo),
businessType: 22,
}
}];
}
override toPreview (): string {

View File

@ -34,8 +34,9 @@ export async function NCoreInitFramework (
});
const pathWrapper = new NapCatPathWrapper();
await applyPendingUpdates(pathWrapper);
const logger = new LogWrapper(pathWrapper.logsPath);
await applyPendingUpdates(pathWrapper, logger);
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
@ -72,14 +73,17 @@ export async function NCoreInitFramework (
// 过早进入会导致addKernelMsgListener等Listener添加失败
// await sleep(2500);
// 初始化 NapCatFramework
const loaderObject = new NapCatFramework(wrapper, session, logger, loginService, selfInfo, basicInfoWrapper, pathWrapper, nativePacketHandler);
const loaderObject = new NapCatFramework(wrapper, session, logger, selfInfo, basicInfoWrapper, pathWrapper, nativePacketHandler);
await loaderObject.core.initCore();
// 启动WebUi
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework);
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
// 初始化LLNC的Onebot实现
await new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper).InitOneBot();
const oneBotAdapter = new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper);
// 注册到 WebUiDataRuntime供调试功能使用
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
await oneBotAdapter.InitOneBot();
}
export class NapCatFramework {
@ -90,7 +94,6 @@ export class NapCatFramework {
wrapper: WrapperNodeApi,
session: NodeIQQNTWrapperSession,
logger: LogWrapper,
loginService: NodeIKernelLoginService,
selfInfo: SelfInfo,
basicInfoWrapper: QQBasicInfoWrapper,
pathWrapper: NapCatPathWrapper,
@ -102,7 +105,6 @@ export class NapCatFramework {
wrapper,
session,
logger,
loginService,
basicInfoWrapper,
pathWrapper,
};

View File

@ -100,7 +100,7 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
// 3. 定义协议回退逻辑函数
const protocolFallbackLogic = async (resId: string) => {
const ob = (await this.obContext.apis.MsgApi.parseMessageV2(createFakeForwardMsg(resId)))?.arrayMsg;
const ob = (await this.obContext.apis.MsgApi.parseMessageV2(createFakeForwardMsg(resId), true))?.arrayMsg;
if (ob) {
return {
messages: (ob?.message?.[0] as OB11MessageForward)?.data?.content,
@ -122,7 +122,7 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
if (rootMsg) {
// 5. 获取消息内容
const data = await this.core.apis.MsgApi.getMsgsByMsgId(rootMsg.Peer, [rootMsg.MsgId]);
const data = await this.core.apis.MsgApi.getMsgHistory(rootMsg.Peer, rootMsg.MsgId, 1);//getMsgsIncludeSelf
if (data && data.result === 0 && data.msgList.length > 0) {
const singleMsg = data.msgList[0];

View File

@ -12,6 +12,7 @@ const SchemaData = Type.Object({
name: Type.String(),
folder: Type.Optional(Type.String()),
folder_id: Type.Optional(Type.String()), // 临时扩展
upload_file: Type.Boolean({ default: true }),
});
type Payload = Static<typeof SchemaData>;
@ -41,7 +42,7 @@ export default class GoCQHTTPUploadGroupFile extends OneBotAction<Payload, Uploa
peer,
deleteAfterSentFiles: [],
};
const sendFileEle = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id);
const sendFileEle = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id, payload.upload_file);
msgContext.deleteAfterSentFiles.push(downloadResult.path);
const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles);

View File

@ -11,6 +11,7 @@ const SchemaData = Type.Object({
user_id: Type.Union([Type.Number(), Type.String()]),
file: Type.String(),
name: Type.String(),
upload_file: Type.Boolean({ default: true }),
});
type Payload = Static<typeof SchemaData>;
@ -51,7 +52,7 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, Upl
}, ContextMode.Private),
deleteAfterSentFiles: [],
};
const sendFileEle: SendFileElement = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name);
const sendFileEle: SendFileElement = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, '', payload.upload_file);
msgContext.deleteAfterSentFiles.push(downloadResult.path);
const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(await this.getPeer(payload), [sendFileEle], msgContext.deleteAfterSentFiles);

View File

@ -317,11 +317,11 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
const MixElement = sendElements.filter(
element =>
element.elementType !== ElementType.FILE && element.elementType !== ElementType.VIDEO && element.elementType !== ElementType.ARK
element.elementType !== ElementType.FILE && element.elementType !== ElementType.VIDEO && element.elementType !== ElementType.ARK && element.elementType !== ElementType.PTT
);
const SingleElement = sendElements.filter(
element =>
element.elementType === ElementType.FILE || element.elementType === ElementType.VIDEO || element.elementType === ElementType.ARK
element.elementType === ElementType.FILE || element.elementType === ElementType.VIDEO || element.elementType === ElementType.ARK || element.elementType === ElementType.PTT
).map(e => [e]);
const AllElement: SendMessageElement[][] = [MixElement, ...SingleElement].filter(e => e !== undefined && e.length !== 0);

View File

@ -19,16 +19,18 @@ export class OneBotFileApi {
this.core = core;
}
async createValidSendFileElement (context: SendMessageContext, filePath: string, fileName: string = '', folderId: string = ''): Promise<SendFileElement> {
async createValidSendFileElement (context: SendMessageContext, filePath: string, fileName: string = '', folderId: string = '', uploadGroupFile: boolean = false): Promise<SendFileElement> {
const {
fileName: _fileName,
path,
fileSize,
} = await this.core.apis.FileApi.uploadFile(filePath, ElementType.FILE);
} = await this.core.apis.FileApi.uploadFile(filePath, ElementType.FILE, 0, uploadGroupFile);
if (fileSize === 0) {
throw new Error('文件异常大小为0');
}
context.deleteAfterSentFiles.push(path);
if (uploadGroupFile) {
context.deleteAfterSentFiles.push(path);
}
return {
elementType: ElementType.FILE,
elementId: '',

View File

@ -19,6 +19,7 @@ import { OB11GroupCardEvent } from '@/napcat-onebot/event/notice/OB11GroupCardEv
import { OB11GroupPokeEvent } from '@/napcat-onebot/event/notice/OB11PokeEvent';
import { OB11GroupEssenceEvent } from '@/napcat-onebot/event/notice/OB11GroupEssenceEvent';
import { OB11GroupTitleEvent } from '@/napcat-onebot/event/notice/OB11GroupTitleEvent';
import { OB11GroupGrayTipEvent } from '@/napcat-onebot/event/notice/OB11GroupGrayTipEvent';
import { OB11GroupUploadNoticeEvent } from '../event/notice/OB11GroupUploadNoticeEvent';
import { OB11GroupNameEvent } from '../event/notice/OB11GroupNameEvent';
import { FileNapCatOneBotUUID } from 'napcat-common/src/file-uuid';
@ -174,7 +175,6 @@ export class OneBotGroupApi {
async registerParseGroupReactEventByCore () {
this.core.event.on('event:emoji_like', async (data) => {
console.log('Received emoji_like event from core:', data);
const event = await this.createGroupEmojiLikeEvent(
data.groupId,
data.senderUin,
@ -207,15 +207,24 @@ export class OneBotGroupApi {
}
return undefined;
}
async parseOtherJsonEvent (msg: RawMessage, jsonStr: string, context: InstanceContext) {
const json = JSON.parse(jsonStr);
const type = json.items[json.items.length - 1]?.txt;
async parseOtherJsonEvent (msg: RawMessage, jsonGrayTipElement: GrayTipElement['jsonGrayTipElement'], context: InstanceContext) {
let json: { items?: { txt?: string; param?: string[] }[] };
try {
json = JSON.parse(jsonGrayTipElement.jsonStr);
} catch (e) {
context.logger.logWarn('灰条消息JSON解析失败', jsonGrayTipElement.jsonStr, e);
return undefined;
}
const type = json.items?.[json.items.length - 1]?.txt;
await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid);
if (type === '头衔') {
const memberUin = json.items[1].param[0];
const title = json.items[3].txt;
const memberUin = json.items?.[1]?.param?.[0];
const title = json.items?.[3]?.txt;
context.logger.logDebug('收到群成员新头衔消息', json);
if (memberUin == null || title == null) {
context.logger.logWarn('收到格式异常的群成员新头衔灰条消息', json);
return undefined;
}
return new OB11GroupTitleEvent(
this.core,
+msg.peerUid,
@ -226,6 +235,27 @@ export class OneBotGroupApi {
context.logger.logDebug('收到机器人被踢消息', json);
} else {
context.logger.logWarn('收到未知的灰条消息', json);
// 如果有真实发送者非0生成事件上报可用于检测和撤回伪造灰条
const senderUin = Number(msg.senderUin) || 0;
if (senderUin !== 0) {
const peer = { chatType: ChatType.KCHATTYPEGROUP, guildId: '', peerUid: msg.peerUid };
const messageId = MessageUnique.createUniqueMsgId(peer, msg.msgId);
return new OB11GroupGrayTipEvent(
this.core,
+msg.peerUin,
senderUin,
messageId,
jsonGrayTipElement.busiId,
jsonGrayTipElement.jsonStr,
{
msgSeq: msg.msgSeq,
msgTime: msg.msgTime,
msgId: msg.msgId,
json,
}
);
}
}
return undefined;
}
@ -377,7 +407,7 @@ export class OneBotGroupApi {
return await this.parse51TypeEvent(msg, grayTipElement);
} else {
console.log('Unknown JSON event:', grayTipElement.jsonGrayTipElement, JSON.stringify(grayTipElement));
return await this.parseOtherJsonEvent(msg, grayTipElement.jsonGrayTipElement.jsonStr, this.core.context);
return await this.parseOtherJsonEvent(msg, grayTipElement.jsonGrayTipElement, this.core.context);
}
}
return undefined;

View File

@ -749,26 +749,31 @@ export class OneBotMsgApi {
[OB11MessageDataType.music]: async ({ data }, context) => {
// 保留, 直到...找到更好的解决方案
const supportedPlatforms = ['qq', '163', 'kugou', 'kuwo', 'migu'];
const supportedPlatformsWithCustom = [...supportedPlatforms, 'custom'];
// 验证音乐类型
if (data.id !== undefined) {
if (!['qq', '163', 'kugou', 'kuwo', 'migu'].includes(data.type)) {
this.core.context.logger.logError('音乐卡片type错误, 只支持qq、163、kugou、kuwo、migu当前type:', data.type);
if (!supportedPlatforms.includes(data.type)) {
this.core.context.logger.logError(`[音乐卡片] type参数错误: "${data.type}",仅支持: ${supportedPlatforms.join('、')}`);
return undefined;
}
} else {
if (!['qq', '163', 'kugou', 'kuwo', 'migu', 'custom'].includes(data.type)) {
this.core.context.logger.logError('音乐卡片type错误, 只支持qq、163、kugou、kuwo、migu、custom当前type:', data.type);
if (!supportedPlatformsWithCustom.includes(data.type)) {
this.core.context.logger.logError(`[音乐卡片] type参数错误: "${data.type}",仅支持: ${supportedPlatformsWithCustom.join('、')}`);
return undefined;
}
if (!data.url) {
this.core.context.logger.logError('自定义音卡缺少参数url');
this.core.context.logger.logError('[音乐卡片] 自定义音缺少必需参数: url');
return undefined;
}
if (!data.image) {
this.core.context.logger.logError('自定义音卡缺少参数image');
this.core.context.logger.logError('[音乐卡片] 自定义音缺少必需参数: image');
return undefined;
}
}
// 构建请求数据
let postData: IdMusicSignPostData | CustomMusicSignPostData;
if (data.id === undefined && data.content) {
const { content, ...others } = data;
@ -776,11 +781,14 @@ export class OneBotMsgApi {
} else {
postData = data;
}
// 获取签名服务地址
let signUrl = this.obContext.configLoader.configData.musicSignUrl;
if (!signUrl) {
signUrl = 'https://ss.xingzhige.com/music_card/card';// 感谢思思!已获思思许可 其余地方使用请自行询问
// throw Error('音乐消息签名地址未配置');
}
// 请求签名服务
try {
const musicJson = await RequestUtil.HttpGetJson<string>(signUrl, 'POST', postData);
return this.ob11ToRawConverters.json({
@ -788,9 +796,16 @@ export class OneBotMsgApi {
type: OB11MessageDataType.json,
}, context);
} catch (e) {
this.core.context.logger.logError('生成音乐消息失败', e);
const errorMessage = e instanceof Error ? e.message : String(e);
this.core.context.logger.logError(
'[音乐卡片签名失败] 签名服务请求出错!\n' +
` ├─ 音乐类型: ${data.type}\n` +
` ├─ 音乐ID: ${data.id ?? '自定义'}\n` +
` ├─ 错误信息: ${errorMessage}\n` +
' └─ 提示: 请检查网络连接,或尝试在配置中更换其他音乐签名服务地址(musicSignUrl)'
);
return undefined;
}
return undefined;
},
[OB11MessageDataType.node]: async () => undefined,
@ -969,8 +984,20 @@ export class OneBotMsgApi {
disableGetUrl: boolean = false,
quick_reply: boolean = false
) {
if (msg.senderUin === '0' || msg.senderUin === '') return;
if (msg.peerUin === '0' || msg.peerUin === '') return;
if ((msg.senderUin === '0' || msg.senderUin === '')) {
if (msg.senderUid && msg.senderUid !== '' && msg.senderUid !== '0') {
msg.senderUin = await this.core.apis.UserApi.getUinByUidV2(msg.senderUid);
} else {
return undefined;
}
}
if (msg.peerUin === '0' || msg.peerUin === '') {
if (msg.peerUid && msg.peerUid !== '' && msg.peerUid !== '0') {
msg.peerUin = await this.core.apis.UserApi.getUinByUidV2(msg.peerUid);
} else {
return undefined;
}
}
const resMsg = this.initializeMessage(msg);

View File

@ -0,0 +1,35 @@
import { OB11BaseNoticeEvent } from './OB11BaseNoticeEvent';
import { NapCatCore } from 'napcat-core';
/**
*
* 便
*/
export class OB11GroupGrayTipEvent extends OB11BaseNoticeEvent {
notice_type = 'notify';
sub_type = 'gray_tip';
group_id: number;
user_id: number; // 真实发送者QQ如果是伪造的灰条这就是攻击者
message_id: number; // 消息ID可用于撤回
busi_id: string; // 业务ID
content: string; // 灰条内容JSON字符串
raw_info: unknown; // 原始信息
constructor (
core: NapCatCore,
groupId: number,
userId: number,
messageId: number,
busiId: string,
content: string,
rawInfo: unknown
) {
super(core);
this.group_id = groupId;
this.user_id = userId;
this.message_id = messageId;
this.busi_id = busiId;
this.content = content;
this.raw_info = rawInfo;
}
}

View File

@ -1,12 +1,22 @@
import type { createActionMap } from 'napcat-onebot/action';
import { EventType } from 'napcat-onebot/event/OneBotEvent';
import type { PluginModule } from 'napcat-onebot/network/plugin';
/**
* napcat 使 @/napcat...使 napcat...
* @/napcat... napcat
*/
// action 作为参数传递时请用这个
let actionMap: ReturnType<typeof createActionMap> | undefined = undefined;
const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => {
console.log('[Plugin: example] 插件已初始化');
actionMap = _actions;
};
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, actions, instance) => {
if (event.post_type === EventType.MESSAGE && event.raw_message.includes('ping')) {
await actions.get('send_group_msg')?.handle({ group_id: String(event.group_id), message: 'pong' }, adapter, instance.config);
}
};
export { plugin_init, plugin_onmessage };
export { plugin_init, plugin_onmessage, actionMap };

View File

@ -9,7 +9,7 @@ export default defineConfig({
resolve: {
conditions: ['node', 'default'],
alias: {
'@/napcat-core': resolve(__dirname, '../core'),
'@/napcat-core': resolve(__dirname, '../napcat-core'),
'@': resolve(__dirname, '../'),
},
},

View File

@ -1,5 +1,5 @@
@echo off
chcp 65001
chcp 65001 >nul
set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json
set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js
set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll
@ -27,6 +27,6 @@ if not exist "%QQpath%" (
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %1
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
pause
pause

View File

@ -1,5 +1,5 @@
@echo off
chcp 65001
chcp 65001 >nul
set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json
set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js
set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll
@ -26,8 +26,9 @@ if not exist "%QQpath%" (
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %1
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
REM "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" 123456
REM Optional: -q <QQ_NUMBER> for quick login, omit for QR code login
REM Example: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456
pause
pause

View File

@ -1,11 +1,11 @@
@echo off
chcp 65001
chcp 65001 >nul
net session >nul 2>&1
if %errorLevel% == 0 (
if %ERRORLEVEL% == 0 (
echo Administrator mode detected.
) else (
echo Please run this script in administrator mode.
powershell -Command "Start-Process 'cmd.exe' -ArgumentList '/c cd /d \"%cd%\" && \"%~f0\" %1' -Verb runAs"
powershell -Command "Start-Process 'cmd.exe' -ArgumentList '/c cd /d \"%cd%\" && \"%~f0\" %*' -Verb runAs"
exit
)
@ -35,6 +35,7 @@ if not exist "%QQPath%" (
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %1
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
REM "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" 123456
REM Optional: -q <QQ_NUMBER> for quick login, omit for QR code login
REM Example: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456

View File

@ -1,11 +1,11 @@
@echo off
chcp 65001
chcp 65001 >nul
net session >nul 2>&1
if %errorLevel% == 0 (
if %ERRORLEVEL% == 0 (
echo Administrator mode detected.
) else (
echo Please run this script in administrator mode.
powershell -Command "Start-Process 'wt.exe' -ArgumentList 'cmd /c cd /d \"%cd%\" && \"%~f0\" %1' -Verb runAs"
powershell -Command "Start-Process 'wt.exe' -ArgumentList 'cmd /c cd /d \"%cd%\" && \"%~f0\" %*' -Verb runAs"
exit
)
@ -36,4 +36,4 @@ if not exist "%QQPath%" (
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %1
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*

View File

@ -1,4 +1,7 @@
@echo off
REM ./launcher.bat 123456
REM ./launcher-win10.bat 123456
REM 带有REM的为注释 删掉你需要的系统的那行REM这三个单词 修改QQ本脚本启动即可
REM 快速登录示例脚本
REM -q 参数是可选的,不传则使用二维码登录
REM
REM 使用方法(删掉对应系统那行的 REM
REM ./launcher.bat -q 123456
REM ./launcher-win10.bat -q 123456

View File

@ -319,7 +319,7 @@ export async function NCoreInitShell () {
const pathWrapper = new NapCatPathWrapper();
const logger = new LogWrapper(pathWrapper.logsPath);
handleUncaughtExceptions(logger);
await applyPendingUpdates(pathWrapper);
await applyPendingUpdates(pathWrapper, logger);
// 初始化 FFmpeg 服务
await FFmpegService.init(pathWrapper.binaryPath, logger);
@ -418,7 +418,6 @@ export async function NCoreInitShell () {
wrapper,
session,
logger,
loginService,
selfInfo,
basicInfoWrapper,
pathWrapper,
@ -434,7 +433,6 @@ export class NapCatShell {
wrapper: WrapperNodeApi,
session: NodeIQQNTWrapperSession,
logger: LogWrapper,
loginService: NodeIKernelLoginService,
selfInfo: SelfInfo,
basicInfoWrapper: QQBasicInfoWrapper,
pathWrapper: NapCatPathWrapper,
@ -446,7 +444,6 @@ export class NapCatShell {
wrapper,
session,
logger,
loginService,
basicInfoWrapper,
pathWrapper,
};
@ -455,7 +452,11 @@ export class NapCatShell {
async InitNapCat () {
await this.core.initCore();
new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper).InitOneBot()
const oneBotAdapter = new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper);
// 注册到 WebUiDataRuntime供调试功能使用
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
oneBotAdapter.InitOneBot()
.catch(e => this.context.logger.logError('初始化OneBot失败', e));
}
}

View File

@ -27,6 +27,7 @@ const ShellBaseConfigPlugin: PluginOption[] = [
targets: [
{ src: '../napcat-native/', dest: 'dist/native', flatten: false },
{ src: '../napcat-webui-frontend/dist/', dest: 'dist/static/', flatten: false },
{ src: '../napcat-webui-backend/src/assets/sw_template.js', dest: 'dist/static/' },
{ src: '../napcat-core/external/napcat.json', dest: 'dist/config/' },
{ src: '../../package.json', dest: 'dist' },
{ src: '../napcat-shell-loader', dest: 'dist' },

View File

@ -6,8 +6,49 @@ import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* SemVer 2.0 正则表达式
* 格式: 主版本号.次版本号.修订号[-先行版本号][+版本编译信息]
* 参考: https://semver.org/lang/zh-CN/
*/
const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
/**
* Validate version format according to SemVer 2.0 specification
* @param {string} version - The version string to validate (with or without 'v' prefix)
* @returns {{ valid: boolean, normalized: string, major: number, minor: number, patch: number, prerelease: string|null, buildmetadata: string|null }}
*/
function validateVersion (version) {
if (!version || typeof version !== 'string') {
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
}
const match = version.trim().match(SEMVER_REGEX);
if (match) {
const major = parseInt(match[1], 10);
const minor = parseInt(match[2], 10);
const patch = parseInt(match[3], 10);
const prerelease = match[4] || null;
const buildmetadata = match[5] || null;
// 构建标准化版本号(不带 v 前缀)
let normalized = `${major}.${minor}.${patch}`;
if (prerelease) normalized += `-${prerelease}`;
if (buildmetadata) normalized += `+${buildmetadata}`;
return { valid: true, normalized, major, minor, patch, prerelease, buildmetadata };
}
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
}
/**
* NapCat Vite Plugin: fetches latest GitHub tag (not release) and injects into import.meta.env
*
* 版本号来源优先级:
* 1. 环境变量 NAPCAT_VERSION (用于 CI 构建)
* 2. 缓存的 GitHub tag
* 3. GitHub API 获取最新 tag
* 4. 兆底版本号: 1.0.0-dev
*/
export default function vitePluginNapcatVersion () {
const pluginDir = path.resolve(__dirname, 'dist');
@ -16,6 +57,9 @@ export default function vitePluginNapcatVersion () {
const repo = 'NapCatQQ';
const maxAgeMs = 24 * 60 * 60 * 1000; // cache 1 day
const githubToken = process.env.GITHUB_TOKEN;
// CI 构建时可通过环境变量直接指定版本号
const envVersion = process.env.NAPCAT_VERSION;
const fallbackVersion = '1.0.0-dev';
fs.mkdirSync(pluginDir, { recursive: true });
@ -58,7 +102,14 @@ export default function vitePluginNapcatVersion () {
try {
const json = JSON.parse(data);
if (Array.isArray(json) && json[0]?.name) {
resolve(json[0].name.replace(/^v/, ''));
const tagName = json[0].name;
const { valid, normalized } = validateVersion(tagName);
if (valid) {
resolve(normalized);
} else {
console.warn(`[vite-plugin-napcat-version] Invalid tag format: ${tagName}, expected vX.X.X`);
reject(new Error(`Invalid tag format: ${tagName}, expected vX.X.X`));
}
} else reject(new Error('Invalid GitHub tag response'));
} catch (e) {
reject(e);
@ -71,6 +122,17 @@ export default function vitePluginNapcatVersion () {
}
async function getVersion () {
// 优先使用环境变量指定的版本号 (CI 构建)
if (envVersion) {
const { valid, normalized } = validateVersion(envVersion);
if (valid) {
console.log(`[vite-plugin-napcat-version] Using version from NAPCAT_VERSION env: ${normalized}`);
return normalized;
} else {
console.warn(`[vite-plugin-napcat-version] Invalid NAPCAT_VERSION format: ${envVersion}, falling back to fetch`);
}
}
const cached = readCache();
if (cached) return cached;
try {
@ -79,7 +141,7 @@ export default function vitePluginNapcatVersion () {
return tag;
} catch (e) {
console.warn('[vite-plugin-napcat-version] Failed to fetch tag:', e.message);
return cached ?? '0.0.0';
return cached ?? fallbackVersion;
}
}
@ -115,3 +177,6 @@ export default function vitePluginNapcatVersion () {
},
};
}
// Export validateVersion for external use
export { validateVersion };

View File

@ -22,6 +22,14 @@ import { existsSync, readFileSync } from 'node:fs'; // 引入multer用于错误
import { ILogWrapper } from 'napcat-common/src/log-interface';
import { ISubscription } from 'napcat-common/src/subscription-interface';
import { IStatusHelperSubscription } from '@/napcat-common/src/status-interface';
import { handleDebugWebSocket } from '@/napcat-webui-backend/src/api/Debug';
import compression from 'compression';
import { napCatVersion } from 'napcat-common/src/version';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 实例化Express
const app = express();
/**
@ -34,6 +42,7 @@ export let WebUiConfig: WebUiConfigWrapper;
export let webUiPathWrapper: NapCatPathWrapper;
export let logSubscription: ISubscription;
export let statusHelperSubscription: IStatusHelperSubscription;
export let webUiLogger: ILogWrapper | null = null;
const MAX_PORT_TRY = 100;
export let webUiRuntimePort = 6099;
@ -92,6 +101,7 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
webUiPathWrapper = pathWrapper;
logSubscription = Subscription;
statusHelperSubscription = statusSubscription;
webUiLogger = logger;
WebUiConfig = new WebUiConfigWrapper();
let config = await WebUiConfig.GetWebUIConfig();
@ -142,39 +152,118 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
// ------------注册中间件------------
// 使用express的json中间件
app.use(express.json());
// 启用gzip压缩对所有响应启用阈值1KB
app.use(compression({
level: 6, // 压缩级别 1-96 是性能和压缩率的平衡点
threshold: 1024, // 只压缩大于 1KB 的响应
filter: (req, res) => {
// 不压缩 SSE 和 WebSocket 升级请求
if (req.headers['accept'] === 'text/event-stream') {
return false;
}
// 使用默认过滤器
return compression.filter(req, res);
},
}));
// CORS中间件
// TODO:
app.use(cors);
// 如果是webui字体文件挂载字体文件
app.use('/webui/fonts/AaCute.woff', async (_req, res, next) => {
const isFontExist = await WebUiConfig.CheckWebUIFontExist();
if (isFontExist) {
res.sendFile(WebUiConfig.GetWebUIFontPath());
// 自定义字体文件路由 - 返回用户上传的字体文件
app.use('/webui/fonts/CustomFont.woff', async (_req, res) => {
const fontPath = await WebUiConfig.GetWebUIFontPath();
if (fontPath) {
res.sendFile(fontPath);
} else {
next();
res.status(404).send('Custom font not found');
}
});
// 如果是自定义色彩构建一个css文件
app.use('/files/theme.css', async (_req, res) => {
const colors = await WebUiConfig.GetTheme();
const theme = await WebUiConfig.GetTheme();
const fontMode = theme.fontMode || 'system';
let css = ':root, .light, [data-theme="light"] {';
for (const key in colors.light) {
css += `${key}: ${colors.light[key]};`;
let css = '';
// 生成字体 @font-face
if (fontMode === 'aacute') {
css += `
@font-face {
font-family: 'Aa偷吃可爱长大的';
src: url('/webui/fonts/AaCute.woff') format('woff');
font-display: swap;
}
`;
} else if (fontMode === 'custom') {
css += `
@font-face {
font-family: 'CustomFont';
src: url('/webui/fonts/CustomFont.woff') format('woff');
font-display: swap;
}
`;
}
// 生成颜色主题和字体变量
css += ':root, .light, [data-theme="light"] {';
for (const key in theme.light) {
css += `${key}: ${theme.light[key]};`;
}
// 添加字体变量
if (fontMode === 'aacute') {
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
} else if (fontMode === 'custom') {
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
} else {
css += '--font-family-base: var(--font-family-fallbacks) !important;';
}
css += '}';
css += '.dark, [data-theme="dark"] {';
for (const key in colors.dark) {
css += `${key}: ${colors.dark[key]};`;
for (const key in theme.dark) {
css += `${key}: ${theme.dark[key]};`;
}
// 添加字体变量
if (fontMode === 'aacute') {
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
} else if (fontMode === 'custom') {
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
} else {
css += '--font-family-base: var(--font-family-fallbacks) !important;';
}
css += '}';
res.send(css);
});
// 动态生成 sw.js
app.get('/webui/sw.js', async (_req, res) => {
try {
// 读取模板文件
let templatePath = resolve(__dirname, 'static', 'sw_template.js');
if (!existsSync(templatePath)) {
templatePath = resolve(__dirname, 'src', 'assets', 'sw_template.js');
}
let swContent = readFileSync(templatePath, 'utf-8');
// 替换版本号
// 使用 napCatVersion如果为 alpha 则尝试加上时间戳或其他标识以避免缓存冲突,或者直接使用
// 用户要求控制 sw.js 版本napCatVersion 是核心控制点
swContent = swContent.replace('{{VERSION}}', napCatVersion);
res.header('Content-Type', 'application/javascript');
res.header('Service-Worker-Allowed', '/webui/');
res.header('Cache-Control', 'no-cache, no-store, must-revalidate');
res.send(swContent);
} catch (error) {
console.error('[NapCat] [WebUi] Error generating sw.js', error);
res.status(500).send('Error generating service worker');
}
});
// ------------中间件结束------------
// ------------挂载路由------------
@ -187,7 +276,15 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
const isHttps = !!sslCerts;
const server = isHttps && sslCerts ? createHttpsServer(sslCerts, app) : createServer(app);
server.on('upgrade', (request, socket, head) => {
terminalManager.initialize(request, socket, head, logger);
const url = new URL(request.url || '', `http://${request.headers.host}`);
// 检查是否是调试 WebSocket 连接
if (url.pathname.startsWith('/api/Debug/ws')) {
handleDebugWebSocket(request, socket, head);
} else {
// 默认为终端 WebSocket
terminalManager.initialize(request, socket, head, logger);
}
});
// 挂载API接口
app.use('/api', ALLRouter);

View File

@ -20,6 +20,7 @@
"@sinclair/typebox": "^0.34.38",
"ajv": "^8.13.0",
"compressing": "^1.10.3",
"compression": "^1.8.1",
"express": "^5.0.0",
"express-rate-limit": "^7.5.0",
"json5": "^2.2.3",
@ -29,6 +30,7 @@
"ws": "^8.18.3"
},
"devDependencies": {
"@types/compression": "^1.8.1",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.0.1",

View File

@ -3,7 +3,9 @@ import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { WebUiConfig } from '@/napcat-webui-backend/index';
import { getLatestTag } from 'napcat-common/src/helper';
import { getLatestTag, getAllTags, compareSemVer } from 'napcat-common/src/helper';
import { getLatestActionArtifacts } from '@/napcat-common/src/mirror';
import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types';
export const GetNapCatVersion: RequestHandler = (_, res) => {
const data = WebUiDataRuntime.GetNapCatVersion();
@ -15,7 +17,126 @@ export const getLatestTagHandler: RequestHandler = async (_, res) => {
const latestTag = await getLatestTag();
sendSuccess(res, latestTag);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch latest tag' });
res.status(500).json({ error: 'Failed to fetch latest tag', details: (error as Error).message });
}
};
/**
*
*/
export interface VersionInfo {
tag: string;
type: 'release' | 'prerelease' | 'action';
/** Action artifact 专用字段 */
artifactId?: number;
artifactName?: string;
createdAt?: string;
expiresAt?: string;
size?: number;
workflowRunId?: number;
headSha?: string;
}
/**
* release + action artifacts
* type
*/
export const getAllReleasesHandler: RequestHandler = async (req, res) => {
try {
const page = parseInt(req.query['page'] as string) || 1;
const pageSize = parseInt(req.query['pageSize'] as string) || 20;
const typeFilter = req.query['type'] as string | undefined; // 'release' | 'action' | 'all'
const searchQuery = (req.query['search'] as string || '').toLowerCase().trim();
let versions: VersionInfo[] = [];
let actionVersions: VersionInfo[] = [];
let usedMirror = '';
// 懒加载:只获取需要的版本类型
const needReleases = !typeFilter || typeFilter === 'all' || typeFilter === 'release';
const needActions = typeFilter === 'action' || typeFilter === 'all';
// 获取正式版本(仅当需要时)
if (needReleases) {
try {
const result = await getAllTags();
usedMirror = result.mirror;
versions = result.tags.map(tag => {
const isPrerelease = /-(alpha|beta|rc|dev|pre|snapshot)/i.test(tag);
return {
tag,
type: isPrerelease ? 'prerelease' : 'release',
} as VersionInfo;
});
// 使用语义化版本排序(最新的在前)
versions.sort((a, b) => -compareSemVer(a.tag, b.tag));
} catch {
// 如果获取 tags 失败,返回空列表而不是抛出错误
versions = [];
}
}
// 获取 Action Artifacts仅当需要时
if (needActions) {
try {
const artifacts = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main');
// 根据当前工作环境自动过滤对应的 artifact 类型
const isFramework = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework;
const targetArtifactName = isFramework ? 'NapCat.Framework' : 'NapCat.Shell';
actionVersions = artifacts
.filter(a => a.name === targetArtifactName)
.map(a => ({
tag: `action-${a.id}`,
type: 'action' as const,
artifactId: a.id,
artifactName: a.name,
createdAt: a.created_at,
expiresAt: a.expires_at,
size: a.size_in_bytes,
workflowRunId: a.workflow_run_id,
headSha: a.head_sha,
}));
} catch {
// 获取失败时返回空列表
actionVersions = [];
}
}
// 合并版本列表action 在最前面)
let allVersions = [...actionVersions, ...versions];
// 搜索过滤
if (searchQuery) {
allVersions = allVersions.filter(v => {
const tagMatch = v.tag.toLowerCase().includes(searchQuery);
const nameMatch = v.artifactName?.toLowerCase().includes(searchQuery);
return tagMatch || nameMatch;
});
}
// 分页
const total = allVersions.length;
const totalPages = Math.ceil(total / pageSize);
const start = (page - 1) * pageSize;
const end = start + pageSize;
const paginatedVersions = allVersions.slice(start, end);
sendSuccess(res, {
versions: paginatedVersions,
pagination: {
page,
pageSize,
total,
totalPages,
},
mirror: usedMirror
});
} catch (error) {
res.status(500).json({ error: 'Failed to fetch releases' });
}
};

View File

@ -0,0 +1,406 @@
import { Router, Request, Response } from 'express';
import { WebSocket, WebSocketServer } from 'ws';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { IncomingMessage } from 'http';
import { OB11Response } from '@/napcat-onebot/action/OneBotAction';
import { ActionName } from '@/napcat-onebot/action/router';
import { OB11LifeCycleEvent, LifeCycleSubType } from '@/napcat-onebot/event/meta/OB11LifeCycleEvent';
const router = Router();
const DEFAULT_ADAPTER_NAME = 'debug-primary';
/**
*
* OneBot NetworkManager WebSocket
*/
class DebugAdapter {
name: string;
isEnable: boolean = true;
// 安全令牌
readonly token: string;
// 添加 config 属性,模拟 PluginConfig 结构
config: {
enable: boolean;
name: string;
messagePostFormat?: string;
reportSelfMessage?: boolean;
debug?: boolean;
token?: string;
heartInterval?: number;
};
wsClients: Set<WebSocket> = new Set();
lastActivityTime: number = Date.now();
inactivityTimer: NodeJS.Timeout | null = null;
readonly INACTIVITY_TIMEOUT = 5 * 60 * 1000; // 5分钟不活跃
constructor (sessionId: string) {
this.name = `debug-${sessionId}`;
// 生成简单的随机 token
this.token = Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2);
this.config = {
enable: true,
name: this.name,
messagePostFormat: 'array',
reportSelfMessage: true,
debug: true,
token: this.token,
heartInterval: 30000
};
this.startInactivityCheck();
}
// 实现 IOB11NetworkAdapter 接口所需的抽象方法
async open (): Promise<void> { }
async close (): Promise<void> { this.cleanup(); }
async reload (_config: any): Promise<any> { return 0; }
/**
* OneBot - WebSocket ()
*/
async onEvent (event: any) {
this.updateActivity();
const payload = JSON.stringify(event);
if (this.wsClients.size === 0) {
return;
}
this.wsClients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
try {
client.send(payload);
} catch (error) {
console.error('[Debug] 发送事件到 WebSocket 失败:', error);
}
}
});
}
/**
* OneBot API (HTTP 使)
*/
async callApi (actionName: string, params: any): Promise<any> {
this.updateActivity();
const oneBotContext = WebUiDataRuntime.getOneBotContext();
if (!oneBotContext) {
throw new Error('OneBot 未初始化');
}
const action = oneBotContext.actions.get(actionName);
if (!action) {
throw new Error(`不支持的 API: ${actionName}`);
}
return await action.handle(params, this.name, {
name: this.name,
enable: true,
messagePostFormat: 'array',
reportSelfMessage: true,
debug: true,
});
}
/**
* WebSocket (OneBot )
*/
async handleWsMessage (ws: WebSocket, message: string | Buffer) {
this.updateActivity();
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any; } = { action: ActionName.Unknown, params: {} };
let echo;
try {
receiveData = JSON.parse(message.toString());
echo = receiveData.echo;
} catch {
this.sendWsResponse(ws, OB11Response.error('json解析失败,请检查数据格式', 1400, echo));
return;
}
receiveData.params = (receiveData?.params) ? receiveData.params : {};
// 兼容 WebUI 之前可能的一些非标准格式 (如果用户是旧前端)
// 但既然用户说要"原始流",我们优先支持标准格式
const oneBotContext = WebUiDataRuntime.getOneBotContext();
if (!oneBotContext) {
this.sendWsResponse(ws, OB11Response.error('OneBot 未初始化', 1404, echo));
return;
}
const action = oneBotContext.actions.get(receiveData.action as any);
if (!action) {
this.sendWsResponse(ws, OB11Response.error('不支持的API ' + receiveData.action, 1404, echo));
return;
}
try {
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
send: async (data: object) => {
this.sendWsResponse(ws, OB11Response.ok(data, echo ?? '', true));
},
});
this.sendWsResponse(ws, retdata);
} catch (e: any) {
this.sendWsResponse(ws, OB11Response.error(e.message || '内部错误', 1200, echo));
}
}
sendWsResponse (ws: WebSocket, data: any) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
}
/**
* WebSocket
*/
addWsClient (ws: WebSocket) {
this.wsClients.add(ws);
this.updateActivity();
// 发送生命周期事件 (Connect)
const oneBotContext = WebUiDataRuntime.getOneBotContext();
if (oneBotContext && oneBotContext.core) {
try {
const event = new OB11LifeCycleEvent(oneBotContext.core, LifeCycleSubType.CONNECT);
ws.send(JSON.stringify(event));
} catch (e) {
console.error('[Debug] 发送生命周期事件失败', e);
}
}
}
/**
* WebSocket
*/
removeWsClient (ws: WebSocket) {
this.wsClients.delete(ws);
}
updateActivity () {
this.lastActivityTime = Date.now();
}
startInactivityCheck () {
this.inactivityTimer = setInterval(() => {
const inactive = Date.now() - this.lastActivityTime;
// 如果没有 WebSocket 连接且超时,则自动清理
if (inactive > this.INACTIVITY_TIMEOUT && this.wsClients.size === 0) {
console.log(`[Debug] Adapter ${this.name} 不活跃,自动关闭`);
this.cleanup();
}
}, 30000);
}
cleanup () {
if (this.inactivityTimer) {
clearInterval(this.inactivityTimer);
this.inactivityTimer = null;
}
// 关闭所有 WebSocket 连接
this.wsClients.forEach((client) => {
try {
client.close();
} catch (error) {
// ignore
}
});
this.wsClients.clear();
// 从 OneBot NetworkManager 移除
const oneBotContext = WebUiDataRuntime.getOneBotContext();
if (oneBotContext) {
oneBotContext.networkManager.adapters.delete(this.name);
}
// 从管理器中移除
debugAdapterManager.removeAdapter(this.name);
}
/**
* Token
*/
validateToken (inputToken: string): boolean {
return this.token === inputToken;
}
}
/**
*
*/
class DebugAdapterManager {
private currentAdapter: DebugAdapter | null = null;
getOrCreateAdapter (): DebugAdapter {
// 如果已存在且活跃,直接返回
if (this.currentAdapter) {
this.currentAdapter.updateActivity();
return this.currentAdapter;
}
// 创建新实例
const adapter = new DebugAdapter('primary');
this.currentAdapter = adapter;
// 注册到 OneBot NetworkManager
const oneBotContext = WebUiDataRuntime.getOneBotContext();
if (oneBotContext) {
oneBotContext.networkManager.adapters.set(adapter.name, adapter as any);
} else {
console.warn('[Debug] OneBot 未初始化,无法注册适配器');
}
return adapter;
}
getAdapter (name: string): DebugAdapter | undefined {
if (this.currentAdapter && this.currentAdapter.name === name) {
return this.currentAdapter;
}
return undefined;
}
removeAdapter (name: string) {
if (this.currentAdapter && this.currentAdapter.name === name) {
this.currentAdapter = null;
}
}
}
const debugAdapterManager = new DebugAdapterManager();
/**
*
*/
router.post('/create', async (_req: Request, res: Response) => {
try {
const adapter = debugAdapterManager.getOrCreateAdapter();
sendSuccess(res, {
adapterName: adapter.name,
token: adapter.token,
message: '调试适配器已就绪',
});
} catch (error: any) {
sendError(res, error.message);
}
});
/**
* HTTP OneBot API ( adapter)
*/
const handleCallApi = async (req: Request, res: Response) => {
try {
let adapterName = req.params['adapterName'] || req.body.adapterName || DEFAULT_ADAPTER_NAME;
let adapter = debugAdapterManager.getAdapter(adapterName);
// 如果是默认 adapter 且不存在,尝试创建
if (!adapter && adapterName === DEFAULT_ADAPTER_NAME) {
adapter = debugAdapterManager.getOrCreateAdapter();
}
if (!adapter) {
return sendError(res, '调试适配器不存在');
}
const { action, params } = req.body;
const result = await adapter.callApi(action, params || {});
sendSuccess(res, result);
} catch (error: any) {
sendError(res, error.message);
}
};
router.post('/call/:adapterName', handleCallApi);
router.post('/call', handleCallApi);
/**
*
*/
router.post('/close/:adapterName', async (req: Request, res: Response) => {
try {
const { adapterName } = req.params;
if (!adapterName) {
return sendError(res, '缺少 adapterName 参数');
}
debugAdapterManager.removeAdapter(adapterName);
sendSuccess(res, { message: '调试适配器已关闭' });
} catch (error: any) {
sendError(res, error.message);
}
});
/**
* WebSocket
* : /api/Debug/ws?adapterName=xxx&token=xxx
*/
export function handleDebugWebSocket (request: IncomingMessage, socket: any, head: any) {
const url = new URL(request.url || '', `http://${request.headers.host}`);
let adapterName = url.searchParams.get('adapterName');
const token = url.searchParams.get('token') || url.searchParams.get('access_token');
// 默认 adapterName
if (!adapterName) {
adapterName = DEFAULT_ADAPTER_NAME;
}
// Debug session should provide token
if (!token) {
console.log('[Debug] WebSocket 连接被拒绝: 缺少 Token');
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
let adapter = debugAdapterManager.getAdapter(adapterName);
// 如果是默认 adapter 且不存在,尝试创建
if (!adapter && adapterName === DEFAULT_ADAPTER_NAME) {
adapter = debugAdapterManager.getOrCreateAdapter();
}
if (!adapter) {
console.log('[Debug] WebSocket 连接被拒绝: 适配器不存在');
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
socket.destroy();
return;
}
if (!adapter.validateToken(token)) {
console.log('[Debug] WebSocket 连接被拒绝: Token 无效');
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
// 创建 WebSocket 服务器
const wsServer = new WebSocketServer({ noServer: true });
wsServer.handleUpgrade(request, socket, head, (ws) => {
adapter.addWsClient(ws);
ws.on('message', async (data) => {
try {
await adapter.handleWsMessage(ws, data as any);
} catch (error: any) {
console.error('[Debug] handleWsMessage error', error);
}
});
ws.on('close', () => {
adapter.removeWsClient(ws);
});
ws.on('error', () => {
adapter.removeWsClient(ws);
});
});
}
export default router;

View File

@ -640,10 +640,10 @@ export const UploadWebUIFontHandler: RequestHandler = async (req, res) => {
// 删除WebUI字体文件处理方法
export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => {
try {
const fontPath = WebUiConfig.GetWebUIFontPath();
const fontPath = await WebUiConfig.GetWebUIFontPath();
const exists = await WebUiConfig.CheckWebUIFontExist();
if (!exists) {
if (!exists || !fontPath) {
return sendSuccess(res, true);
}

View File

@ -4,18 +4,22 @@ import * as fs from 'fs';
import * as path from 'path';
import * as https from 'https';
import compressing from 'compressing';
import { webUiPathWrapper } from '../../index';
import { webUiPathWrapper, webUiLogger } from '../../index';
import { NapCatPathWrapper } from '@/napcat-common/src/path';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types';
import {
getGitHubRelease,
findAvailableDownloadUrl
} from '@/napcat-common/src/mirror';
import { ILogWrapper } from '@/napcat-common/src/log-interface';
interface Release {
tag_name: string;
assets: Array<{
name: string;
browser_download_url: string;
}>;
body?: string;
// 更新请求体接口
interface UpdateRequestBody {
/** 要更新到的版本 tag如 "v4.9.9",不传则更新到最新版本 */
targetVersion?: string;
/** 是否强制更新(即使是降级也更新) */
force?: boolean;
}
// 更新配置文件接口
@ -69,91 +73,24 @@ function scanFilesRecursively (dirPath: string, basePath: string = dirPath): Arr
return files;
}
// 镜像源列表参考ffmpeg下载实现
const mirrorUrls = [
'https://j.1win.ggff.net/',
'https://git.yylx.win/',
'https://ghfile.geekertao.top/',
'https://gh-proxy.net/',
'https://ghm.078465.xyz/',
'https://gitproxy.127731.xyz/',
'https://jiashu.1win.eu.org/',
'', // 原始URL
];
/**
* URL是否可用
*/
async function testUrl (url: string): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const req = https.get(url, { timeout: 5000 }, (res) => {
const statusCode = res.statusCode || 0;
if (statusCode >= 200 && statusCode < 300) {
req.destroy();
resolve(true);
} else {
req.destroy();
resolve(false);
}
});
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
}
/**
* URL
*/
function buildMirrorUrl (originalUrl: string, mirror: string): string {
if (!mirror) return originalUrl;
return mirror + originalUrl;
}
/**
* URL
*/
async function findAvailableUrl (originalUrl: string): Promise<string> {
console.log('Testing download URLs...');
// 先测试原始URL
if (await testUrl(originalUrl)) {
console.log('Using original URL:', originalUrl);
return originalUrl;
}
// 测试镜像源
for (const mirror of mirrorUrls) {
const mirrorUrl = buildMirrorUrl(originalUrl, mirror);
console.log('Testing mirror:', mirrorUrl);
if (await testUrl(mirrorUrl)) {
console.log('Using mirror URL:', mirrorUrl);
return mirrorUrl;
}
}
throw new Error('所有下载源都不可用');
}
// 注:镜像配置已迁移到 @/napcat-common/src/mirror 模块统一管理
/**
*
*/
async function downloadFile (url: string, dest: string): Promise<void> {
console.log('Starting download from:', url);
webUiLogger?.log('[NapCat Update] Starting download from:', url);
const file = fs.createWriteStream(dest);
return new Promise((resolve, reject) => {
const request = https.get(url, {
headers: { 'User-Agent': 'NapCat-WebUI' }
}, (res) => {
console.log('Response status:', res.statusCode);
console.log('Content-Type:', res.headers['content-type']);
webUiLogger?.log('[NapCat Update] Response status:', res.statusCode);
webUiLogger?.log('[NapCat Update] Content-Type:', res.headers['content-type']);
if (res.statusCode === 302 || res.statusCode === 301) {
console.log('Following redirect to:', res.headers.location);
webUiLogger?.log('[NapCat Update] Following redirect to:', res.headers.location);
file.close();
fs.unlinkSync(dest);
downloadFile(res.headers.location!, dest).then(resolve).catch(reject);
@ -170,13 +107,13 @@ async function downloadFile (url: string, dest: string): Promise<void> {
res.pipe(file);
file.on('finish', () => {
file.close();
console.log('Download completed');
webUiLogger?.log('[NapCat Update] Download completed');
resolve();
});
});
request.on('error', (err) => {
console.error('Download error:', err);
webUiLogger?.logError('[NapCat Update] Download error:', err);
file.close();
fs.unlink(dest, () => { });
reject(err);
@ -184,37 +121,128 @@ async function downloadFile (url: string, dest: string): Promise<void> {
});
}
export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
try {
// 获取最新release信息
const latestRelease = await getLatestRelease() as Release;
// 从请求体获取目标版本(可选)
const { targetVersion, force } = req.body as UpdateRequestBody;
// 确定要下载的文件名
const ReleaseName = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip';
const shellZipAsset = latestRelease.assets.find(asset => asset.name === ReleaseName);
if (!shellZipAsset) {
throw new Error(`未找到${ReleaseName}文件`);
// 确定目标版本 tag
// 如果指定了版本,使用指定版本;否则使用 'latest'
const targetTag = targetVersion || 'latest';
webUiLogger?.log(`[NapCat Update] Target version: ${targetTag}`);
// 检查是否是 action 临时版本
const isActionVersion = targetTag.startsWith('action-');
let downloadUrl: string;
let actualVersion: string;
if (isActionVersion) {
// 处理 action 临时版本
const runId = parseInt(targetTag.replace('action-', ''));
if (isNaN(runId)) {
throw new Error(`Invalid action version format: ${targetTag}`);
}
webUiLogger?.log(`[NapCat Update] Downloading action artifact from run: ${runId}`);
// 根据当前工作环境确定 artifact 名称
const artifactName = ReleaseName.replace('.zip', ''); // NapCat.Framework 或 NapCat.Shell
// Action artifacts 通过 nightly.link 下载
// 格式https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
const baseUrl = `https://nightly.link/NapNeko/NapCatQQ/actions/runs/${runId}/${artifactName}.zip`;
actualVersion = targetTag;
webUiLogger?.log(`[NapCat Update] Action artifact URL: ${baseUrl}`);
// 使用 mirror 模块查找可用的 nightly.link 镜像
try {
downloadUrl = await findAvailableDownloadUrl(baseUrl, {
validateContent: true,
minFileSize: 1024 * 1024,
timeout: 10000,
});
webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`);
} catch (error) {
// 如果镜像都不可用,直接使用原始 URL
webUiLogger?.logWarn(`[NapCat Update] All nightly.link mirrors failed, using original URL`);
downloadUrl = baseUrl;
}
} else {
// 处理标准 release 版本
// 使用 mirror 模块获取 release 信息(不依赖 API
// 通过 assetNames 参数直接构建下载 URL避免调用 GitHub API
const release = await getGitHubRelease('NapNeko', 'NapCatQQ', targetTag, {
assetNames: [ReleaseName, 'NapCat.Framework.zip', 'NapCat.Shell.zip'],
fetchChangelog: false, // 不需要 changelog避免 API 调用
});
const shellZipAsset = release.assets.find(asset => asset.name === ReleaseName);
if (!shellZipAsset) {
throw new Error(`未找到${ReleaseName}文件`);
}
actualVersion = release.tag_name;
// 使用 mirror 模块查找可用的下载 URL
// 启用内容验证,确保返回的是有效文件而非错误页面
downloadUrl = await findAvailableDownloadUrl(shellZipAsset.browser_download_url, {
validateContent: true, // 验证 Content-Type 和状态码
minFileSize: 1024 * 1024, // 最小 1MB确保不是错误页面
timeout: 10000, // 10秒超时
});
}
// 检查是否需要强制更新(降级警告)
const currentVersion = WebUiDataRuntime.GetNapCatVersion();
webUiLogger?.log(`[NapCat Update] Current version: ${currentVersion}, Target version: ${actualVersion}`);
if (!force && currentVersion && !isActionVersion) {
// 简单的版本比较(可选的降级保护)
const parseVersion = (v: string): [number, number, number] => {
const match = v.match(/^v?(\d+)\.(\d+)\.(\d+)/);
if (!match) return [0, 0, 0];
return [parseInt(match[1] || '0'), parseInt(match[2] || '0'), parseInt(match[3] || '0')];
};
const [currMajor, currMinor, currPatch] = parseVersion(currentVersion);
const [targetMajor, targetMinor, targetPatch] = parseVersion(actualVersion);
const isDowngrade =
targetMajor < currMajor ||
(targetMajor === currMajor && targetMinor < currMinor) ||
(targetMajor === currMajor && targetMinor === currMinor && targetPatch < currPatch);
if (isDowngrade) {
webUiLogger?.log(`[NapCat Update] Downgrade from ${currentVersion} to ${actualVersion}, force=${force}`);
// 不阻止降级,只是记录日志
}
}
webUiLogger?.log(`[NapCat Update] Updating to version: ${actualVersion}`);
// 创建临时目录
const tempDir = path.join(webUiPathWrapper.binaryPath, './temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
// 查找可用的下载URL
const downloadUrl = await findAvailableUrl(shellZipAsset.browser_download_url);
webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`);
// 下载zip
const zipPath = path.join(tempDir, 'napcat-latest.zip');
console.log('[NapCat Update] Saving to:', zipPath);
webUiLogger?.log('[NapCat Update] Saving to:', zipPath);
await downloadFile(downloadUrl, zipPath);
// 检查文件大小
const stats = fs.statSync(zipPath);
console.log('[NapCat Update] Downloaded file size:', stats.size, 'bytes');
webUiLogger?.log('[NapCat Update] Downloaded file size:', stats.size, 'bytes');
// 解压到临时目录
const extractPath = path.join(tempDir, 'napcat-extract');
console.log('[NapCat Update] Extracting to:', extractPath);
webUiLogger?.log('[NapCat Update] Extracting to:', extractPath);
await compressing.zip.uncompress(zipPath, extractPath);
// 获取解压后的实际内容目录NapCat.Shell.zip直接包含文件无额外根目录
@ -235,7 +263,7 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
// 跳过指定的文件
if (SKIP_UPDATE_FILES.includes(path.basename(fileInfo.relativePath))) {
console.log(`[NapCat Update] Skipping update for ${fileInfo.relativePath}`);
webUiLogger?.log(`[NapCat Update] Skipping update for ${fileInfo.relativePath}`);
continue;
}
@ -253,7 +281,7 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
fs.copyFileSync(fileInfo.sourcePath, targetFilePath);
} catch (error) {
// 如果替换失败,添加到失败列表
console.log(`[NapCat Update] Failed to update ${targetFilePath}, will retry on next startup:`, error);
webUiLogger?.logError(`[NapCat Update] Failed to update ${targetFilePath}, will retry on next startup:`, error);
failedFiles.push({
sourcePath: fileInfo.sourcePath,
targetPath: targetFilePath
@ -264,16 +292,16 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
// 如果有替换失败的文件,创建更新配置文件
if (failedFiles.length > 0) {
const updateConfig: UpdateConfig = {
version: latestRelease.tag_name,
version: actualVersion,
updateTime: new Date().toISOString(),
files: failedFiles,
changelog: latestRelease.body || ''
changelog: ''
};
// 保存更新配置文件
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
fs.writeFileSync(configPath, JSON.stringify(updateConfig, null, 2));
console.log(`[NapCat Update] Update config saved for ${failedFiles.length} failed files: ${configPath}`);
webUiLogger?.log(`[NapCat Update] Update config saved for ${failedFiles.length} failed files: ${configPath}`);
}
// 发送成功响应
@ -283,57 +311,36 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
sendSuccess(res, {
status: 'completed',
message,
newVersion: latestRelease.tag_name,
newVersion: actualVersion,
failedFilesCount: failedFiles.length
});
} catch (error) {
console.error('更新失败:', error);
webUiLogger?.logError('[NapCat Update] 更新失败:', error);
sendError(res, '更新失败: ' + (error instanceof Error ? error.message : '未知错误'));
}
} catch (error: any) {
console.error('更新失败:', error);
webUiLogger?.logError('[NapCat Update] 更新失败:', error);
sendError(res, '更新失败: ' + error.message);
}
};
async function getLatestRelease (): Promise<Release> {
return new Promise((resolve, reject) => {
https.get('https://api.github.com/repos/NapNeko/NapCatQQ/releases/latest', {
headers: { 'User-Agent': 'NapCat-WebUI' }
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const release = JSON.parse(data) as Release;
console.log('Release info:', {
tag_name: release.tag_name,
assets: release.assets?.map(a => ({ name: a.name, url: a.browser_download_url }))
});
resolve(release);
} catch (e) {
reject(e);
}
});
}).on('error', reject);
});
}
// 注getLatestRelease 已移除,现在使用 mirror 模块的 getGitHubRelease
/**
*
*/
export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper): Promise<void> {
export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper, logger: ILogWrapper): Promise<void> {
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
if (!fs.existsSync(configPath)) {
console.log('No pending updates found');
logger.log('[NapCat Update] No pending updates found');
return;
}
try {
console.log('[NapCat Update] Applying pending updates...');
logger.log('[NapCat Update] Applying pending updates...');
const updateConfig: UpdateConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const remainingFiles: Array<{
@ -345,7 +352,7 @@ export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper):
try {
// 检查源文件是否存在
if (!fs.existsSync(file.sourcePath)) {
console.warn(`[NapCat Update] Source file not found: ${file.sourcePath}`);
logger.logWarn(`[NapCat Update] Source file not found: ${file.sourcePath}`);
continue;
}
@ -360,10 +367,10 @@ export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper):
fs.unlinkSync(file.targetPath); // 删除旧文件
}
fs.copyFileSync(file.sourcePath, file.targetPath);
console.log(`[NapCat Update] Updated ${path.basename(file.targetPath)} on startup`);
logger.log(`[NapCat Update] Updated ${path.basename(file.targetPath)} on startup`);
} catch (error) {
console.error(`[NapCat Update] Failed to update ${file.targetPath} on startup:`, error);
logger.logError(`[NapCat Update] Failed to update ${file.targetPath} on startup:`, error);
// 如果仍然失败,保留在列表中
remainingFiles.push(file);
}
@ -376,13 +383,13 @@ export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper):
files: remainingFiles
};
fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2));
console.log(`${remainingFiles.length} files still pending update`);
logger.log(`[NapCat Update] ${remainingFiles.length} files still pending update`);
} else {
// 所有文件都成功更新,删除配置文件
fs.unlinkSync(configPath);
console.log('[NapCat Update] All pending updates applied successfully');
logger.log('[NapCat Update] All pending updates applied successfully');
}
} catch (error) {
console.error('[NapCat Update] Failed to apply pending updates:', error);
logger.logError('[NapCat Update] Failed to apply pending updates:', error);
}
}

View File

@ -0,0 +1,132 @@
const CACHE_NAME = 'napcat-webui-v{{VERSION}}';
const ASSETS_TO_CACHE = [
'/webui/'
];
// 安装阶段:预缓存核心文件
self.addEventListener('install', (event) => {
self.skipWaiting(); // 强制立即接管
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
// 这里的资源如果加载失败不应该阻断 SW 安装
return cache.addAll(ASSETS_TO_CACHE).catch(err => console.warn('Failed to cache core assets', err));
})
);
});
// 激活阶段:清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName.startsWith('napcat-webui-') && cacheName !== CACHE_NAME) {
console.log('[SW] Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
self.clients.claim(); // 立即控制所有客户端
});
// 拦截请求
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// 1. API 请求:仅网络 (Network Only)
if (url.pathname.startsWith('/api/') || url.pathname.includes('/socket')) {
return;
}
// 2. 强缓存策略 (Cache First)
// - 外部 QQ 头像 (q1.qlogo.cn)
// - 静态资源 (assets, fonts)
// - 常见静态文件后缀
const isQLogo = url.hostname === 'q1.qlogo.cn';
const isCustomFont = url.pathname.includes('CustomFont.woff'); // 用户自定义字体,不强缓存
const isThemeCss = url.pathname.includes('files/theme.css'); // 主题 CSS不强缓存
const isStaticAsset = url.pathname.includes('/webui/assets/') ||
url.pathname.includes('/webui/fonts/');
const isStaticFile = /\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|ico)$/i.test(url.pathname);
if (!isCustomFont && !isThemeCss && (isQLogo || isStaticAsset || isStaticFile)) {
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
return response;
}
// 跨域请求 (qlogo) 需要 mode: 'no-cors' 才能缓存 opaque response
// 但 fetch(event.request) 默认会继承 request 的 mode。
// 如果是 img标签发起的请求通常 mode 是 no-cors 或 cors。
// 对于 opaque response (status 0), cache API 允许缓存。
return fetch(event.request).then((response) => {
// 对 qlogo 允许 status 0 (opaque)
// 对其他资源要求 status 200
const isValidResponse = response && (
response.status === 200 ||
response.type === 'basic' ||
(isQLogo && response.type === 'opaque')
);
if (!isValidResponse) {
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
})
);
return;
}
// 3. HTML 页面 / 导航请求 -> 网络优先 (Network First)
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.then((response) => {
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
return;
}
// 4. 其他 Same-Origin 请求 -> Stale-While-Revalidate
// 优先返回缓存,同时后台更新缓存,保证下次访问是新的
if (url.origin === self.location.origin) {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
const fetchPromise = fetch(event.request).then((networkResponse) => {
if (networkResponse && networkResponse.status === 200) {
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
}
return networkResponse;
});
// 如果有缓存,返回缓存;否则等待网络
return cachedResponse || fetchPromise;
})
);
return;
}
// 默认:网络优先
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
});

View File

@ -15,6 +15,7 @@ const LoginRuntime: LoginRuntimeType = {
nick: '',
},
QQVersion: 'unknown',
OneBotContext: null,
onQQLoginStatusChange: async (status: boolean) => {
LoginRuntime.QQLoginStatus = status;
},
@ -154,4 +155,12 @@ export const WebUiDataRuntime = {
runWebUiConfigQuickFunction: async function () {
await LoginRuntime.WebUiConfigQuickFunction();
},
setOneBotContext (context: any): void {
LoginRuntime.OneBotContext = context;
},
getOneBotContext (): any | null {
return LoginRuntime.OneBotContext;
},
};

View File

@ -37,7 +37,7 @@ export class PasskeyHelper {
} catch {
await fs.writeFile(passkeyFile, JSON.stringify({}, null, 2));
}
} catch (error) {
} catch (_error) {
// Directory or file already exists or other error
}
}
@ -49,7 +49,8 @@ export class PasskeyHelper {
const data = await fs.readFile(passkeyFile, 'utf-8');
const passkeys = JSON.parse(data);
return typeof passkeys === 'object' && passkeys !== null ? passkeys : {};
} catch (error) {
} catch (_error) {
console.error('Failed to read passkey file:', _error);
return {};
}
}
@ -82,8 +83,8 @@ export class PasskeyHelper {
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: rpId,
userID: new TextEncoder().encode(userId),
userName: userName,
userID: new TextEncoder().encode(userId) as Uint8Array<ArrayBuffer>,
userName,
attestationType: 'none',
excludeCredentials: userPasskeys.map(passkey => ({
id: passkey.id,
@ -203,4 +204,4 @@ export class PasskeyHelper {
const userPasskeys = await this.getUserPasskeys(userId);
return userPasskeys.length > 0;
}
}
}

View File

@ -176,17 +176,35 @@ export class WebUiConfigWrapper {
return [];
}
// 判断字体是否存在(webui.woff
// 判断字体是否存在(支持多种格式
async CheckWebUIFontExist (): Promise<boolean> {
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
const fontPath = await this.GetWebUIFontPath();
if (!fontPath) return false;
return await fs
.access(resolve(fontsPath, './webui.woff'), constants.F_OK)
.access(fontPath, constants.F_OK)
.then(() => true)
.catch(() => false);
}
// 获取webui字体文件路径
GetWebUIFontPath (): string {
// 获取webui字体文件路径支持多种格式
async GetWebUIFontPath (): Promise<string | null> {
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
const extensions = ['.woff', '.woff2', '.ttf', '.otf'];
for (const ext of extensions) {
const fontPath = resolve(fontsPath, `webui${ext}`);
const exists = await fs
.access(fontPath, constants.F_OK)
.then(() => true)
.catch(() => false);
if (exists) {
return fontPath;
}
}
return null;
}
// 同步版本,用于 multer 配置
GetWebUIFontPathSync (): string {
return resolve(webUiPathWrapper.configPath, './fonts/webui.woff');
}

View File

@ -1,5 +1,5 @@
import { Router } from 'express';
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler } from '../api/BaseInfo';
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler, getAllReleasesHandler } from '../api/BaseInfo';
import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status';
import { GetProxyHandler } from '../api/Proxy';
@ -8,6 +8,7 @@ const router = Router();
router.get('/QQVersion', QQVersionHandler);
router.get('/GetNapCatVersion', GetNapCatVersion);
router.get('/getLatestTag', getLatestTagHandler);
router.get('/getAllReleases', getAllReleasesHandler);
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
router.get('/proxy', GetProxyHandler);
router.get('/Theme', GetThemeConfigHandler);

View File

@ -15,6 +15,7 @@ import { BaseRouter } from '@/napcat-webui-backend/src/router/Base';
import { FileRouter } from './File';
import { WebUIConfigRouter } from './WebUIConfig';
import { UpdateNapCatRouter } from './UpdateNapCat';
import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
const router = Router();
@ -41,5 +42,7 @@ router.use('/File', FileRouter);
router.use('/WebUIConfig', WebUIConfigRouter);
// router:更新NapCat相关路由
router.use('/UpdateNapCat', UpdateNapCatRouter);
// router:调试相关路由
router.use('/Debug', DebugRouter);
export { router as ALLRouter };

View File

@ -47,6 +47,7 @@ export interface LoginRuntimeType {
onQQLoginStatusChange: (status: boolean) => Promise<void>;
onWebUiTokenChange: (token: string) => Promise<void>;
WebUiConfigQuickFunction: () => Promise<void>;
OneBotContext: any | null; // OneBot 上下文,用于调试功能
NapCatHelper: {
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;

View File

@ -4,9 +4,11 @@ export const themeType = Type.Object(
{
dark: Type.Record(Type.String(), Type.String()),
light: Type.Record(Type.String(), Type.String()),
fontMode: Type.String({ default: 'system' }),
},
{
default: {
fontMode: 'system',
dark: {
'--heroui-background': '0 0% 0%',
'--heroui-foreground-50': '240 5.88% 10%',
@ -124,11 +126,11 @@ export const themeType = Type.Object(
'--heroui-border-width-medium': '2px',
'--heroui-border-width-large': '3px',
'--heroui-box-shadow-small':
'0px 0px 5px 0px rgba(0, 0, 0, .05), 0px 2px 10px 0px rgba(0, 0, 0, .2), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'0px 0px 5px 0px rgba(0, 0, 0, .05), 0px 2px 10px 0px rgba(0, 0, 0, .2), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'--heroui-box-shadow-medium':
'0px 0px 15px 0px rgba(0, 0, 0, .06), 0px 2px 30px 0px rgba(0, 0, 0, .22), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'0px 0px 15px 0px rgba(0, 0, 0, .06), 0px 2px 30px 0px rgba(0, 0, 0, .22), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'--heroui-box-shadow-large':
'0px 0px 30px 0px rgba(0, 0, 0, .07), 0px 30px 60px 0px rgba(0, 0, 0, .26), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'0px 0px 30px 0px rgba(0, 0, 0, .07), 0px 30px 60px 0px rgba(0, 0, 0, .26), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'--heroui-hover-opacity': '.9',
},
light: {
@ -248,11 +250,11 @@ export const themeType = Type.Object(
'--heroui-border-width-medium': '2px',
'--heroui-border-width-large': '3px',
'--heroui-box-shadow-small':
'0px 0px 5px 0px rgba(0, 0, 0, .02), 0px 2px 10px 0px rgba(0, 0, 0, .06), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'0px 0px 5px 0px rgba(0, 0, 0, .02), 0px 2px 10px 0px rgba(0, 0, 0, .06), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'--heroui-box-shadow-medium':
'0px 0px 15px 0px rgba(0, 0, 0, .03), 0px 2px 30px 0px rgba(0, 0, 0, .08), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'0px 0px 15px 0px rgba(0, 0, 0, .03), 0px 2px 30px 0px rgba(0, 0, 0, .08), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'--heroui-box-shadow-large':
'0px 0px 30px 0px rgba(0, 0, 0, .04), 0px 30px 60px 0px rgba(0, 0, 0, .12), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'0px 0px 30px 0px rgba(0, 0, 0, .04), 0px 30px 60px 0px rgba(0, 0, 0, .12), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'--heroui-hover-opacity': '.8',
},
},

View File

@ -4,30 +4,51 @@ import fs from 'fs';
import type { Request, Response } from 'express';
import { WebUiConfig } from '@/napcat-webui-backend/index';
// 支持的字体格式
const SUPPORTED_FONT_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf'];
// 清理旧的字体文件
const cleanOldFontFiles = (fontsPath: string) => {
for (const ext of SUPPORTED_FONT_EXTENSIONS) {
const fontPath = path.join(fontsPath, `webui${ext}`);
try {
if (fs.existsSync(fontPath)) {
fs.unlinkSync(fontPath);
}
} catch {
// 忽略删除失败
}
}
};
export const webUIFontStorage = multer.diskStorage({
destination: (_, __, cb) => {
try {
const fontsPath = path.dirname(WebUiConfig.GetWebUIFontPath());
const fontsPath = path.dirname(WebUiConfig.GetWebUIFontPathSync());
// 确保字体目录存在
fs.mkdirSync(fontsPath, { recursive: true });
// 清理旧的字体文件
cleanOldFontFiles(fontsPath);
cb(null, fontsPath);
} catch (error) {
// 确保错误信息被正确传递
cb(new Error(`创建字体目录失败:${(error as Error).message}`), '');
}
},
filename: (_, __, cb) => {
// 统一保存为webui.woff
cb(null, 'webui.woff');
filename: (_, file, cb) => {
// 保留原始扩展名,统一文件名为 webui
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `webui${ext}`);
},
});
export const webUIFontUpload = multer({
storage: webUIFontStorage,
fileFilter: (_, file, cb) => {
// 再次验证文件类型
if (!file.originalname.toLowerCase().endsWith('.woff')) {
cb(new Error('只支持WOFF格式的字体文件'));
// 验证文件类型
const ext = path.extname(file.originalname).toLowerCase();
if (!SUPPORTED_FONT_EXTENSIONS.includes(ext)) {
cb(new Error('只支持 WOFF/WOFF2/TTF/OTF 格式的字体文件'));
return;
}
cb(null, true);
@ -41,8 +62,6 @@ const webUIFontUploader = (req: Request, res: Response) => {
return new Promise((resolve, reject) => {
webUIFontUpload(req, res, (error) => {
if (error) {
// 错误处理
// sendError(res, error.message, true);
return reject(error);
}
return resolve(true);

View File

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

View File

@ -26,7 +26,5 @@ dist-ssr
# NPM LOCK files
package-lock.json
yarn.lock
pnpm-lock.yaml
dist.zip

View File

@ -5,12 +5,19 @@
"type": "module",
"scripts": {
"dev": "vite --host=0.0.0.0",
"build": "tsc && vite build",
"build": "vite build",
"build:full": "tsc && vite build",
"fontmin": "node scripts/fontmin.cjs",
"typecheck": "tsc --noEmit",
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.6",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@ -22,6 +29,7 @@
"@heroui/checkbox": "2.3.9",
"@heroui/chip": "2.2.7",
"@heroui/code": "2.2.7",
"@heroui/divider": "^2.2.21",
"@heroui/dropdown": "2.3.10",
"@heroui/form": "2.1.9",
"@heroui/image": "2.2.6",
@ -45,11 +53,10 @@
"@heroui/theme": "2.4.6",
"@heroui/tooltip": "2.2.8",
"@monaco-editor/loader": "^1.4.0",
"@monaco-editor/react": "4.7.0-rc.0",
"@react-aria/visually-hidden": "^3.8.19",
"@reduxjs/toolkit": "^2.5.1",
"@simplewebauthn/browser": "^13.2.2",
"@uidotdev/usehooks": "^2.4.1",
"@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
@ -58,10 +65,7 @@
"axios": "^1.7.9",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"echarts": "^5.5.1",
"event-source-polyfill": "^1.0.31",
"framer-motion": "^12.0.6",
"monaco-editor": "^0.52.2",
"motion": "^12.0.6",
"path-browserify": "^1.0.1",
"qface": "^1.4.1",
@ -78,7 +82,6 @@
"react-markdown": "^9.0.3",
"react-photo-view": "^1.2.7",
"react-redux": "^9.2.0",
"react-responsive": "^10.0.0",
"react-router-dom": "^7.1.4",
"react-use-websocket": "^4.11.1",
"react-window": "^1.8.11",
@ -106,10 +109,15 @@
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "5.2.3",
"eslint-plugin-unused-imports": "^4.1.4",
"fontmin": "^0.9.9",
"glob": "^10.3.10",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"sharp": "^0.34.5",
"typescript": "^5.7.3",
"vite": "^6.0.5",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-image-optimizer": "^2.0.3",
"vite-plugin-static-copy": "^2.2.0",
"vite-tsconfig-paths": "^5.1.4"
},
@ -123,4 +131,4 @@
"react-dom": "$react-dom"
}
}
}
}

View File

@ -0,0 +1,137 @@
/**
* Fontmin Script - 动态裁剪字体
* 扫描 src 目录中所有中文字符生成字体子集
*/
const Fontmin = require('fontmin');
const fs = require('fs');
const path = require('path');
const glob = require('glob');
// 配置
const SOURCE_FONT = path.resolve(__dirname, '../src/assets/fonts/AaCute-full.ttf');
const SOURCE_TTF_ORIGINAL = path.resolve(__dirname, '../src/assets/fonts/AaCute.ttf');
const OUTPUT_DIR = path.resolve(__dirname, '../public/fonts');
const OUTPUT_NAME = 'AaCute.woff';
const SRC_DIR = path.resolve(__dirname, '../src');
// 基础字符集(常用汉字 + 标点 + 数字 + 字母)
const BASE_CHARS = `
0123456789
abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ
""''·
,.:;!?'"()[]<>-_+=*/\\|@#$%^&~\`
基础信息系统版本网络配置服务器客户端终端日志调试关于设置主题
登录退出确定取消保存删除编辑新建刷新加载更新下载上传
成功失败错误警告提示信息状态在线离线连接断开
用户名密码账号验证码记住自动
文件管理打开关闭复制粘贴剪切重命名移动
发送消息输入内容搜索查找筛选排序
帮助文档教程反馈问题建议
开启关闭启用禁用显示隐藏展开收起
返回前进上一步下一步完成跳过
今天昨天明天时间日期年月日时分秒
总量使用占用剩余内存内核主频型号
有新版本可用当前最新立即稍后
`;
/**
* 从源码文件中提取所有中文字符
*/
function extractCharsFromSource () {
const chars = new Set(BASE_CHARS.replace(/\s/g, ''));
// 匹配所有 .tsx, .ts, .jsx, .js, .css 文件
const files = glob.sync(`${SRC_DIR}/**/*.{tsx,ts,jsx,js,css}`, {
ignore: ['**/node_modules/**']
});
// 中文字符正则
const chineseRegex = /[\u4e00-\u9fa5]/g;
files.forEach(file => {
try {
const content = fs.readFileSync(file, 'utf-8');
const matches = content.match(chineseRegex);
if (matches) {
matches.forEach(char => chars.add(char));
}
} catch (e) {
console.warn(`Warning: Could not read file ${file}`);
}
});
return Array.from(chars).join('');
}
/**
* 运行 fontmin
*/
async function run () {
console.log('🔍 Scanning source files for Chinese characters...');
const text = extractCharsFromSource();
console.log(`📝 Found ${text.length} unique characters`);
// 检查源字体是否存在
let sourceFont = SOURCE_FONT;
if (!fs.existsSync(SOURCE_FONT)) {
// 尝试查找原始 TTF 并复制(不重命名,保留原始)
if (fs.existsSync(SOURCE_TTF_ORIGINAL)) {
console.log('📦 Copying original font to AaCute-full.ttf...');
fs.copyFileSync(SOURCE_TTF_ORIGINAL, SOURCE_FONT);
} else {
console.error(`❌ Source font not found: ${SOURCE_FONT}`);
console.log('💡 Please ensure AaCute.ttf exists in src/assets/fonts/');
process.exit(1);
}
}
console.log('✂️ Subsetting font...');
// 确保输出目录存在
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
const fontmin = new Fontmin()
.src(sourceFont)
.use(Fontmin.glyph({ text }))
.use(Fontmin.ttf2woff())
.dest(OUTPUT_DIR);
return new Promise((resolve, reject) => {
fontmin.run((err, files) => {
if (err) {
console.error('❌ Fontmin error:', err);
reject(err);
} else {
// 重命名输出文件
const generatedWoff = path.join(OUTPUT_DIR, 'AaCute-full.woff');
const targetFile = path.join(OUTPUT_DIR, OUTPUT_NAME);
if (fs.existsSync(generatedWoff)) {
// 如果目标文件存在,先删除
if (fs.existsSync(targetFile)) {
fs.unlinkSync(targetFile);
}
fs.renameSync(generatedWoff, targetFile);
}
// 清理生成的 TTF 文件
const generatedTtf = path.join(OUTPUT_DIR, 'AaCute-full.ttf');
if (fs.existsSync(generatedTtf)) {
fs.unlinkSync(generatedTtf);
}
if (fs.existsSync(targetFile)) {
const stats = fs.statSync(targetFile);
const sizeKB = (stats.size / 1024).toFixed(2);
console.log(`✅ Font subset created: ${targetFile} (${sizeKB} KB)`);
}
resolve();
}
});
});
}
run().catch(console.error);

View File

@ -7,7 +7,6 @@ import PageLoading from '@/components/page_loading';
import Toaster from '@/components/toaster';
import DialogProvider from '@/contexts/dialog';
import AudioProvider from '@/contexts/songs';
import useAuth from '@/hooks/auth';
@ -33,13 +32,11 @@ function App () {
<Provider store={store}>
<PageBackground />
<Toaster />
<AudioProvider>
<Suspense fallback={<PageLoading />}>
<AuthChecker>
<AppRoutes />
</AuthChecker>
</Suspense>
</AudioProvider>
<Suspense fallback={<PageLoading />}>
<AuthChecker>
<AppRoutes />
</AuthChecker>
</Suspense>
</Provider>
</DialogProvider>
);

View File

@ -1,425 +0,0 @@
import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card';
import { Image } from '@heroui/image';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
import { Slider } from '@heroui/slider';
import { Tooltip } from '@heroui/tooltip';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react';
import {
BiSolidSkipNextCircle,
BiSolidSkipPreviousCircle,
} from 'react-icons/bi';
import {
FaPause,
FaPlay,
FaRegHandPointRight,
FaRepeat,
FaShuffle,
} from 'react-icons/fa6';
import { TbRepeatOnce } from 'react-icons/tb';
import { useMediaQuery } from 'react-responsive';
import { PlayMode } from '@/const/enum';
import key from '@/const/key';
import { VolumeHighIcon, VolumeLowIcon } from './icons';
export interface AudioPlayerProps
extends React.AudioHTMLAttributes<HTMLAudioElement> {
src: string
title?: string
artist?: string
cover?: string
pressNext?: () => void
pressPrevious?: () => void
onPlayEnd?: () => void
onChangeMode?: (mode: PlayMode) => void
mode?: PlayMode
}
export default function AudioPlayer (props: AudioPlayerProps) {
const {
src,
pressNext,
pressPrevious,
cover = 'https://nextui.org/images/album-cover.png',
title = '未知',
artist = '未知',
onTimeUpdate,
onLoadedData,
onPlay,
onPause,
onPlayEnd,
onChangeMode,
autoPlay,
mode = PlayMode.Loop,
...rest
} = props;
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(100);
const [isCollapsed, setIsCollapsed] = useLocalStorage(
key.isCollapsedMusicPlayer,
false
);
const audioRef = useRef<HTMLAudioElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const startY = useRef(0);
const startX = useRef(0);
const [translateY, setTranslateY] = useState(0);
const [translateX, setTranslateX] = useState(0);
const isSmallScreen = useMediaQuery({ maxWidth: 767 });
const isMediumUp = useMediaQuery({ minWidth: 768 });
const shouldAdd = useRef(false);
const currentProgress = (currentTime / duration) * 100;
const [storageAutoPlay, setStorageAutoPlay] = useLocalStorage(
key.autoPlay,
true
);
const handleTimeUpdate = (event: React.SyntheticEvent<HTMLAudioElement>) => {
const audio = event.target as HTMLAudioElement;
setCurrentTime(audio.currentTime);
onTimeUpdate?.(event);
};
const handleLoadedData = (event: React.SyntheticEvent<HTMLAudioElement>) => {
const audio = event.target as HTMLAudioElement;
setDuration(audio.duration);
onLoadedData?.(event);
};
const handlePlay = (e: React.SyntheticEvent<HTMLAudioElement>) => {
setIsPlaying(true);
setStorageAutoPlay(true);
onPlay?.(e);
};
const handlePause = (e: React.SyntheticEvent<HTMLAudioElement>) => {
setIsPlaying(false);
onPause?.(e);
};
const changeMode = () => {
const modes = [PlayMode.Loop, PlayMode.Random, PlayMode.Single];
const currentIndex = modes.findIndex((_mode) => _mode === mode);
const nextIndex = currentIndex + 1;
const nextMode = modes[nextIndex] || modes[0];
onChangeMode?.(nextMode);
};
const volumeChange = (value: number) => {
setVolume(value);
};
useEffect(() => {
const audio = audioRef.current;
if (audio) {
audio.volume = volume / 100;
}
}, [volume]);
const handleTouchStart = (e: React.TouchEvent) => {
startY.current = e.touches[0].clientY;
startX.current = e.touches[0].clientX;
};
const handleTouchMove = (e: React.TouchEvent) => {
const deltaY = e.touches[0].clientY - startY.current;
const deltaX = e.touches[0].clientX - startX.current;
const container = cardRef.current;
const header = cardRef.current?.querySelector('[data-header]');
const headerHeight = header?.clientHeight || 20;
const addHeight = (container?.clientHeight || headerHeight) - headerHeight;
const _shouldAdd = isCollapsed && deltaY < 0;
if (isSmallScreen) {
shouldAdd.current = _shouldAdd;
setTranslateY(_shouldAdd ? deltaY + addHeight : deltaY);
} else {
setTranslateX(deltaX);
}
};
const handleTouchEnd = () => {
if (isSmallScreen) {
const container = cardRef.current;
const header = cardRef.current?.querySelector('[data-header]');
const headerHeight = header?.clientHeight || 20;
const addHeight = (container?.clientHeight || headerHeight) - headerHeight;
const _translateY = translateY - (shouldAdd.current ? addHeight : 0);
if (_translateY > 100) {
setIsCollapsed(true);
} else if (_translateY < -100) {
setIsCollapsed(false);
}
setTranslateY(0);
} else {
if (translateX > 100) {
setIsCollapsed(true);
} else if (translateX < -100) {
setIsCollapsed(false);
}
setTranslateX(0);
}
};
const dragTranslate = isSmallScreen
? translateY
? `translateY(${translateY}px)`
: ''
: translateX
? `translateX(${translateX}px)`
: '';
const collapsedTranslate = isCollapsed
? isSmallScreen
? 'translateY(90%)'
: 'translateX(96%)'
: '';
const translateStyle = dragTranslate || collapsedTranslate;
if (!src) return null;
return (
<div
className={clsx(
'fixed right-0 bottom-0 z-[52] w-full md:w-96',
!translateX && !translateY && 'transition-transform',
isCollapsed && 'md:hover:!translate-x-80'
)}
style={{
transform: translateStyle,
}}
>
<audio
src={src}
onLoadedData={handleLoadedData}
onTimeUpdate={handleTimeUpdate}
onPlay={handlePlay}
onPause={handlePause}
onEnded={onPlayEnd}
autoPlay={autoPlay ?? storageAutoPlay}
{...rest}
controls={false}
hidden
ref={audioRef}
/>
<Card
ref={cardRef}
className={clsx(
'border-none bg-background/60 dark:bg-default-300/50 w-full max-w-full transform transition-transform backdrop-blur-md duration-300 overflow-visible',
isSmallScreen ? 'rounded-t-3xl' : 'md:rounded-l-xl'
)}
classNames={{
body: 'p-0',
}}
shadow='sm'
radius='none'
>
{isMediumUp && (
<Button
isIconOnly
className={clsx(
'absolute data-[hover]:bg-foreground/10 text-lg z-50',
isCollapsed
? 'top-0 left-0 w-full h-full rounded-xl bg-opacity-0 hover:bg-opacity-30'
: 'top-3 -left-8 rounded-l-full bg-opacity-50 backdrop-blur-md'
)}
variant='solid'
color='primary'
size='sm'
onPress={() => setIsCollapsed(!isCollapsed)}
>
<FaRegHandPointRight />
</Button>
)}
{isSmallScreen && (
<CardHeader
data-header
className='flex-row justify-center pt-4'
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onClick={() => setIsCollapsed(!isCollapsed)}
>
<div className='w-24 h-2 rounded-full bg-content2-foreground shadow-sm' />
</CardHeader>
)}
<CardBody>
<div className='grid grid-cols-6 md:grid-cols-12 gap-6 md:gap-4 items-center justify-center overflow-hidden p-6 md:p-2 m-0'>
<div className='relative col-span-6 md:col-span-4 flex justify-center'>
<Image
alt='Album cover'
className='object-cover'
classNames={{
wrapper: 'w-36 aspect-square md:w-24 flex',
img: 'block w-full h-full',
}}
shadow='md'
src={cover}
width='100%'
/>
</div>
<div className='flex flex-col col-span-6 md:col-span-8'>
<div className='flex flex-col gap-0'>
<h1 className='font-medium truncate'>{title}</h1>
<p className='text-xs text-foreground/80 truncate'>{artist}</p>
</div>
<div className='flex flex-col'>
<Slider
aria-label='Music progress'
classNames={{
track: 'bg-default-500/30 border-none',
thumb: 'w-2 h-2 after:w-1.5 after:h-1.5',
filler: 'rounded-full',
}}
color='foreground'
value={currentProgress || 0}
defaultValue={0}
size='sm'
onChange={(value) => {
value = Array.isArray(value) ? value[0] : value;
const audio = audioRef.current;
if (audio) {
audio.currentTime = (value / 100) * duration;
}
}}
/>
<div className='flex justify-between h-3'>
<p className='text-xs'>
{Math.floor(currentTime / 60)}:
{Math.floor(currentTime % 60)
.toString()
.padStart(2, '0')}
</p>
<p className='text-xs text-foreground/50'>
{Math.floor(duration / 60)}:
{Math.floor(duration % 60)
.toString()
.padStart(2, '0')}
</p>
</div>
</div>
<div className='flex w-full items-center justify-center'>
<Tooltip
content={
mode === PlayMode.Loop
? '列表循环'
: mode === PlayMode.Random
? '随机播放'
: '单曲循环'
}
>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-lg md:text-medium'
radius='full'
variant='light'
size='md'
onPress={changeMode}
>
{mode === PlayMode.Loop && (
<FaRepeat className='text-foreground/80' />
)}
{mode === PlayMode.Random && (
<FaShuffle className='text-foreground/80' />
)}
{mode === PlayMode.Single && (
<TbRepeatOnce className='text-foreground/80 text-xl' />
)}
</Button>
</Tooltip>
<Tooltip content='上一首'>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
radius='full'
variant='light'
size='md'
onPress={pressPrevious}
>
<BiSolidSkipPreviousCircle />
</Button>
</Tooltip>
<Tooltip content={isPlaying ? '暂停' : '播放'}>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-3xl md:text-3xl'
radius='full'
variant='light'
size='lg'
onPress={() => {
if (isPlaying) {
audioRef.current?.pause();
setStorageAutoPlay(false);
} else {
audioRef.current?.play();
}
}}
>
{isPlaying ? <FaPause /> : <FaPlay className='ml-1' />}
</Button>
</Tooltip>
<Tooltip content='下一首'>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
radius='full'
variant='light'
size='md'
onPress={pressNext}
>
<BiSolidSkipNextCircle />
</Button>
</Tooltip>
<Popover
placement='top'
classNames={{
content: 'bg-opacity-30 backdrop-blur-md',
}}
>
<PopoverTrigger>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-xl md:text-xl'
radius='full'
variant='light'
size='md'
>
<VolumeHighIcon />
</Button>
</PopoverTrigger>
<PopoverContent>
<Slider
orientation='vertical'
showTooltip
aria-label='Volume'
className='h-40'
color='primary'
defaultValue={volume}
onChange={(value) => {
value = Array.isArray(value) ? value[0] : value;
volumeChange(value);
}}
startContent={<VolumeHighIcon className='text-2xl' />}
size='sm'
endContent={<VolumeLowIcon className='text-2xl' />}
/>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</CardBody>
</Card>
</div>
);
}

View File

@ -18,7 +18,7 @@ import {
} from '../icons';
export interface AddButtonProps {
onOpen: (key: keyof OneBotConfig['network']) => void
onOpen: (key: keyof OneBotConfig['network']) => void;
}
const AddButton: React.FC<AddButtonProps> = (props) => {
@ -33,7 +33,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
>
<DropdownTrigger>
<Button
color='primary'
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
startContent={<IoAddCircleOutline className='text-2xl' />}
>
@ -41,7 +41,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
</DropdownTrigger>
<DropdownMenu
aria-label='Create Network Config'
color='primary'
color='default'
variant='flat'
onAction={(key) => {
onOpen(key as keyof OneBotConfig['network']);

View File

@ -4,11 +4,11 @@ import toast from 'react-hot-toast';
import { IoMdRefresh } from 'react-icons/io';
export interface SaveButtonsProps {
onSubmit: () => void
reset: () => void
refresh?: () => void
isSubmitting: boolean
className?: string
onSubmit: () => void;
reset: () => void;
refresh?: () => void;
isSubmitting: boolean;
className?: string;
}
const SaveButtons: React.FC<SaveButtonsProps> = ({
@ -20,13 +20,15 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
}) => (
<div
className={clsx(
'max-w-full mx-3 w-96 flex flex-col justify-center gap-3',
'w-full flex flex-col justify-center gap-3',
className
)}
>
<div className='flex items-center justify-center gap-2 mt-5'>
<Button
color='default'
radius="full"
variant="flat"
className="font-medium bg-default-100 text-default-600 dark:bg-default-50/50"
onPress={() => {
reset();
toast.success('重置成功');
@ -36,6 +38,8 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
</Button>
<Button
color='primary'
radius="full"
className="font-medium shadow-md shadow-primary/20"
isLoading={isSubmitting}
onPress={() => onSubmit()}
>
@ -44,12 +48,12 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
{refresh && (
<Button
isIconOnly
color='secondary'
radius='full'
variant='flat'
className="text-default-500 bg-default-100 dark:bg-default-50/50"
onPress={() => refresh()}
>
<IoMdRefresh size={24} />
<IoMdRefresh size={20} />
</Button>
)}
</div>

View File

@ -10,14 +10,27 @@ import {
import ChatInput from '.';
export default function ChatInputModal () {
interface ChatInputModalProps {
children?: (onOpen: () => void) => React.ReactNode;
}
export default function ChatInputModal ({ children }: ChatInputModalProps) {
const { isOpen, onOpen, onOpenChange } = useDisclosure();
return (
<>
<Button onPress={onOpen} color='primary' radius='full' variant='flat'>
</Button>
{children ? children(onOpen) : (
<Button
onPress={onOpen}
color='primary'
radius='full'
variant='flat'
size='sm'
className="bg-primary/10 text-primary"
>
</Button>
)}
<Modal
size='4xl'
scrollBehavior='inside'

View File

@ -1,55 +1,160 @@
import Editor, { OnMount, loader } from '@monaco-editor/react';
import React from 'react';
import React, { useImperativeHandle, useEffect, useState } from 'react';
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { json } from '@codemirror/lang-json';
import { oneDark } from '@codemirror/theme-one-dark';
import { useTheme } from '@/hooks/use-theme';
import { EditorView } from '@codemirror/view';
import clsx from 'clsx';
import monaco from '@/monaco';
const getLanguageExtension = (lang?: string) => {
switch (lang) {
case 'json': return json();
default: return [];
}
};
loader.config({
monaco,
paths: {
vs: '/webui/monaco-editor/min/vs',
},
});
loader.config({
'vs/nls': {
availableLanguages: { '*': 'zh-cn' },
},
});
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
test?: string
export interface CodeEditorProps {
value?: string;
defaultValue?: string;
language?: string;
defaultLanguage?: string;
onChange?: (value: string | undefined) => void;
height?: string;
options?: any;
onMount?: any;
}
export type CodeEditorRef = monaco.editor.IStandaloneCodeEditor;
export interface CodeEditorRef {
getValue: () => string;
}
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>(
(props, ref) => {
const { isDark } = useTheme();
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>((props, ref) => {
const { isDark } = useTheme();
const chromeless = !!props.options?.chromeless;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [val, setVal] = useState(props.value || props.defaultValue || '');
const internalRef = React.useRef<ReactCodeMirrorRef>(null);
const handleEditorDidMount: OnMount = (editor, monaco) => {
if (ref) {
if (typeof ref === 'function') {
ref(editor);
} else {
(ref as React.RefObject<CodeEditorRef>).current = editor;
}
}
if (props.onMount) {
props.onMount(editor, monaco);
}
};
useEffect(() => {
if (props.value !== undefined) {
setVal(props.value);
}
}, [props.value]);
return (
<Editor
{...props}
onMount={handleEditorDidMount}
theme={isDark ? 'vs-dark' : 'light'}
useImperativeHandle(ref, () => ({
getValue: () => {
// Prefer getting dynamic value from view, fallback to state
return internalRef.current?.view?.state.doc.toString() || val;
}
}));
const customTheme = EditorView.theme({
"&": {
fontSize: "14px",
height: "100% !important",
backgroundColor: 'transparent !important',
},
"&.cm-editor": {
backgroundColor: 'transparent !important',
},
".cm-scroller": {
fontFamily: "var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace)",
lineHeight: "1.6",
overflow: "auto !important",
height: "100% !important",
backgroundColor: 'transparent !important',
},
".cm-gutters": {
backgroundColor: "transparent !important",
borderRight: "none",
color: isDark
? 'hsl(var(--heroui-foreground-500) / 0.75)'
: 'hsl(var(--heroui-foreground-500) / 0.65)',
},
".cm-gutterElement": {
paddingLeft: "12px",
paddingRight: "12px",
},
".cm-activeLineGutter": {
backgroundColor: 'transparent !important',
color: isDark
? 'hsl(var(--heroui-foreground) / 0.9) !important'
: 'hsl(var(--heroui-foreground) / 0.8) !important',
},
".cm-content": {
color: 'hsl(var(--heroui-foreground) / 0.9)',
caretColor: 'hsl(var(--heroui-foreground) / 0.9)',
paddingTop: "12px",
paddingBottom: "12px",
backgroundColor: 'transparent !important',
},
".cm-activeLine": {
backgroundColor: isDark
? 'hsl(var(--heroui-foreground) / 0.08)'
: 'hsl(var(--heroui-foreground) / 0.06)',
},
".cm-selectionMatch": {
backgroundColor: isDark
? 'hsl(var(--heroui-foreground) / 0.16)'
: 'hsl(var(--heroui-foreground) / 0.12)',
},
// Syntax highlighting overrides for better readability
".ͼo": {
// JSON property names - use a softer primary color
color: isDark
? 'hsl(var(--heroui-primary) / 0.85)'
: 'hsl(var(--heroui-primary) / 0.75)',
},
".ͼd": {
// Strings - softer green
color: isDark ? '#98c379cc' : '#50a14fcc',
},
".ͼc": {
// Numbers - softer orange
color: isDark ? '#d19a66cc' : '#c18401cc',
},
});
const extensions = [
customTheme,
getLanguageExtension(props.language || props.defaultLanguage),
props.options?.wordWrap === 'on' ? EditorView.lineWrapping : [],
props.options?.readOnly ? EditorView.editable.of(false) : [],
].flat();
return (
<div
style={{ fontSize: props.options?.fontSize || 14, height: props.height || '100%', display: 'flex', flexDirection: 'column' }}
className={clsx(
chromeless
? 'overflow-hidden transition-colors bg-transparent'
: 'rounded-xl border overflow-hidden transition-colors backdrop-blur-sm',
!chromeless && (isDark
? 'border-white/10 bg-white/5 text-default-100'
: 'border-white/40 dark:border-white/10 bg-white/60 dark:bg-black/20 text-default-700')
)}
>
<CodeMirror
ref={internalRef}
value={props.value ?? props.defaultValue}
height="100%"
className="h-full w-full [&_.cm-editor]:!bg-transparent [&_.cm-scroller]:!bg-transparent"
style={{ backgroundColor: 'transparent' }}
theme={isDark ? oneDark : 'light'}
extensions={extensions}
onChange={(value) => {
setVal(value);
props.onChange?.(value);
}}
readOnly={props.options?.readOnly}
basicSetup={{
lineNumbers: props.options?.lineNumbers !== 'off',
foldGutter: props.options?.folding !== false,
highlightActiveLine: props.options?.renderLineHighlight !== 'none',
}}
/>
);
}
);
</div>
);
});
export default CodeEditor;

View File

@ -0,0 +1,228 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@heroui/modal';
import clsx from 'clsx';
import { useEffect, useMemo, useRef, useState } from 'react';
import { TbCornerDownLeft, TbSearch } from 'react-icons/tb';
export type CommandPaletteCommand = {
id: string;
title: string;
subtitle?: string;
group?: string;
};
export type CommandPaletteExecuteMode = 'open' | 'send';
export interface CommandPaletteProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
commands: CommandPaletteCommand[];
onExecute: (commandId: string, mode: CommandPaletteExecuteMode) => void;
}
const isMobileByViewport = () => {
try {
return window.innerWidth < 768;
} catch {
return false;
}
};
export default function CommandPalette (props: CommandPaletteProps) {
const { isOpen, onOpenChange, commands, onExecute } = props;
const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const [mobile, setMobile] = useState(false);
useEffect(() => {
const update = () => setMobile(isMobileByViewport());
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
useEffect(() => {
if (!isOpen) return;
setQuery('');
setActiveIndex(0);
// 等 Modal 动画挂载后再 focus
const t = window.setTimeout(() => inputRef.current?.focus(), 50);
return () => window.clearTimeout(t);
}, [isOpen]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const list = !q
? commands
: commands.filter((c) => {
const hay = `${c.id} ${c.title} ${c.subtitle ?? ''} ${c.group ?? ''}`.toLowerCase();
return hay.includes(q);
});
// 简单:优先 path 前缀命中
if (!q) return list;
const starts = list.filter((c) => c.id.toLowerCase().startsWith(q));
const rest = list.filter((c) => !c.id.toLowerCase().startsWith(q));
return [...starts, ...rest];
}, [commands, query]);
useEffect(() => {
if (activeIndex >= filtered.length) setActiveIndex(0);
}, [filtered.length, activeIndex]);
const active = filtered[activeIndex];
const exec = (mode: CommandPaletteExecuteMode) => {
if (!active) return;
onExecute(active.id, mode);
onOpenChange(false);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, Math.max(0, filtered.length - 1)));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
return;
}
if (e.key === 'Enter') {
e.preventDefault();
// Shift+Enter 仅打开Enter 打开并发送
exec(e.shiftKey ? 'open' : 'send');
return;
}
if (e.key === 'Escape') {
e.preventDefault();
onOpenChange(false);
}
};
return (
<Modal
isOpen={isOpen}
onOpenChange={onOpenChange}
size={mobile ? 'full' : '2xl'}
radius={mobile ? 'none' : 'lg'}
scrollBehavior='inside'
backdrop='blur'
>
<ModalContent>
{() => (
<>
<ModalHeader className={clsx(
'flex items-center gap-2',
mobile ? 'border-b border-default-200/50' : ''
)}>
<span className='text-sm font-semibold'></span>
<span className='text-xs text-default-400 font-normal hidden md:inline'>Ctrl/Cmd + K</span>
</ModalHeader>
<ModalBody className={clsx('gap-3', mobile ? 'p-3' : 'p-4')}>
<Input
ref={inputRef as any}
autoFocus
value={query}
onValueChange={setQuery}
onKeyDown={onKeyDown}
placeholder='输入 /set_xxx 或 描述… Enter打开并发送Shift+Enter仅打开'
startContent={<TbSearch className='opacity-40' size={16} />}
radius='lg'
variant='flat'
classNames={{
inputWrapper: 'bg-content2/50 border border-default-200/50 dark:border-default-100/20',
input: 'text-sm',
}}
/>
<div className={clsx(
'rounded-xl border border-default-200/50 dark:border-default-100/20 overflow-hidden',
mobile ? 'flex-1 min-h-0' : 'max-h-[420px]'
)}>
<div className={clsx(
'divide-y divide-default-200/50 dark:divide-default-100/20 overflow-y-auto no-scrollbar',
mobile ? 'h-full' : 'max-h-[420px]'
)}>
{filtered.length === 0 && (
<div className='p-6 text-sm text-default-400'></div>
)}
{filtered.map((c, idx) => (
<button
key={c.id}
type='button'
className={clsx(
'w-full text-left px-4 py-3 transition-colors flex items-center gap-3',
idx === activeIndex
? 'bg-primary/10'
: 'hover:bg-default-100/50 dark:hover:bg-default-50/10'
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => {
setActiveIndex(idx);
exec('open');
}}
>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2 min-w-0'>
<span className='text-xs font-mono text-default-500 truncate'>{c.id}</span>
{c.group && (
<span className='text-[10px] px-2 py-0.5 rounded-full bg-default-100/60 dark:bg-default-50/20 text-default-500'>
{c.group}
</span>
)}
</div>
<div className='text-sm text-default-700 dark:text-default-200 truncate'>{c.title}</div>
{c.subtitle && (
<div className='text-xs text-default-400 truncate'>{c.subtitle}</div>
)}
</div>
<div className='flex items-center gap-2 flex-shrink-0'>
<span className='hidden md:inline text-[10px] text-default-400'>Enter</span>
<TbCornerDownLeft className='opacity-40' size={16} />
</div>
</button>
))}
</div>
</div>
</ModalBody>
{mobile && (
<ModalFooter className='border-t border-default-200/50'>
<Button radius='full' variant='flat' onPress={() => onOpenChange(false)}>
</Button>
<Button
radius='full'
variant='flat'
color='primary'
isDisabled={!active}
onPress={() => exec('open')}
>
</Button>
<Button
radius='full'
color='primary'
isDisabled={!active}
onPress={() => exec('send')}
>
</Button>
</ModalFooter>
)}
</>
)}
</ModalContent>
</Modal>
);
}

View File

@ -1,5 +1,6 @@
import { Button, ButtonGroup } from '@heroui/button';
import { Button } from '@heroui/button';
import { Switch } from '@heroui/switch';
import clsx from 'clsx';
import { useState } from 'react';
import { CgDebug } from 'react-icons/cg';
import { FiEdit3 } from 'react-icons/fi';
@ -10,33 +11,33 @@ import DisplayCardContainer from './container';
type NetworkType = OneBotConfig['network'];
export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{
label: string
value: NetworkType[T][0][keyof NetworkType[T][0]]
label: string;
value: NetworkType[T][0][keyof NetworkType[T][0]];
render?: (
value: NetworkType[T][0][keyof NetworkType[T][0]]
) => React.ReactNode
) => React.ReactNode;
}>;
export interface NetworkDisplayCardProps<T extends keyof NetworkType> {
data: NetworkType[T][0]
showType?: boolean
typeLabel: string
fields: NetworkDisplayCardFields<T>
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
data: NetworkType[T][0];
typeLabel: string;
fields: NetworkDisplayCardFields<T>;
onEdit: () => void;
onEnable: () => Promise<void>;
onDelete: () => Promise<void>;
onEnableDebug: () => Promise<void>;
showType?: boolean;
}
const NetworkDisplayCard = <T extends keyof NetworkType>({
const NetworkDisplayCard = <T extends keyof NetworkType> ({
data,
showType,
typeLabel,
fields,
onEdit,
onEnable,
onDelete,
onEnableDebug,
showType,
}: NetworkDisplayCardProps<T>) => {
const { name, enable, debug } = data;
const [editing, setEditing] = useState(false);
@ -56,79 +57,149 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
onEnableDebug().finally(() => setEditing(false));
};
const isFullWidthField = (label: string) => ['URL', 'Token', 'AccessToken'].includes(label);
return (
<DisplayCardContainer
className='w-full max-w-[420px]'
tag={showType ? typeLabel : undefined}
action={
<ButtonGroup
fullWidth
isDisabled={editing}
radius='sm'
size='sm'
variant='flat'
>
<div className='flex gap-2 w-full'>
<Button
color='warning'
fullWidth
radius='full'
size='sm'
variant='flat'
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-warning/20 hover:text-warning transition-colors'
startContent={<FiEdit3 size={16} />}
onPress={onEdit}
isDisabled={editing}
>
</Button>
<Button
color={debug ? 'secondary' : 'success'}
fullWidth
radius='full'
size='sm'
variant='flat'
startContent={
<CgDebug
style={{
width: '16px',
height: '16px',
minWidth: '16px',
minHeight: '16px',
}}
/>
}
className={clsx(
'flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors',
debug
? 'hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary'
: 'hover:bg-success/20 hover:text-success data-[hover=true]:text-success'
)}
startContent={<CgDebug size={16} />}
onPress={handleEnableDebug}
isDisabled={editing}
>
{debug ? '关闭调试' : '开启调试'}
</Button>
<Button
className='bg-danger/20 text-danger hover:bg-danger/30 transition-colors'
fullWidth
radius='full'
size='sm'
variant='flat'
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-danger/20 hover:text-danger transition-colors'
startContent={<MdDeleteForever size={16} />}
onPress={handleDelete}
isDisabled={editing}
>
</Button>
</ButtonGroup>
</div>
}
enableSwitch={
<Switch
isDisabled={editing}
isSelected={enable}
onChange={handleEnable}
classNames={{
wrapper: 'group-data-[selected=true]:bg-primary-400',
}}
/>
}
tag={showType && typeLabel}
title={name}
>
<div className='grid grid-cols-2 gap-1'>
{fields.map((field, index) => (
<div
key={index}
className={`flex items-center gap-2 ${
field.label === 'URL' ? 'col-span-2' : ''
}`}
>
<span className='text-default-400'>{field.label}</span>
{field.render
? (
field.render(field.value)
)
: (
<span>{field.value}</span>
)}
</div>
))}
<div className='grid grid-cols-2 gap-3'>
{(() => {
const targetFullField = fields.find(f => isFullWidthField(f.label));
if (targetFullField) {
// 模式1存在全宽字段如URL布局为
// Row 1: 类型 (全宽)
// Row 2: 全宽字段 (全宽)
return (
<>
<div
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'></span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{typeLabel}
</div>
</div>
<div
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{targetFullField.label}</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{targetFullField.render
? targetFullField.render(targetFullField.value)
: (
<span className={clsx(
typeof targetFullField.value === 'string' && (targetFullField.value.startsWith('http') || targetFullField.value.includes('.') || targetFullField.value.includes(':')) ? 'font-mono' : ''
)}
>
{String(targetFullField.value)}
</span>
)}
</div>
</div>
</>
);
} else {
// 模式2无全宽字段布局为 4 个小块 (2行 x 2列)
// Row 1: 类型 | Field 0
// Row 2: Field 1 | Field 2
const displayFields = fields.slice(0, 3);
return (
<>
<div
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'></span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{typeLabel}
</div>
</div>
{displayFields.map((field, index) => (
<div
key={index}
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{field.label}</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{field.render
? (
field.render(field.value)
)
: (
<span className={clsx(
typeof field.value === 'string' && (field.value.startsWith('http') || field.value.includes('.') || field.value.includes(':')) ? 'font-mono' : ''
)}
>
{String(field.value)}
</span>
)}
</div>
</div>
))}
{/* 如果字段不足3个可以补充空白块占位吗或者是让它空着用户说要高度一致。只要是grid通常高度会被撑开。目前这样应该能保证最多2行。 */}
</>
);
}
})()}
</div>
</DisplayCardContainer>
);

View File

@ -1,55 +1,54 @@
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { title } from '../primitives';
import key from '@/const/key';
export interface ContainerProps {
title: string
tag?: React.ReactNode
action: React.ReactNode
enableSwitch: React.ReactNode
children: React.ReactNode
title: string;
tag?: React.ReactNode;
action: React.ReactNode;
enableSwitch: React.ReactNode;
children: React.ReactNode;
className?: string; // Add className prop
}
export interface DisplayCardProps {
showType?: boolean
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
showType?: boolean;
onEdit: () => void;
onEnable: () => Promise<void>;
onDelete: () => Promise<void>;
onEnableDebug: () => Promise<void>;
}
const DisplayCardContainer: React.FC<ContainerProps> = ({
title: _title,
action,
tag,
enableSwitch,
children,
className,
}) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return (
<Card className='bg-opacity-50 backdrop-blur-sm'>
<CardHeader className='pb-0 flex items-center'>
{tag && (
<div className='text-center text-default-400 mb-1 absolute top-0 left-1/2 -translate-x-1/2 text-sm pointer-events-none bg-warning-100 dark:bg-warning-50 px-2 rounded-b'>
{tag}
<Card className={clsx(
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm rounded-2xl overflow-hidden transition-all',
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40',
className
)}
>
<CardHeader className='p-4 pb-2 flex items-center justify-between gap-3'>
<div className='flex-1 min-w-0 mr-2'>
<div className='inline-flex items-center px-3 py-1 rounded-lg bg-default-100/50 dark:bg-white/10 border border-transparent dark:border-white/5'>
<span className='font-bold text-default-600 dark:text-white/90 text-sm truncate select-text'>
{_title}
</span>
</div>
)}
<h2
className={clsx(
title({
color: 'foreground',
size: 'xs',
shadow: true,
}),
'truncate'
)}
>
{_title}
</h2>
<div className='ml-auto'>{enableSwitch}</div>
</div>
<div className='flex-shrink-0'>{enableSwitch}</div>
</CardHeader>
<CardBody className='text-sm'>{children}</CardBody>
<CardFooter>{action}</CardFooter>
<CardBody className='px-4 py-2 text-sm text-default-600'>{children}</CardBody>
<CardFooter className='px-4 pb-4 pt-2'>{action}</CardFooter>
</Card>
);
};

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card';
interface HTTPClientDisplayCardProps {
data: OneBotConfig['network']['httpClients'][0]
showType?: boolean
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
data: OneBotConfig['network']['httpClients'][0];
showType?: boolean;
onEdit: () => void;
onEnable: () => Promise<void>;
onDelete: () => Promise<void>;
onEnableDebug: () => Promise<void>;
}
const HTTPClientDisplayCard: React.FC<HTTPClientDisplayCardProps> = (props) => {

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card';
interface HTTPServerDisplayCardProps {
data: OneBotConfig['network']['httpServers'][0]
showType?: boolean
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
data: OneBotConfig['network']['httpServers'][0];
showType?: boolean;
onEdit: () => void;
onEnable: () => Promise<void>;
onDelete: () => Promise<void>;
onEnableDebug: () => Promise<void>;
}
const HTTPServerDisplayCard: React.FC<HTTPServerDisplayCardProps> = (props) => {

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card';
interface HTTPSSEServerDisplayCardProps {
data: OneBotConfig['network']['httpSseServers'][0]
showType?: boolean
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
data: OneBotConfig['network']['httpSseServers'][0];
showType?: boolean;
onEdit: () => void;
onEnable: () => Promise<void>;
onDelete: () => Promise<void>;
onEnableDebug: () => Promise<void>;
}
const HTTPSSEServerDisplayCard: React.FC<HTTPSSEServerDisplayCardProps> = (

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card';
interface WebsocketClientDisplayCardProps {
data: OneBotConfig['network']['websocketClients'][0]
showType?: boolean
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
data: OneBotConfig['network']['websocketClients'][0];
showType?: boolean;
onEdit: () => void;
onEnable: () => Promise<void>;
onDelete: () => Promise<void>;
onEnableDebug: () => Promise<void>;
}
const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = (

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card';
interface WebsocketServerDisplayCardProps {
data: OneBotConfig['network']['websocketServers'][0]
showType?: boolean
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
data: OneBotConfig['network']['websocketServers'][0];
showType?: boolean;
onEdit: () => void;
onEnable: () => Promise<void>;
onDelete: () => Promise<void>;
onEnableDebug: () => Promise<void>;
}
const WebsocketServerDisplayCard: React.FC<WebsocketServerDisplayCardProps> = (

View File

@ -1,12 +1,14 @@
import { Card, CardBody } from '@heroui/card';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import key from '@/const/key';
import { title } from '@/components/primitives';
export interface NetworkItemDisplayProps {
count: number
label: string
size?: 'sm' | 'md'
count: number;
label: string;
size?: 'sm' | 'md';
}
const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
@ -14,38 +16,37 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
label,
size = 'md',
}) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return (
<Card
className={clsx(
'bg-opacity-60 shadow-sm md:rounded-3xl',
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all',
hasBackground
? 'bg-white/10 dark:bg-black/10 hover:bg-white/20 dark:hover:bg-black/20'
: 'bg-white/60 dark:bg-black/40 hover:bg-white/70 dark:hover:bg-black/30',
size === 'md'
? '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'
? 'col-span-8 md:col-span-2'
: 'col-span-2 md:col-span-1'
)}
shadow='sm'
shadow='none'
>
<CardBody className='items-center md:gap-1 p-1 md:p-2'>
<div
className={clsx(
'flex-1',
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
title({
color: size === 'md' ? 'pink' : 'yellow',
size,
})
'flex-1 font-mono font-bold',
size === 'md' ? 'text-4xl md:text-5xl' : 'text-2xl md:text-3xl',
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
)}
>
{count}
</div>
<div
className={clsx(
'whitespace-nowrap text-nowrap flex-shrink-0',
size === 'md' ? 'text-sm md:text-base' : 'text-xs md:text-sm',
title({
color: size === 'md' ? 'pink' : 'yellow',
shadow: true,
size: 'xxs',
})
'whitespace-nowrap text-nowrap flex-shrink-0 font-medium',
size === 'md' ? 'text-sm' : 'text-xs',
hasBackground ? 'text-white/80' : 'text-default-500'
)}
>
{label}

View File

@ -94,7 +94,7 @@ const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
ref={lightRef}
className={clsx(
isShowLight ? 'opacity-100' : 'opacity-0',
'absolute rounded-full blur-[150px] filter transition-opacity duration-300 dark:bg-[#2850ff] bg-[#ff4132] w-[100px] h-[100px]',
'absolute rounded-full blur-[100px] filter transition-opacity duration-300 bg-gradient-to-r from-primary-400 to-secondary-400 w-[150px] h-[150px]',
lightClassName
)}
style={{

View File

@ -9,13 +9,13 @@ import {
} from '@heroui/modal';
interface CreateFileModalProps {
isOpen: boolean
fileType: 'file' | 'directory'
newFileName: string
onTypeChange: (type: 'file' | 'directory') => void
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onClose: () => void
onCreate: () => void
isOpen: boolean;
fileType: 'file' | 'directory';
newFileName: string;
onTypeChange: (type: 'file' | 'directory') => void;
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onClose: () => void;
onCreate: () => void;
}
export default function CreateFileModal ({
@ -28,12 +28,12 @@ export default function CreateFileModal ({
onCreate,
}: CreateFileModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<Modal radius='sm' isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<div className='flex flex-col gap-4'>
<ButtonGroup color='primary'>
<ButtonGroup radius='sm' color='primary'>
<Button
variant={fileType === 'file' ? 'solid' : 'flat'}
onPress={() => onTypeChange('file')}
@ -47,14 +47,14 @@ export default function CreateFileModal ({
</Button>
</ButtonGroup>
<Input label='名称' value={newFileName} onChange={onNameChange} />
<Input radius='sm' label='名称' value={newFileName} onChange={onNameChange} />
</div>
</ModalBody>
<ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}>
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
</Button>
<Button color='primary' onPress={onCreate}>
<Button radius='sm' color='primary' onPress={onCreate}>
</Button>
</ModalFooter>

View File

@ -11,11 +11,11 @@ import {
import CodeEditor from '@/components/code_editor';
interface FileEditModalProps {
isOpen: boolean
file: { path: string; content: string } | null
onClose: () => void
onSave: () => void
onContentChange: (newContent?: string) => void
isOpen: boolean;
file: { path: string; content: string; } | null;
onClose: () => void;
onSave: () => void;
onContentChange: (newContent?: string) => void;
}
export default function FileEditModal ({
@ -63,14 +63,22 @@ export default function FileEditModal ({
};
return (
<Modal size='full' isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader className='flex items-center gap-2 bg-content2 bg-opacity-50'>
<Modal radius='sm' size='full' isOpen={isOpen} onClose={onClose} scrollBehavior="inside">
<ModalContent className="flex flex-col h-full max-h-[100dvh]">
<ModalHeader className='flex items-center gap-2 border-b border-default-200/50 flex-shrink-0'>
<span></span>
<Code className='text-xs'>{file?.path}</Code>
<Code radius='sm' className='text-xs'>{file?.path}</Code>
<div className="ml-auto text-xs text-default-400 font-normal px-2">
<span className="px-1 py-0.5 rounded border border-default-300 bg-default-100">Ctrl/Cmd + S</span>
</div>
</ModalHeader>
<ModalBody className='p-0'>
<div className='h-full'>
<ModalBody className='p-4 bg-content2/50 flex-1 min-h-0 overflow-hidden'>
<div className='h-full w-full overflow-auto' onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
onSave();
}
}}>
<CodeEditor
height='100%'
value={file?.content || ''}
@ -80,11 +88,11 @@ export default function FileEditModal ({
/>
</div>
</ModalBody>
<ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}>
<ModalFooter className="border-t border-default-200/50 flex-shrink-0">
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
</Button>
<Button color='primary' onPress={onSave}>
<Button radius='sm' color='primary' onPress={onSave}>
</Button>
</ModalFooter>

View File

@ -14,9 +14,9 @@ import { useEffect } from 'react';
import FileManager from '@/controllers/file_manager';
interface FilePreviewModalProps {
isOpen: boolean
filePath: string
onClose: () => void
isOpen: boolean;
filePath: string;
onClose: () => void;
}
export const videoExts = ['.mp4', '.webm'];
@ -75,14 +75,14 @@ export default function FilePreviewModal ({
}
return (
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior='inside' size='3xl'>
<Modal radius='sm' isOpen={isOpen} onClose={onClose} scrollBehavior='inside' size='3xl'>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody className='flex justify-center items-center'>
{contentElement}
</ModalBody>
<ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}>
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
</Button>
</ModalFooter>

View File

@ -25,21 +25,21 @@ 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
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;
@ -105,6 +105,7 @@ export default function FileTable ({
/>
<Table
aria-label='文件列表'
radius='sm'
sortDescriptor={sortDescriptor}
onSortChange={onSortChange}
onSelectionChange={onSelectionChange}
@ -112,7 +113,7 @@ export default function FileTable ({
selectedKeys={selectedFiles}
selectionMode='multiple'
bottomContent={
<div className='flex w-full justify-center'>
<div className='flex w-full justify-center p-2 border-t border-white/10'>
<Pagination
isCompact
showControls
@ -121,21 +122,29 @@ export default function FileTable ({
page={page}
total={pages}
onChange={(page) => setPage(page)}
classNames={{
cursor: 'bg-primary shadow-lg',
}}
/>
</div>
}
classNames={{
wrapper: 'bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm p-0',
th: 'bg-white/40 dark:bg-white/5 backdrop-blur-md text-default-600',
td: 'group-data-[first=true]:first:before:rounded-none group-data-[first=true]:last:before:rounded-none',
}}
>
<TableHeader>
<TableColumn key='name' allowsSorting>
</TableColumn>
<TableColumn key='type' allowsSorting>
<TableColumn key='type' allowsSorting className='hidden md:table-cell'>
</TableColumn>
<TableColumn key='size' allowsSorting>
<TableColumn key='size' allowsSorting className='hidden md:table-cell'>
</TableColumn>
<TableColumn key='mtime' allowsSorting>
<TableColumn key='mtime' allowsSorting className='hidden md:table-cell'>
</TableColumn>
<TableColumn key='actions'></TableColumn>
@ -167,6 +176,7 @@ export default function FileTable ({
)
: (
<Button
radius='sm'
variant='light'
onPress={() =>
file.isDirectory
@ -180,57 +190,57 @@ export default function FileTable ({
name={file.name}
isDirectory={file.isDirectory}
/>
}
}
>
{file.name}
</Button>
)}
</TableCell>
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
<TableCell>
<TableCell className='hidden md:table-cell'>{file.isDirectory ? '目录' : '文件'}</TableCell>
<TableCell className='hidden md:table-cell'>
{isNaN(file.size) || file.isDirectory
? '-'
: `${file.size} 字节`}
</TableCell>
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
<TableCell className='hidden md:table-cell'>{new Date(file.mtime).toLocaleString()}</TableCell>
<TableCell>
<ButtonGroup size='sm'>
<ButtonGroup radius='sm' size='sm' variant='light'>
<Button
isIconOnly
color='primary'
variant='flat'
color='default'
className='text-default-500 hover:text-primary'
onPress={() => onRenameRequest(file.name)}
>
<BiRename />
</Button>
<Button
isIconOnly
color='primary'
variant='flat'
color='default'
className='text-default-500 hover:text-primary'
onPress={() => onMoveRequest(file.name)}
>
<FiMove />
</Button>
<Button
isIconOnly
color='primary'
variant='flat'
color='default'
className='text-default-500 hover:text-primary'
onPress={() => onCopyPath(file.name)}
>
<FiCopy />
</Button>
<Button
isIconOnly
color='primary'
variant='flat'
color='default'
className='text-default-500 hover:text-primary'
onPress={() => onDownload(filePath)}
>
<FiDownload />
</Button>
<Button
isIconOnly
color='primary'
variant='flat'
color='danger'
className='text-danger hover:bg-danger/10'
onPress={() => onDelete(filePath)}
>
<FiTrash2 />

View File

@ -10,17 +10,17 @@ import FileManager from '@/controllers/file_manager';
import FileIcon from '../file_icon';
export interface PreviewImage {
key: string
src: string
alt: string
key: string;
src: string;
alt: string;
}
export const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp'];
export interface ImageNameButtonProps {
name: string
filePath: string
onPreview: () => void
onAddPreview: (image: PreviewImage) => void
name: string;
filePath: string;
onPreview: () => void;
onAddPreview: (image: PreviewImage) => void;
}
export default function ImageNameButton ({
@ -61,6 +61,7 @@ export default function ImageNameButton ({
return (
<Button
radius='sm'
variant='light'
className='text-left justify-start'
onPress={onPreview}

View File

@ -83,15 +83,16 @@ function DirectoryTree ({
return (
<div className='ml-4'>
<Button
radius='sm'
onPress={handleClick}
className='py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md'
className='py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-sm'
size='sm'
color='primary'
variant={variant}
startContent={
<div
className={clsx(
'rounded-md',
'rounded-sm',
isSeleted ? 'bg-primary-600' : 'bg-primary-50'
)}
>
@ -140,11 +141,11 @@ export default function MoveModal ({
onSelect,
}: MoveModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<Modal radius='sm' isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<div className='rounded-md p-2 border border-default-300 overflow-auto max-h-60'>
<div className='rounded-sm p-2 border border-default-300 overflow-auto max-h-60'>
<DirectoryTree
basePath='/'
onSelect={onSelect}
@ -157,10 +158,10 @@ export default function MoveModal ({
<p className='text-sm text-default-500'>{selectionInfo}</p>
</ModalBody>
<ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}>
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
</Button>
<Button color='primary' onPress={onMove}>
<Button radius='sm' color='primary' onPress={onMove}>
</Button>
</ModalFooter>

View File

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

View File

@ -1,9 +1,15 @@
/* eslint-disable @stylistic/jsx-closing-bracket-location */
/* eslint-disable @stylistic/jsx-closing-tag-location */
import { Button } from '@heroui/button';
import { Tooltip } from '@heroui/tooltip';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useRequest } from 'ahooks';
import clsx from 'clsx';
import toast from 'react-hot-toast';
import { IoMdQuote } from 'react-icons/io';
import { IoCopy, IoRefresh } from 'react-icons/io5';
import key from '@/const/key';
import { request } from '@/utils/request';
import PageLoading from './page_loading';
@ -15,10 +21,17 @@ export default function Hitokoto () {
loading,
run,
} = useRequest(() => request.get<IHitokoto>('https://hitokoto.152710.xyz/'), {
pollingInterval: 10000,
throttleWait: 1000,
});
const data = dataOri?.data;
const backupData = {
hitokoto: '凡是过往,皆为序章。',
from: '暴风雨',
from_who: '莎士比亚',
};
const data = dataOri?.data || (error ? backupData : undefined);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const onCopy = () => {
try {
const text = `${data?.hitokoto} —— ${data?.from} ${data?.from_who}`;
@ -29,45 +42,68 @@ export default function Hitokoto () {
}
};
return (
<div>
<div className='relative'>
{loading && <PageLoading />}
{error
? (
<div className='text-primary-400'>{error.message}</div>
)
: (
<>
<div>{data?.hitokoto}</div>
<div className='text-right'>
<span className='text-default-400'>{data?.from}</span>{' '}
{data?.from_who}
</div>
</>
)}
<div className='overflow-hidden'>
<div className='relative flex flex-col items-center justify-center p-4 md:p-6'>
{loading && !data && <PageLoading />}
{data && (
<>
<IoMdQuote className={clsx(
'text-4xl mb-4',
hasBackground ? 'text-white/30' : 'text-primary/20'
)}
/>
<div className={clsx(
'text-xl font-medium tracking-wide leading-relaxed italic',
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
)}
>
" {data?.hitokoto} "
</div>
<div className='mt-4 flex flex-col items-center text-sm'>
<span className={clsx(
'font-bold',
hasBackground ? 'text-white/90' : 'text-primary-500/80'
)}
> {data?.from}
</span>
{data?.from_who && <span className={clsx(
'text-xs mt-1',
hasBackground ? 'text-white/70' : 'text-default-400'
)}
> {data?.from_who}
</span>}
</div>
</>
)}
</div>
<div className='flex gap-2'>
<Tooltip content='刷新' placement='top'>
<Button
className={clsx(
'transition-colors',
hasBackground ? 'text-white/60 hover:text-white' : 'text-default-400 hover:text-primary'
)}
onPress={run}
size='sm'
isLoading={loading}
isIconOnly
radius='full'
color='primary'
variant='flat'
variant='light'
>
<IoRefresh />
</Button>
</Tooltip>
<Tooltip content='复制' placement='top'>
<Button
className={clsx(
'transition-colors',
hasBackground ? 'text-white/60 hover:text-white' : 'text-default-400 hover:text-success'
)}
onPress={onCopy}
size='sm'
isIconOnly
radius='full'
color='success'
variant='flat'
variant='light'
>
<IoCopy />
</Button>

View File

@ -7,6 +7,7 @@ export interface FileInputProps {
onDelete?: () => Promise<void> | void;
label?: string;
accept?: string;
placeholder?: string;
}
const FileInput: React.FC<FileInputProps> = ({
@ -14,6 +15,7 @@ const FileInput: React.FC<FileInputProps> = ({
onDelete,
label,
accept,
placeholder,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
@ -25,8 +27,13 @@ const FileInput: React.FC<FileInputProps> = ({
ref={inputRef}
label={label}
type='file'
placeholder='选择文件'
placeholder={placeholder || '选择文件'}
accept={accept}
classNames={{
inputWrapper:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
onChange={async (e) => {
try {
setIsLoading(true);

View File

@ -4,9 +4,9 @@ import { Input } from '@heroui/input';
import { useRef } from 'react';
export interface ImageInputProps {
onChange: (base64: string) => void
value: string
label?: string
onChange: (base64: string) => void;
value: string;
label?: string;
}
const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
@ -26,6 +26,11 @@ const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
type='file'
placeholder='选择图片'
accept='image/*'
classNames={{
inputWrapper:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {

View File

@ -2,8 +2,11 @@ import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card';
import { Select, SelectItem } from '@heroui/select';
import type { Selection } from '@react-types/shared';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react';
import key from '@/const/key';
import { colorizeLogLevel } from '@/utils/terminal';
import PageLoading from '../page_loading';
@ -12,15 +15,15 @@ import type { XTermRef } from '../xterm';
import LogLevelSelect from './log_level_select';
export interface HistoryLogsProps {
list: string[]
onSelect: (name: string) => void
selectedLog?: string
refreshList: () => void
refreshLog: () => void
listLoading?: boolean
logLoading?: boolean
listError?: Error
logContent?: string
list: string[];
onSelect: (name: string) => void;
selectedLog?: string;
refreshList: () => void;
refreshLog: () => void;
listLoading?: boolean;
logLoading?: boolean;
listError?: Error;
logContent?: string;
}
const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
const {
@ -39,6 +42,8 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
const [logLevel, setLogLevel] = useState<Selection>(
new Set(['info', 'warn', 'error'])
);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const logToColored = (log: string) => {
const logs = log
@ -83,7 +88,10 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
return (
<>
<title> - NapCat WebUI</title>
<Card className='max-w-full h-full bg-opacity-50 backdrop-blur-sm'>
<Card className={clsx(
'max-w-full h-full backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm',
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
)}>
<CardHeader className='flex-row justify-start gap-3'>
<Select
label='选择日志'
@ -92,7 +100,7 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
errorMessage={listError?.message}
classNames={{
trigger:
'hover:!bg-content3 bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60',
'bg-default-100/50 backdrop-blur-sm hover:!bg-default-200/50',
}}
placeholder='选择日志'
onChange={(e) => {
@ -118,11 +126,13 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
selectedKeys={logLevel}
onSelectionChange={setLogLevel}
/>
<Button className='flex-shrink-0' onPress={onDownloadLog}>
</Button>
<Button onPress={refreshList}></Button>
<Button onPress={refreshLog}></Button>
<div className='flex gap-2 ml-auto'>
<Button className='flex-shrink-0' onPress={onDownloadLog} size='sm' variant='flat' color='primary'>
</Button>
<Button onPress={refreshList} size='sm' variant='flat'></Button>
<Button onPress={refreshLog} size='sm' variant='flat'></Button>
</div>
</CardHeader>
<CardBody className='relative'>
<PageLoading loading={logLoading} />

View File

@ -6,17 +6,17 @@ import type { Selection } from '@react-types/shared';
import { LogLevel } from '@/const/enum';
export interface LogLevelSelectProps {
selectedKeys: Selection
onSelectionChange: (keys: SharedSelection) => void
selectedKeys: Selection;
onSelectionChange: (keys: SharedSelection) => void;
}
const logLevelColor: {
[key in LogLevel]:
| 'default'
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'primary'
| 'default'
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'primary'
} = {
[LogLevel.DEBUG]: 'default',
[LogLevel.INFO]: 'primary',
@ -40,7 +40,7 @@ const LogLevelSelect = (props: LogLevelSelectProps) => {
aria-label='Log Level'
classNames={{
label: 'mb-2',
trigger: 'bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60',
trigger: 'bg-default-100/50 backdrop-blur-sm hover:!bg-default-200/50',
popoverContent: 'bg-opacity-50 backdrop-blur-sm',
}}
size='sm'

View File

@ -1,9 +1,12 @@
import { Button } from '@heroui/button';
import type { Selection } from '@react-types/shared';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react';
import toast from 'react-hot-toast';
import { IoDownloadOutline } from 'react-icons/io5';
import key from '@/const/key';
import { colorizeLogLevelWithTag } from '@/utils/terminal';
import WebUIManager, { Log } from '@/controllers/webui_manager';
@ -18,6 +21,8 @@ const RealTimeLogs = () => {
new Set(['info', 'warn', 'error'])
);
const [dataArr, setDataArr] = useState<Log[]>([]);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const onDownloadLog = () => {
const logContent = dataArr
@ -91,7 +96,10 @@ const RealTimeLogs = () => {
return (
<>
<title> - NapCat WebUI</title>
<div className='flex items-center gap-2'>
<div className={clsx(
'flex items-center gap-2 p-2 rounded-2xl border backdrop-blur-sm transition-all shadow-sm mb-4',
hasBackground ? 'bg-white/20 dark:bg-black/10 border-white/40 dark:border-white/10' : 'bg-white/60 dark:bg-black/40 border-white/40 dark:border-white/10'
)}>
<LogLevelSelect
selectedKeys={logLevel}
onSelectionChange={setLogLevel}
@ -100,6 +108,8 @@ const RealTimeLogs = () => {
className='flex-shrink-0'
onPress={onDownloadLog}
startContent={<IoDownloadOutline className='text-lg' />}
color='primary'
variant='flat'
>
</Button>

View File

@ -10,18 +10,19 @@ import {
import React from 'react';
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
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;
hideFooter?: boolean;
}
const Modal: React.FC<ModalProps> = React.memo((props) => {
@ -33,6 +34,7 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
dismissible,
confirmText = '确定',
cancelText = '取消',
hideFooter = false,
onClose,
onConfirm,
onCancel,
@ -62,29 +64,31 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
<ModalHeader className='flex flex-col gap-1'>{title}</ModalHeader>
)}
<ModalBody className='break-all'>{content}</ModalBody>
<ModalFooter>
{showCancel && (
{!hideFooter && (
<ModalFooter>
{showCancel && (
<Button
color='primary'
variant='light'
onPress={() => {
onCancel?.();
nativeClose();
}}
>
{cancelText}
</Button>
)}
<Button
color='primary'
variant='light'
onPress={() => {
onCancel?.();
onConfirm?.();
nativeClose();
}}
>
{cancelText}
{confirmText}
</Button>
)}
<Button
color='primary'
onPress={() => {
onConfirm?.();
nativeClose();
}}
>
{confirmText}
</Button>
</ModalFooter>
</ModalFooter>
)}
</>
)}
</ModalContent>

View File

@ -109,6 +109,11 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
isDisabled={field.isDisabled}
label={field.label}
placeholder={field.placeholder}
classNames={{
inputWrapper:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
/>
);
case 'select':
@ -121,6 +126,10 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
placeholder={field.placeholder}
selectedKeys={[controllerField.value as string]}
value={controllerField.value.toString()}
classNames={{
trigger: 'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
value: 'text-default-700',
}}
>
{field.options?.map((option) => (
<SelectItem key={option.key} value={option.value}>

View File

@ -1,13 +1,16 @@
import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card';
import { Input } from '@heroui/input';
import { Snippet } from '@heroui/snippet';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
import { Tooltip } from '@heroui/tooltip';
import { Tab, Tabs } from '@heroui/tabs';
import { Chip } from '@heroui/chip';
import { useLocalStorage } from '@uidotdev/usehooks';
import { motion } from 'motion/react';
import { useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import { forwardRef, useEffect, useImperativeHandle, useState, useCallback } from 'react';
import toast from 'react-hot-toast';
import { IoLink, IoSend } from 'react-icons/io5';
import { PiCatDuotone } from 'react-icons/pi';
import { IoChevronDown, IoSend, IoSettingsSharp, IoCopy } from 'react-icons/io5';
import { TbCode, TbMessageCode } from 'react-icons/tb';
import key from '@/const/key';
import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api';
@ -17,7 +20,7 @@ import CodeEditor from '@/components/code_editor';
import PageLoading from '@/components/page_loading';
import { request } from '@/utils/request';
import { parseAxiosResponse } from '@/utils/url';
import { generateDefaultJson, parse } from '@/utils/zod';
import DisplayStruct from './display_struct';
@ -25,32 +28,82 @@ import DisplayStruct from './display_struct';
export interface OneBotApiDebugProps {
path: OneBotHttpApiPath;
data: OneBotHttpApiContent;
adapterName?: string;
}
const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
const { path, data } = props;
export interface OneBotApiDebugRef {
setRequestBody: (value: string) => void;
sendWithBody: (value: string) => void;
focusRequestEditor: () => void;
}
const OneBotApiDebug = forwardRef<OneBotApiDebugRef, OneBotApiDebugProps>((props, ref) => {
const { path, data, adapterName } = props;
const currentURL = new URL(window.location.origin);
currentURL.port = '3000';
const defaultHttpUrl = currentURL.href;
const defaultToken = localStorage.getItem('token') || '';
const [httpConfig, setHttpConfig] = useLocalStorage(key.httpDebugConfig, {
url: defaultHttpUrl,
token: '',
token: defaultToken,
});
const [requestBody, setRequestBody] = useState('{}');
const [responseContent, setResponseContent] = useState('');
const [isCodeEditorOpen, setIsCodeEditorOpen] = useState(false);
const [isResponseOpen, setIsResponseOpen] = useState(false);
const [isFetching, setIsFetching] = useState(false);
const responseRef = useRef<HTMLDivElement>(null);
const [activeTab, setActiveTab] = useState<any>('request');
const [responseExpanded, setResponseExpanded] = useState(true);
const [responseStatus, setResponseStatus] = useState<{ code: number; text: string; } | null>(null);
// Height Resizing Logic
const [responseHeight, setResponseHeight] = useState(240);
const [storedHeight, setStoredHeight] = useLocalStorage('napcat_debug_response_height', 240);
const parsedRequest = parse(data.request);
const parsedResponse = parse(data.response);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const sendRequest = async () => {
const sendRequest = async (bodyOverride?: string) => {
if (isFetching) return;
setIsFetching(true);
setResponseStatus(null);
const r = toast.loading('正在发送请求...');
try {
const parsedRequestBody = JSON.parse(requestBody);
const parsedRequestBody = JSON.parse(bodyOverride ?? requestBody);
// 如果有 adapterName走后端转发
if (adapterName) {
request.post(`/api/Debug/call/${adapterName}`, {
action: path.replace(/^\//, ''), // 去掉开头的 /
params: parsedRequestBody
}, {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
}).then((res) => {
if (res.data.code === 0) {
setResponseContent(JSON.stringify(res.data.data, null, 2));
setResponseStatus({ code: 200, text: 'OK' });
} else {
setResponseContent(JSON.stringify(res.data, null, 2));
setResponseStatus({ code: 500, text: res.data.message });
}
setResponseExpanded(true);
toast.success('请求成功');
}).catch((err) => {
toast.error('请求失败:' + err.message);
setResponseContent(JSON.stringify({ error: err.message }, null, 2));
setResponseStatus({ code: 500, text: 'Error' });
setResponseExpanded(true);
}).finally(() => {
setIsFetching(false);
toast.dismiss(r);
});
return;
}
// 回退到旧逻辑 (直接请求)
const requestURL = new URL(httpConfig.url);
requestURL.pathname = path;
request
@ -58,23 +111,23 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
headers: {
Authorization: `Bearer ${httpConfig.token}`,
},
responseType: 'text',
})
}) // 移除 responseType: 'text',以便 axios 自动解析 JSON
.then((res) => {
setResponseContent(parseAxiosResponse(res));
toast.success('请求发送完成,请查看响应');
setResponseContent(JSON.stringify(res.data, null, 2));
setResponseStatus({ code: res.status, text: res.statusText });
setResponseExpanded(true);
toast.success('请求成功');
})
.catch((err) => {
toast.error('请求发送失败:' + err.message);
setResponseContent(parseAxiosResponse(err.response));
toast.error('请求失败:' + err.message);
setResponseContent(JSON.stringify(err.response?.data || { error: err.message }, null, 2));
if (err.response) {
setResponseStatus({ code: err.response.status, text: err.response.statusText });
}
setResponseExpanded(true);
})
.finally(() => {
setIsFetching(false);
setIsResponseOpen(true);
responseRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
toast.dismiss(r);
});
} catch (_error) {
@ -84,156 +137,294 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
}
};
useImperativeHandle(ref, () => ({
setRequestBody: (value: string) => {
setActiveTab('request');
setRequestBody(value);
},
sendWithBody: (value: string) => {
setActiveTab('request');
setRequestBody(value);
// 直接用 override 发送,避免 setState 异步导致拿到旧值
void sendRequest(value);
},
focusRequestEditor: () => {
setActiveTab('request');
}
}));
useEffect(() => {
setRequestBody(generateDefaultJson(data.request));
setResponseContent('');
setResponseStatus(null);
}, [path]);
return (
<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>
<h1 className='text-lg font-bold mb-4'>
<Snippet
className='bg-default-50 bg-opacity-50 backdrop-blur-md'
symbol={<IoLink size={18} className='inline-block mr-1' />}
tooltipProps={{
content: '点击复制地址',
}}
>
{path}
</Snippet>
</h1>
<div className='flex gap-2 items-center'>
<Input
label='HTTP URL'
placeholder='输入 HTTP URL'
value={httpConfig.url}
onChange={(e) =>
setHttpConfig({ ...httpConfig, url: e.target.value })}
/>
<Input
label='Token'
placeholder='输入 Token'
value={httpConfig.token}
onChange={(e) =>
setHttpConfig({ ...httpConfig, token: e.target.value })}
/>
<Button
onPress={sendRequest}
color='primary'
size='lg'
radius='full'
isIconOnly
isDisabled={isFetching}
>
<IoSend />
</Button>
</div>
<Card
shadow='sm'
className='my-4 bg-opacity-50 backdrop-blur-md overflow-visible'
>
<CardHeader className='font-bold text-lg gap-1 pb-0'>
<span className='mr-2'></span>
<Button
color='warning'
variant='flat'
onPress={() => setIsCodeEditorOpen(!isCodeEditorOpen)}
size='sm'
radius='full'
>
{isCodeEditorOpen ? '收起' : '展开'}
</Button>
</CardHeader>
<CardBody>
<motion.div
ref={responseRef}
initial={{ opacity: 0, height: 0 }}
animate={{
opacity: isCodeEditorOpen ? 1 : 0,
height: isCodeEditorOpen ? 'auto' : 0,
}}
>
<CodeEditor
value={requestBody}
onChange={(value) => setRequestBody(value ?? '')}
language='json'
height='400px'
/>
// Sync from storage on mount
useEffect(() => {
setResponseHeight(storedHeight);
}, []);
<div className='flex justify-end gap-1'>
<ChatInputModal />
<Button
color='primary'
variant='flat'
onPress={() =>
setRequestBody(generateDefaultJson(data.request))}
>
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
const startY = e.clientY;
const startHeight = responseHeight;
let currentH = startHeight;
let frameId: number;
const handleMouseMove = (mv: MouseEvent) => {
if (frameId) cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(() => {
const delta = startY - mv.clientY;
currentH = Math.max(100, Math.min(window.innerHeight - 200, startHeight + delta));
setResponseHeight(currentH);
});
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
if (frameId) cancelAnimationFrame(frameId);
setStoredHeight(currentH);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [responseHeight, setStoredHeight]);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
const startY = touch.clientY;
const startHeight = responseHeight;
let currentH = startHeight;
let frameId: number;
const handleTouchMove = (mv: TouchEvent) => {
if (frameId) cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(() => {
const mvTouch = mv.touches[0];
const delta = startY - mvTouch.clientY;
currentH = Math.max(100, Math.min(window.innerHeight - 200, startHeight + delta));
setResponseHeight(currentH);
});
};
const handleTouchEnd = () => {
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
if (frameId) cancelAnimationFrame(frameId);
setStoredHeight(currentH);
};
document.addEventListener('touchmove', handleTouchMove);
document.addEventListener('touchend', handleTouchEnd);
}, [responseHeight, setStoredHeight]);
return (
<div className='flex flex-col h-full w-full relative overflow-hidden'>
{/* 1. Top Toolbar: URL & Actions */}
<div className={clsx(
'flex items-center gap-4 px-4 py-2 border-b flex-shrink-0 z-10',
hasBackground ? 'border-white/10 bg-white/5' : 'border-black/5 dark:border-white/10 bg-white/40 dark:bg-black/20'
)}>
{/* Method & Path */}
{/* Method & Path */}
{/* Method & Path */}
<div className="flex items-center gap-3 flex-1 min-w-0 pl-1">
<div className={clsx(
'text-sm font-mono truncate select-all px-2 py-1 rounded-md transition-colors',
hasBackground ? 'text-white/90 bg-black/10' : 'text-foreground dark:text-white/90 bg-default-100/50'
)}>
{path}
</div>
</div>
{/* Actions */}
<div className='flex items-center gap-2'>
<Popover placement='bottom-end' backdrop='transparent'>
<PopoverTrigger>
<Button size='sm' variant='light' radius='sm' isIconOnly className='opacity-60 hover:opacity-100'>
<IoSettingsSharp className="text-lg" />
</Button>
</div>
</motion.div>
</CardBody>
</Card>
<Card
shadow='sm'
className='my-4 relative bg-opacity-50 backdrop-blur-md'
>
<PageLoading loading={isFetching} />
<CardHeader className='font-bold text-lg gap-1 pb-0'>
<span className='mr-2'></span>
</PopoverTrigger>
<PopoverContent className='w-[260px] p-3 rounded-md border border-white/10 shadow-2xl bg-white/80 dark:bg-black/80 backdrop-blur-xl'>
<div className='flex flex-col gap-2'>
<p className='text-[10px] font-bold opacity-30 uppercase tracking-widest'>Debug Setup</p>
<Input label='Base URL' labelPlacement="outside" placeholder="http://..." value={httpConfig.url} onChange={(e) => setHttpConfig({ ...httpConfig, url: e.target.value })} size='sm' variant='bordered' />
<Input label='Token' labelPlacement="outside" placeholder="access_token" value={httpConfig.token} onChange={(e) => setHttpConfig({ ...httpConfig, token: e.target.value })} size='sm' variant='bordered' />
</div>
</PopoverContent>
</Popover>
<Button
color='warning'
variant='flat'
onPress={() => setIsResponseOpen(!isResponseOpen)}
onPress={() => sendRequest()}
color='primary'
radius='sm'
size='sm'
radius='full'
className='font-bold shadow-sm px-4'
isLoading={isFetching}
startContent={!isFetching && <IoSend className="text-xs" />}
>
{isResponseOpen ? '收起' : '展开'}
</Button>
<Button
color='success'
variant='flat'
onPress={() => {
navigator.clipboard.writeText(responseContent);
toast.success('响应内容已复制到剪贴板');
}}
size='sm'
radius='full'
>
</Button>
</CardHeader>
<CardBody>
<motion.div
className='overflow-y-auto text-sm'
initial={{ opacity: 0, height: 0 }}
animate={{
opacity: isResponseOpen ? 1 : 0,
height: isResponseOpen ? 300 : 0,
}}
>
<pre>
<code>
{responseContent || (
<div className='text-gray-400'></div>
)}
</code>
</pre>
</motion.div>
</CardBody>
</Card>
<div className='p-2 md:p-4 border border-default-50 dark:border-default-200 rounded-lg backdrop-blur-sm'>
<h2 className='text-xl font-semibold mb-2'></h2>
<DisplayStruct schema={parsedRequest} />
<h2 className='text-xl font-semibold mt-4 mb-2'></h2>
<DisplayStruct schema={parsedResponse} />
</div>
</div>
</section>
{/* 2. Main Workspace (Request) - Flexible Height */}
<div className='flex-1 min-h-0 flex flex-col relative'>
<div className='flex-1 flex flex-col overflow-hidden relative'>
{/* Request Toolbar */}
<div className={clsx(
'px-4 flex items-center justify-between h-10 flex-shrink-0 border-b',
hasBackground ? 'border-white/10' : 'border-default-100 dark:border-white/10'
)}>
<Tabs
aria-label="Request Options"
size="sm"
variant="underlined"
selectedKey={activeTab}
onSelectionChange={setActiveTab}
classNames={{
tabList: 'p-0 gap-6 bg-transparent',
cursor: 'w-full bg-foreground dark:bg-white h-[2px]',
tab: 'px-0 h-full',
tabContent: 'text-xs font-medium text-default-500 dark:text-white/50 group-data-[selected=true]:text-foreground dark:group-data-[selected=true]:text-white'
}}
>
<Tab key="request" title="请求体" />
<Tab key="docs" title="接口文档" />
</Tabs>
<div className='flex items-center gap-1 opacity-70'>
<ChatInputModal>
{(onOpen) => (
<Tooltip content="构造 CQ 码" closeDelay={0}>
<Button isIconOnly size='sm' variant='light' radius='sm' className='w-8 h-8' onPress={onOpen}>
<TbMessageCode size={16} />
</Button>
</Tooltip>
)}
</ChatInputModal>
<Tooltip content="生成示例" closeDelay={0}>
<Button isIconOnly size='sm' variant='light' radius='sm' className='w-8 h-8' onPress={() => setRequestBody(generateDefaultJson(data.request))}>
<TbCode size={16} />
</Button>
</Tooltip>
</div>
</div>
{/* Content Area */}
<div className='flex-1 relative overflow-hidden'>
{activeTab === 'request' ? (
<div className="absolute inset-0">
<CodeEditor
value={requestBody}
onChange={(value) => setRequestBody(value ?? '')}
language='json'
options={{
minimap: { enabled: false },
fontSize: 13,
fontFamily: 'JetBrains Mono, monospace',
scrollBeyondLastLine: false,
wordWrap: 'on',
padding: { top: 16, bottom: 16 },
lineNumbersMinChars: 3,
chromeless: true,
backgroundColor: 'transparent'
}}
/>
</div>
) : (
<div className='p-6 space-y-8 overflow-y-auto h-full scrollbar-hide'>
<section>
<h3 className='text-[10px] font-bold text-default-700 dark:text-default-50 uppercase tracking-widest mb-4'>Request Params</h3>
<DisplayStruct schema={parsedRequest} />
</section>
<div className='h-px bg-white/10 w-full' />
<section>
<h3 className='text-[10px] font-bold text-default-700 dark:text-default-50 uppercase tracking-widest mb-4'>Response Data</h3>
<DisplayStruct schema={parsedResponse} />
</section>
</div>
)}
</div>
</div>
</div>
{/* 3. Response Panel (Bottom) */}
<div
className='flex-shrink-0 flex flex-col overflow-hidden relative'
style={{ height: responseExpanded ? undefined : 'auto' }}
>
{/* Resize Handle / Header */}
<div
className={clsx(
'flex items-center justify-between px-4 py-1.5 cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-colors select-none group relative border-t',
hasBackground ? 'border-white/10' : 'border-default-100 dark:border-white/10'
)}
onClick={() => setResponseExpanded(!responseExpanded)}
>
{/* Invisible Draggable Area */}
{responseExpanded && (
<div
className="absolute -top-1.5 left-0 w-full h-4 cursor-ns-resize z-20"
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e); }}
onTouchStart={(e) => { e.stopPropagation(); handleTouchStart(e); }}
onClick={(e) => e.stopPropagation()}
/>
)}
<div className='flex items-center gap-2'>
<div className={clsx('transition-transform duration-200', !responseExpanded && '-rotate-90')}>
<IoChevronDown size={14} className="opacity-50" />
</div>
<span className={clsx(
'text-[10px] font-bold tracking-widest uppercase',
hasBackground ? 'text-white' : 'text-foreground dark:text-white'
)}>Response</span>
{responseStatus && (
<Chip size="sm" variant="dot" color={responseStatus.code >= 200 && responseStatus.code < 300 ? 'success' : 'danger'} className="h-5 text-[10px] font-mono border-none bg-transparent pl-0">
{responseStatus.code} {responseStatus.text}
</Chip>
)}
</div>
<Button size='sm' variant='light' isIconOnly radius='sm' className='h-6 w-6 opacity-40 hover:opacity-100' onClick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(responseContent); toast.success('已复制'); }}>
<IoCopy size={12} />
</Button>
</div>
{/* Response Editor */}
{responseExpanded && (
<div style={{ height: responseHeight }} className="relative bg-transparent">
<PageLoading loading={isFetching} />
<div className="absolute inset-0">
<CodeEditor
value={responseContent || '// Waiting for response...'}
language='json'
options={{
minimap: { enabled: false },
fontSize: 12,
fontFamily: 'JetBrains Mono, monospace',
lineNumbers: 'off',
scrollBeyondLastLine: false,
wordWrap: 'on',
readOnly: true,
folding: true,
padding: { top: 12, bottom: 12 },
renderLineHighlight: 'none',
chromeless: true,
backgroundColor: 'transparent'
}}
/>
</div>
</div>
)}
</div>
</div>
);
};
});
export default OneBotApiDebug;

View File

@ -8,15 +8,15 @@ import { TbSquareRoundedChevronRightFilled } from 'react-icons/tb';
import type { LiteralValue, ParsedSchema } from '@/utils/zod';
interface DisplayStructProps {
schema: ParsedSchema | ParsedSchema[]
schema: ParsedSchema | ParsedSchema[];
}
const SchemaType = ({
type,
value,
}: {
type: string
value?: LiteralValue
type: string;
value?: LiteralValue;
}) => {
let name = type;
switch (type) {
@ -57,7 +57,7 @@ const SchemaType = ({
};
const SchemaLabel: React.FC<{
schema: ParsedSchema
schema: ParsedSchema;
}> = ({ schema }) => (
<>
{Array.isArray(schema.type)
@ -81,8 +81,8 @@ const SchemaLabel: React.FC<{
);
const SchemaContainer: React.FC<{
schema: ParsedSchema
children: React.ReactNode
schema: ParsedSchema;
children: React.ReactNode;
}> = ({ schema, children }) => {
const [expanded, setExpanded] = useState(false);
@ -126,7 +126,7 @@ const SchemaContainer: React.FC<{
);
};
const RenderSchema: React.FC<{ schema: ParsedSchema }> = ({ schema }) => {
const RenderSchema: React.FC<{ schema: ParsedSchema; }> = ({ schema }) => {
if (schema.type === 'object') {
return (
<SchemaContainer schema={schema}>
@ -193,7 +193,7 @@ const RenderSchema: React.FC<{ schema: ParsedSchema }> = ({ schema }) => {
const DisplayStruct: React.FC<DisplayStructProps> = ({ schema }) => {
return (
<div className='p-4 bg-content2 rounded-lg bg-opacity-50'>
<div className=''>
{Array.isArray(schema)
? (
schema.map((s, i) => <RenderSchema key={s.name || i} schema={s} />)

View File

@ -1,85 +1,181 @@
import { Card, CardBody } from '@heroui/card';
import { Input } from '@heroui/input';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { motion } from 'motion/react';
import { useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { useMemo, useState } from 'react';
import { TbChevronRight, TbFolder, TbSearch } from 'react-icons/tb';
import key from '@/const/key';
import oneBotHttpApiGroup from '@/const/ob_api/group';
import oneBotHttpApiMessage from '@/const/ob_api/message';
import oneBotHttpApiSystem from '@/const/ob_api/system';
import oneBotHttpApiUser from '@/const/ob_api/user';
import type { OneBotHttpApi, OneBotHttpApiPath } from '@/const/ob_api';
export interface OneBotApiNavListProps {
data: OneBotHttpApi
selectedApi: OneBotHttpApiPath
onSelect: (apiName: OneBotHttpApiPath) => void
openSideBar: boolean
data: OneBotHttpApi;
selectedApi: OneBotHttpApiPath;
onSelect: (apiName: OneBotHttpApiPath) => void;
openSideBar: boolean;
onToggle?: (isOpen: boolean) => void;
}
const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
const { data, selectedApi, onSelect, openSideBar } = props;
const { data, selectedApi, onSelect, openSideBar, onToggle } = props;
const [searchValue, setSearchValue] = useState('');
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const groups = useMemo(() => {
const rawGroups = [
{ id: 'user', label: '账号相关', keys: Object.keys(oneBotHttpApiUser) },
{ id: 'message', label: '消息相关', keys: Object.keys(oneBotHttpApiMessage) },
{ id: 'group', label: '群聊相关', keys: Object.keys(oneBotHttpApiGroup) },
{ id: 'system', label: '系统操作', keys: Object.keys(oneBotHttpApiSystem) },
];
return rawGroups.map(g => {
const apis = g.keys
.filter(k => k in data)
.map(k => ({ path: k as OneBotHttpApiPath, ...data[k as OneBotHttpApiPath] }))
.filter(api =>
api.path.toLowerCase().includes(searchValue.toLowerCase()) ||
api.description?.toLowerCase().includes(searchValue.toLowerCase())
);
return { ...g, apis };
}).filter(g => g.apis.length > 0);
}, [data, searchValue]);
const toggleGroup = (id: string) => {
setExpandedGroups(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
return (
<motion.div
className={clsx(
'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={{
type: openSideBar ? 'spring' : 'tween',
stiffness: 150,
damping: 15,
}}
animate={{ width: openSideBar ? '16rem' : '0rem' }}
style={{ overflowY: openSideBar ? 'auto' : 'hidden' }}
>
<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-primary-600'
classNames={{
inputWrapper:
'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'
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
isClearable
onClear={() => setSearchValue('')}
/>
{Object.entries(data).map(([apiName, api]) => (
<Card
key={apiName}
shadow='none'
className={clsx(
'w-full border border-primary-100 rounded-lg mb-1 bg-opacity-30 backdrop-blur-sm text-primary-400',
{
hidden: !(
apiName.includes(searchValue) ||
api.description?.includes(searchValue)
),
},
{
'!bg-opacity-40 border border-primary-400 bg-primary-50 text-primary-600':
apiName === selectedApi,
}
)}
isPressable
onPress={() => onSelect(apiName as OneBotHttpApiPath)}
>
<CardBody>
<h2 className='font-bold'>{api.description}</h2>
<div
className={clsx('text-sm text-primary-200', {
'!text-primary-400': apiName === selectedApi,
})}
>
{apiName}
</div>
</CardBody>
</Card>
))}
</div>
</motion.div>
<>
{/* Mobile backdrop overlay - below header (z-40) */}
<AnimatePresence>
{openSideBar && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/50 backdrop-blur-[2px] z-30 md:hidden"
onClick={() => onToggle?.(false)}
/>
)}
</AnimatePresence>
<motion.div
className={clsx(
'h-full z-40 flex-shrink-0 border-r border-white/10 dark:border-white/5 overflow-hidden transition-all',
// Mobile: absolute position, drawer style
// Desktop: relative position, pushing content
'absolute md:relative left-0 top-0',
hasBackground
? 'bg-white/10 dark:bg-black/40 backdrop-blur-xl md:bg-transparent md:backdrop-blur-none'
: 'bg-white/80 dark:bg-black/40 backdrop-blur-xl md:bg-transparent md:backdrop-blur-none'
)}
initial={false}
animate={{
width: openSideBar ? 260 : 0,
opacity: openSideBar ? 1 : 0,
x: (window.innerWidth < 768 && !openSideBar) ? -260 : 0 // Optional: slide out completely on mobile
}}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
<div className='w-[260px] h-full flex flex-col'>
<div className='p-3'>
<Input
classNames={{
inputWrapper:
'bg-white/5 dark:bg-white/5 border border-white/10 hover:bg-white/10 transition-all shadow-none',
input: 'bg-transparent text-xs placeholder:opacity-30',
}}
isClearable
radius='lg'
placeholder='搜索接口...'
startContent={<TbSearch size={14} className="opacity-30" />}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onClear={() => setSearchValue('')}
size="sm"
/>
</div>
<div className='flex-1 px-2 pb-4 flex flex-col gap-1 overflow-y-auto no-scrollbar'>
{groups.map((group) => {
const isOpen = expandedGroups.includes(group.id) || searchValue.length > 0;
return (
<div key={group.id} className="flex flex-col">
{/* Group Header */}
<div
className="flex items-center gap-2 px-2 py-2 rounded-lg cursor-pointer hover:bg-white/5 transition-all group/header"
onClick={() => toggleGroup(group.id)}
>
<TbChevronRight
size={12}
className={clsx(
'transition-transform duration-200 opacity-20 group-hover/header:opacity-50',
isOpen && 'rotate-90'
)}
/>
<TbFolder className="text-primary/60" size={16} />
<span className="text-[13px] font-medium opacity-70 flex-1">{group.label}</span>
<span className="text-[11px] opacity-20 font-mono tracking-tighter">({group.apis.length})</span>
</div>
{/* Group Content */}
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden flex flex-col gap-1 ml-4 border-l border-white/5 pl-2 my-1"
>
{group.apis.map((api) => {
const isSelected = api.path === selectedApi;
return (
<div
key={api.path}
onClick={() => onSelect(api.path)}
className={clsx(
'flex flex-col gap-0.5 px-3 py-2 rounded-lg cursor-pointer transition-all border select-none',
isSelected
? (hasBackground
? 'bg-white/10 border-white/20'
: 'bg-primary/10 border-primary/20 shadow-sm')
: 'border-transparent hover:bg-white/10 dark:hover:bg-white/5'
)}
>
<span className={clsx(
'text-[12px] font-medium transition-colors truncate',
isSelected ? 'text-primary' : 'opacity-70'
)}>
{api.description}
</span>
<span className={clsx(
'text-[10px] font-mono truncate transition-all',
isSelected ? 'text-primary/60' : 'opacity-30'
)}>
{api.path}
</span>
</div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
</div>
</motion.div>
</>
);
};

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