mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-09 06:20:24 +00:00
Compare commits
35 Commits
v4.9.84
...
test-pr-is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20f6101f95 | ||
|
|
018e8aa4f0 | ||
|
|
1ad700b935 | ||
|
|
68c8b984ad | ||
|
|
8eb1aa2fb4 | ||
|
|
2d3f4e696b | ||
|
|
b241881c74 | ||
|
|
aecf33f4dc | ||
|
|
dd4374389b | ||
|
|
100efb03ab | ||
|
|
ce9482f19d | ||
|
|
4e37b002f9 | ||
|
|
7e7262415b | ||
|
|
3365211507 | ||
|
|
05b38825c0 | ||
|
|
95f4a4d37e | ||
|
|
cd495fc7a0 | ||
|
|
656279d74b | ||
|
|
377c780d1a | ||
|
|
aefa8985b1 | ||
|
|
b034940dfd | ||
|
|
cb8e10cc7e | ||
|
|
afed164ba1 | ||
|
|
a34a86288b | ||
|
|
50bcd71144 | ||
|
|
fa3a229827 | ||
|
|
e56b912bbd | ||
|
|
da0dd01460 | ||
|
|
578dda2f17 | ||
|
|
649165bf00 | ||
|
|
c4f7107038 | ||
|
|
7f81bf45ee | ||
|
|
7e6035d98b | ||
|
|
2405cb03d8 | ||
|
|
32d3ff6998 |
31
.github/prompt/default.md
vendored
31
.github/prompt/default.md
vendored
@@ -1,27 +1,18 @@
|
||||
# V?.?.?
|
||||
# {VERSION}
|
||||
[使用文档](https://napneko.github.io/)
|
||||
|
||||
## Windows 一键包
|
||||
我们为提供了的轻量化一键部署方案
|
||||
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
|
||||
我们提供了轻量化一键部署方案,内置 QQ 和 NapCat,详见使用文档。
|
||||
|
||||
你可以下载
|
||||
可下载文件:
|
||||
- NapCat.Shell.Windows.Node.zip(无头模式)
|
||||
|
||||
NapCat.Shell.Windows.OneKey.zip (无头)
|
||||
## 注意事项
|
||||
**推荐 QQ 版本:9.9.23+,最低支持 9.9.22**
|
||||
**默认 WebUI 密钥为随机密码,请在控制台查看**
|
||||
|
||||
启动后可自动化部署一键包,教程参考使用文档安装部分
|
||||
## 运行库
|
||||
如果 Windows x64 缺少 xxx.dll,请安装 [VC++ 运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
||||
|
||||
## 警告
|
||||
**注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本**
|
||||
**默认WebUi密钥为随机密码 控制台查看**
|
||||
|
||||
**[9.9.22-40990 X64 Win](https://dldir1v6.qq.com/qqfile/qq/QQNT/2c9d3f6c/QQ9.9.22.40990_x64.exe)**
|
||||
[LinuxX64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_amd64.deb)
|
||||
[LinuxX64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_x86_64.rpm)
|
||||
[LinuxArm64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_arm64.deb)
|
||||
[LinuxArm64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_aarch64.rpm)
|
||||
[MAC DMG 40990 ](https://dldir1v6.qq.com/qqfile/qq/QQNT/c6cb0f5d/QQ_v6.9.82.40990.dmg)
|
||||
## 如果WinX64缺少运行库或者xxx.dll?
|
||||
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
||||
|
||||
## 更新
|
||||
## 更新内容
|
||||
详见 commit 历史。
|
||||
118
.github/prompt/release_note_prompt.txt
vendored
118
.github/prompt/release_note_prompt.txt
vendored
@@ -1,60 +1,94 @@
|
||||
注意:输出必须严格使用 NapCat 的发布说明格式,严格保证示例格式,并用简体中文。
|
||||
# NapCat Release Note Generator
|
||||
|
||||
格式规则:
|
||||
1. 第一行:# V{TAG}
|
||||
2. 第二行:[使用文档](https://napneko.github.io/)
|
||||
3. 空行后,按下面的节顺序输出(存在则输出,不存在则省略该节):
|
||||
你是 NapCat 项目的发布说明生成器。请根据提供的 commit 列表生成标准格式的发布说明。
|
||||
|
||||
## Windows 一键包
|
||||
- 简短一句话介绍一键包用途
|
||||
- 列出可下载的文件名(只列文件名,不写下载链接)
|
||||
## 核心规则
|
||||
|
||||
## 警告
|
||||
- 如果有需要特别提醒的兼容/运行库/版本要求,写成加粗警告句
|
||||
1. **版本号**:第一行必须是 `# {VERSION}`,使用用户提供的版本号(如 v4.10.2),不要添加额外的 V 前缀
|
||||
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 一键包
|
||||
我们为提供了的轻量化一键部署方案
|
||||
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
|
||||
我们提供了轻量化一键部署方案,内置 QQ 和 NapCat,详见使用文档。
|
||||
|
||||
你可以下载
|
||||
可下载文件:
|
||||
- NapCat.Shell.Windows.Node.zip(无头模式)
|
||||
|
||||
NapCat.Shell.Windows.OneKey.zip (无头)
|
||||
## 注意事项
|
||||
**推荐 QQ 版本:9.9.23+,最低支持 9.9.22**
|
||||
**默认 WebUI 密钥为随机密码,请在控制台查看**
|
||||
|
||||
启动后可自动化部署一键包,教程参考使用文档安装部分
|
||||
## 运行库
|
||||
如果 Windows x64 缺少 xxx.dll,请安装 [VC++ 运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
||||
|
||||
## 警告
|
||||
**注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本**
|
||||
**默认WebUi密钥为随机密码 控制台查看**
|
||||
## 更新内容
|
||||
|
||||
**[9.9.22-40990 X64 Win](https://dldir1v6.qq.com/qqfile/qq/QQNT/2c9d3f6c/QQ9.9.22.40990_x64.exe)**
|
||||
[LinuxX64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_amd64.deb)
|
||||
[LinuxX64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_x86_64.rpm)
|
||||
[LinuxArm64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_arm64.deb)
|
||||
[LinuxArm64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_aarch64.rpm)
|
||||
[MAC DMG 40990 ](https://dldir1v6.qq.com/qqfile/qq/QQNT/c6cb0f5d/QQ_v6.9.82.40990.dmg)
|
||||
## 如果WinX64缺少运行库或者xxx.dll?
|
||||
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
||||
### 🐛 修复
|
||||
1. 修复 xxx 问题 (a1b2c3d)
|
||||
2. 修复 yyy 崩溃 (b2c3d4e)
|
||||
|
||||
## 更新
|
||||
1. 修改了XXXXX
|
||||
2. 新增了XXXX
|
||||
3. 重构了XXXX
|
||||
### ✨ 新增
|
||||
1. 新增 xxx 功能 (c3d4e5f)
|
||||
2. 支持 yyy 特性 (d4e5f6g)
|
||||
|
||||
### 🔧 优化
|
||||
1. 优化 xxx 性能 (e5f6g7h)
|
||||
2. 重构 yyy 模块 (f6g7h8i)
|
||||
|
||||
---
|
||||
|
||||
**完整更新日志**: [{PREV_VERSION}...{VERSION}](https://github.com/NapNeko/NapCatQQ/compare/{PREV_VERSION}...{VERSION})
|
||||
```
|
||||
|
||||
## 重要约束
|
||||
|
||||
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
231
.github/scripts/lib/comment.ts
vendored
Normal 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 构建中',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'</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 = '';
|
||||
headerTitle = '# ✅ NapCat 构建成功';
|
||||
} else if (anyCancelled && !anyFailure) {
|
||||
statusBadge = '';
|
||||
headerTitle = '# ⚪ NapCat 构建已取消';
|
||||
} else {
|
||||
statusBadge = '';
|
||||
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
189
.github/scripts/lib/github.ts
vendored
Normal 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
36
.github/scripts/pr-build-building.ts
vendored
Normal 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
206
.github/scripts/pr-build-check.ts
vendored
Normal 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
90
.github/scripts/pr-build-result.ts
vendored
Normal 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
149
.github/scripts/pr-build-run.ts
vendored
Normal 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();
|
||||
32
.github/workflows/build.yml
vendored
32
.github/workflows/build.yml
vendored
@@ -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
303
.github/workflows/pr-build.yml
vendored
Normal 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
|
||||
189
.github/workflows/release.yml
vendored
189
.github/workflows/release.yml
vendored
@@ -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
|
||||
@@ -171,10 +208,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 +236,152 @@ 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"
|
||||
|
||||
# 获取变更的关键文件列表(排除测试、配置等)
|
||||
KEY_FILES=$(git diff --name-only "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | \
|
||||
grep -E "^packages/napcat-(core|onebot|webui-backend|shell)/" | \
|
||||
grep -E "\.(ts|js)$" | \
|
||||
grep -v -E "(test|spec|\.d\.ts|config)" | \
|
||||
head -15)
|
||||
|
||||
CODE_DIFF=""
|
||||
DIFF_CHAR_LIMIT=6000 # 总diff字符限制
|
||||
CURRENT_CHARS=0
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
# 如果没有关键文件变化,获取前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)$" | head -5)
|
||||
|
||||
for file in $TOP_FILES; do
|
||||
FILE_DIFF=$(git diff "$PREV_TAG".."$CURRENT_TAG" -- "$file" 2>/dev/null | head -30)
|
||||
if [ -n "$FILE_DIFF" ] && [ ${#FILE_DIFF} -lt 1000 ]; then
|
||||
CODE_DIFF="$CODE_DIFF
|
||||
|
||||
### $file
|
||||
\`\`\`diff
|
||||
$FILE_DIFF
|
||||
\`\`\`"
|
||||
fi
|
||||
done
|
||||
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 +398,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
898
packages/napcat-common/src/mirror.ts
Normal file
898
packages/napcat-common/src/mirror.ts
Normal file
@@ -0,0 +1,898 @@
|
||||
/**
|
||||
* GitHub 镜像配置模块
|
||||
* 提供统一的镜像源管理,支持复杂网络环境
|
||||
*
|
||||
* 镜像源测试时间: 2026-01-03
|
||||
* 测试通过: 55/61 完全可用
|
||||
*/
|
||||
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
import { RequestUtil } from './request';
|
||||
import { PromiseTimer } from './helper';
|
||||
|
||||
// ============== 镜像源列表 ==============
|
||||
|
||||
/**
|
||||
* GitHub 文件加速镜像
|
||||
* 用于加速 release assets 下载
|
||||
* 按延迟排序,优先使用快速镜像
|
||||
*
|
||||
* 测试时间: 2026-01-03
|
||||
* 镜像支持 301/302 重定向
|
||||
* 懒加载测速:首次使用时自动测速,缓存 30 分钟
|
||||
*/
|
||||
export const GITHUB_FILE_MIRRORS = [
|
||||
// 延迟 < 800ms 的最快镜像
|
||||
'https://github.chenc.dev/', // 666ms
|
||||
'https://ghproxy.cfd/', // 719ms - 支持重定向
|
||||
'https://github.tbedu.top/', // 760ms
|
||||
'https://ghps.cc/', // 768ms
|
||||
'https://gh.llkk.cc/', // 774ms
|
||||
'https://ghproxy.cc/', // 777ms
|
||||
'https://gh.monlor.com/', // 779ms
|
||||
'https://cdn.akaere.online/', // 784ms
|
||||
// 延迟 800-1000ms 的快速镜像
|
||||
'https://gh.idayer.com/', // 869ms
|
||||
'https://gh-proxy.net/', // 885ms
|
||||
'https://ghpxy.hwinzniej.top/', // 890ms
|
||||
'https://github-proxy.memory-echoes.cn/', // 896ms
|
||||
'https://git.yylx.win/', // 917ms
|
||||
'https://gitproxy.mrhjx.cn/', // 950ms
|
||||
'https://jiashu.1win.eu.org/', // 954ms
|
||||
'https://ghproxy.cn/', // 981ms
|
||||
// 延迟 1000-1500ms 的中速镜像
|
||||
'https://gh.fhjhy.top/', // 1014ms
|
||||
'https://gp.zkitefly.eu.org/', // 1015ms
|
||||
'https://gh-proxy.com/', // 1022ms
|
||||
'https://hub.gitmirror.com/', // 1027ms
|
||||
'https://ghfile.geekertao.top/', // 1029ms
|
||||
'https://j.1lin.dpdns.org/', // 1037ms
|
||||
'https://ghproxy.imciel.com/', // 1047ms
|
||||
'https://github-proxy.teach-english.tech/', // 1047ms
|
||||
'https://gh.927223.xyz/', // 1071ms
|
||||
'https://github.ednovas.xyz/', // 1099ms
|
||||
'https://ghf.xn--eqrr82bzpe.top/',// 1122ms
|
||||
'https://gh.dpik.top/', // 1131ms
|
||||
'https://gh.jasonzeng.dev/', // 1139ms
|
||||
'https://gh.xxooo.cf/', // 1157ms
|
||||
'https://gh.bugdey.us.kg/', // 1228ms
|
||||
'https://ghm.078465.xyz/', // 1289ms
|
||||
'https://j.1win.ggff.net/', // 1329ms
|
||||
'https://tvv.tw/', // 1393ms
|
||||
'https://gh.chjina.com/', // 1446ms
|
||||
'https://gitproxy.127731.xyz/', // 1458ms
|
||||
// 延迟 1500-2500ms 的较慢镜像
|
||||
'https://gh.inkchills.cn/', // 1617ms
|
||||
'https://ghproxy.cxkpro.top/', // 1651ms
|
||||
'https://gh.sixyin.com/', // 1686ms
|
||||
'https://github.geekery.cn/', // 1734ms
|
||||
'https://git.669966.xyz/', // 1824ms
|
||||
'https://gh.5050net.cn/', // 1858ms
|
||||
'https://gh.felicity.ac.cn/', // 1903ms
|
||||
'https://gh.ddlc.top/', // 2056ms
|
||||
'https://cf.ghproxy.cc/', // 2058ms
|
||||
'https://gitproxy.click/', // 2068ms
|
||||
'https://github.dpik.top/', // 2313ms
|
||||
'https://gh.zwnes.xyz/', // 2434ms
|
||||
'https://ghp.keleyaa.com/', // 2440ms
|
||||
'https://gh.wsmdn.dpdns.org/', // 2744ms
|
||||
// 延迟 > 2500ms 的慢速镜像(作为备用)
|
||||
'https://ghproxy.monkeyray.net/', // 3023ms
|
||||
'https://fastgit.cc/', // 3369ms
|
||||
'https://cdn.gh-proxy.com/', // 3394ms
|
||||
'https://gh.catmak.name/', // 4119ms
|
||||
'https://gh.noki.icu/', // 5990ms
|
||||
'', // 原始 URL(无镜像)
|
||||
];
|
||||
|
||||
/**
|
||||
* GitHub API 镜像
|
||||
* 用于访问 GitHub API(作为备选方案)
|
||||
* 注:优先使用非 API 方法,减少对 API 的依赖
|
||||
*
|
||||
* 经测试,大部分代理镜像不支持 API 转发
|
||||
* 建议使用 getLatestReleaseTag 等方法避免 API 调用
|
||||
*/
|
||||
export const GITHUB_API_MIRRORS = [
|
||||
'https://api.github.com',
|
||||
// 目前没有可用的公共 API 代理镜像
|
||||
];
|
||||
|
||||
/**
|
||||
* GitHub Raw 镜像
|
||||
* 用于访问 raw.githubusercontent.com
|
||||
* 注:大多数通用代理也支持 raw 文件加速
|
||||
*/
|
||||
export const GITHUB_RAW_MIRRORS = [
|
||||
'https://raw.githubusercontent.com',
|
||||
// 测试确认支持 raw 文件的镜像
|
||||
'https://github.chenc.dev/https://raw.githubusercontent.com',
|
||||
'https://ghproxy.cfd/https://raw.githubusercontent.com',
|
||||
'https://gh.llkk.cc/https://raw.githubusercontent.com',
|
||||
'https://ghproxy.cc/https://raw.githubusercontent.com',
|
||||
'https://gh-proxy.net/https://raw.githubusercontent.com',
|
||||
];
|
||||
|
||||
// ============== 镜像配置接口 ==============
|
||||
|
||||
export interface MirrorConfig {
|
||||
/** 文件下载镜像(用于 release assets) */
|
||||
fileMirrors: string[];
|
||||
/** API 镜像 */
|
||||
apiMirrors: string[];
|
||||
/** Raw 文件镜像 */
|
||||
rawMirrors: string[];
|
||||
/** 超时时间(毫秒) */
|
||||
timeout: number;
|
||||
/** 是否启用镜像 */
|
||||
enabled: boolean;
|
||||
/** 自定义镜像(优先使用) */
|
||||
customMirror?: string;
|
||||
}
|
||||
|
||||
// ============== 默认配置 ==============
|
||||
|
||||
const defaultConfig: MirrorConfig = {
|
||||
fileMirrors: GITHUB_FILE_MIRRORS,
|
||||
apiMirrors: GITHUB_API_MIRRORS,
|
||||
rawMirrors: GITHUB_RAW_MIRRORS,
|
||||
timeout: 10000, // 10秒超时,平衡速度和可靠性
|
||||
enabled: true,
|
||||
customMirror: undefined,
|
||||
};
|
||||
|
||||
let currentConfig: MirrorConfig = { ...defaultConfig };
|
||||
|
||||
// ============== 懒加载镜像测速缓存 ==============
|
||||
|
||||
interface MirrorTestResult {
|
||||
mirror: string;
|
||||
latency: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
// 缓存的快速镜像列表(按延迟排序)
|
||||
let cachedFastMirrors: string[] | null = null;
|
||||
// 测速是否正在进行
|
||||
let mirrorTestingPromise: Promise<string[]> | null = null;
|
||||
// 缓存过期时间(30分钟)
|
||||
const MIRROR_CACHE_TTL = 30 * 60 * 1000;
|
||||
let cacheTimestamp: number = 0;
|
||||
|
||||
/**
|
||||
* 测试单个镜像的延迟(使用 HEAD 请求测试实际文件)
|
||||
* 测试一个小型的实际 release 文件,确保镜像支持文件下载
|
||||
*/
|
||||
async function testMirrorLatency (mirror: string, timeout: number = 5000): Promise<MirrorTestResult> {
|
||||
// 使用一个实际存在的小文件来测试(README 或小型 release asset)
|
||||
// 用 HEAD 请求,不下载实际内容
|
||||
const testUrl = 'https://github.com/NapNeko/NapCatQQ/releases/latest';
|
||||
const url = buildMirrorUrl(testUrl, mirror);
|
||||
const start = Date.now();
|
||||
|
||||
return new Promise<MirrorTestResult>((resolve) => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const isHttps = urlObj.protocol === 'https:';
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const req = client.request({
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (isHttps ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'HEAD',
|
||||
timeout,
|
||||
headers: {
|
||||
'User-Agent': 'NapCat-Mirror-Test',
|
||||
},
|
||||
}, (res) => {
|
||||
const statusCode = res.statusCode || 0;
|
||||
// 2xx 或 3xx 都算成功(3xx 说明镜像工作正常,会重定向)
|
||||
const isValid = statusCode >= 200 && statusCode < 400;
|
||||
resolve({
|
||||
mirror,
|
||||
latency: Date.now() - start,
|
||||
success: isValid,
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
resolve({
|
||||
mirror,
|
||||
latency: Infinity,
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve({
|
||||
mirror,
|
||||
latency: Infinity,
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
req.end();
|
||||
} catch {
|
||||
resolve({
|
||||
mirror,
|
||||
latency: Infinity,
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 懒加载获取快速镜像列表
|
||||
* 第一次调用时会进行测速,后续使用缓存
|
||||
*/
|
||||
export async function getFastMirrors (forceRefresh: boolean = false): Promise<string[]> {
|
||||
// 检查缓存是否有效
|
||||
const now = Date.now();
|
||||
if (!forceRefresh && cachedFastMirrors && (now - cacheTimestamp) < MIRROR_CACHE_TTL) {
|
||||
return cachedFastMirrors;
|
||||
}
|
||||
|
||||
// 如果已经在测速中,等待结果
|
||||
if (mirrorTestingPromise) {
|
||||
return mirrorTestingPromise;
|
||||
}
|
||||
|
||||
// 开始测速
|
||||
mirrorTestingPromise = performMirrorTest();
|
||||
|
||||
try {
|
||||
const result = await mirrorTestingPromise;
|
||||
cachedFastMirrors = result;
|
||||
cacheTimestamp = now;
|
||||
return result;
|
||||
} finally {
|
||||
mirrorTestingPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行镜像测速
|
||||
* 并行测试所有镜像,返回按延迟排序的可用镜像列表
|
||||
*/
|
||||
async function performMirrorTest (): Promise<string[]> {
|
||||
// 开始镜像测速
|
||||
|
||||
const timeout = 8000; // 测速超时 8 秒
|
||||
|
||||
// 并行测试所有镜像
|
||||
const mirrors = currentConfig.fileMirrors.filter(m => m);
|
||||
const results = await Promise.all(
|
||||
mirrors.map(m => testMirrorLatency(m, timeout))
|
||||
);
|
||||
|
||||
// 过滤成功的镜像并按延迟排序
|
||||
const successfulMirrors = results
|
||||
.filter(r => r.success)
|
||||
.sort((a, b) => a.latency - b.latency)
|
||||
.map(r => r.mirror);
|
||||
|
||||
|
||||
|
||||
// 至少返回原始 URL
|
||||
if (successfulMirrors.length === 0) {
|
||||
return [''];
|
||||
}
|
||||
|
||||
return successfulMirrors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除镜像缓存,强制下次重新测速
|
||||
*/
|
||||
export function clearMirrorCache (): void {
|
||||
cachedFastMirrors = null;
|
||||
cacheTimestamp = 0;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存状态
|
||||
*/
|
||||
export function getMirrorCacheStatus (): { cached: boolean; count: number; age: number; } {
|
||||
return {
|
||||
cached: cachedFastMirrors !== null,
|
||||
count: cachedFastMirrors?.length ?? 0,
|
||||
age: cachedFastMirrors ? Date.now() - cacheTimestamp : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ============== 配置管理 ==============
|
||||
|
||||
/**
|
||||
* 获取当前镜像配置
|
||||
*/
|
||||
export function getMirrorConfig (): MirrorConfig {
|
||||
return { ...currentConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新镜像配置
|
||||
*/
|
||||
export function setMirrorConfig (config: Partial<MirrorConfig>): void {
|
||||
currentConfig = { ...currentConfig, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置为默认配置
|
||||
*/
|
||||
export function resetMirrorConfig (): void {
|
||||
currentConfig = { ...defaultConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置自定义镜像(优先级最高)
|
||||
*/
|
||||
export function setCustomMirror (mirror: string): void {
|
||||
currentConfig.customMirror = mirror;
|
||||
}
|
||||
|
||||
// ============== URL 工具函数 ==============
|
||||
|
||||
/**
|
||||
* 构建镜像 URL
|
||||
* @param originalUrl 原始 URL
|
||||
* @param mirror 镜像前缀
|
||||
*/
|
||||
export function buildMirrorUrl (originalUrl: string, mirror: string): string {
|
||||
if (!mirror) return originalUrl;
|
||||
// 如果镜像已经包含完整域名,直接拼接
|
||||
if (mirror.endsWith('/')) {
|
||||
return mirror + originalUrl;
|
||||
}
|
||||
return mirror + '/' + originalUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 URL 是否可用(HTTP GET)
|
||||
* @param url 要测试的 URL
|
||||
* @param timeout 超时时间
|
||||
*/
|
||||
export async function testUrl (url: string, timeout: number = 5000): Promise<boolean> {
|
||||
try {
|
||||
await PromiseTimer(RequestUtil.HttpGetText(url), timeout);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 URL 是否可用(HTTP HEAD,更快)
|
||||
* 验证:状态码、Content-Type、Content-Length
|
||||
*/
|
||||
export async function testUrlHead (url: string, timeout: number = 5000): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const urlObj = new URL(url);
|
||||
const isHttps = urlObj.protocol === 'https:';
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const req = client.request({
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (isHttps ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'HEAD',
|
||||
timeout,
|
||||
headers: {
|
||||
'User-Agent': 'NapCat-Mirror-Test',
|
||||
},
|
||||
}, (res) => {
|
||||
const statusCode = res.statusCode || 0;
|
||||
const contentType = (res.headers['content-type'] as string) || '';
|
||||
const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10);
|
||||
|
||||
// 验证条件:
|
||||
// 1. 状态码 2xx 或 3xx
|
||||
// 2. Content-Type 不应该是 text/html(表示错误页面)
|
||||
// 3. 对于 .zip 文件,Content-Length 应该 > 1MB(避免获取到错误页面)
|
||||
const isValidStatus = statusCode >= 200 && statusCode < 400;
|
||||
const isNotHtmlError = !contentType.includes('text/html');
|
||||
const isValidSize = url.endsWith('.zip') ? contentLength > 1024 * 1024 : true;
|
||||
|
||||
resolve(isValidStatus && isNotHtmlError && isValidSize);
|
||||
});
|
||||
|
||||
req.on('error', () => resolve(false));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 详细验证 URL 响应
|
||||
* 返回验证结果和详细信息
|
||||
*/
|
||||
export interface UrlValidationResult {
|
||||
valid: boolean;
|
||||
statusCode?: number;
|
||||
contentType?: string;
|
||||
contentLength?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function validateUrl (url: string, timeout: number = 5000): Promise<UrlValidationResult> {
|
||||
return new Promise<UrlValidationResult>((resolve) => {
|
||||
const urlObj = new URL(url);
|
||||
const isHttps = urlObj.protocol === 'https:';
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const req = client.request({
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (isHttps ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'HEAD',
|
||||
timeout,
|
||||
headers: {
|
||||
'User-Agent': 'NapCat-Mirror-Test',
|
||||
},
|
||||
}, (res) => {
|
||||
const statusCode = res.statusCode || 0;
|
||||
const contentType = (res.headers['content-type'] as string) || '';
|
||||
const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10);
|
||||
|
||||
// 验证条件
|
||||
const isValidStatus = statusCode >= 200 && statusCode < 400;
|
||||
const isNotHtmlError = !contentType.includes('text/html');
|
||||
const isValidSize = url.endsWith('.zip') ? contentLength > 1024 * 1024 : true;
|
||||
|
||||
if (!isValidStatus) {
|
||||
resolve({
|
||||
valid: false,
|
||||
statusCode,
|
||||
contentType,
|
||||
contentLength,
|
||||
error: `HTTP ${statusCode}`,
|
||||
});
|
||||
} else if (!isNotHtmlError) {
|
||||
resolve({
|
||||
valid: false,
|
||||
statusCode,
|
||||
contentType,
|
||||
contentLength,
|
||||
error: '返回了 HTML 页面而非文件',
|
||||
});
|
||||
} else if (!isValidSize) {
|
||||
resolve({
|
||||
valid: false,
|
||||
statusCode,
|
||||
contentType,
|
||||
contentLength,
|
||||
error: `文件过小 (${contentLength} bytes),可能是错误页面`,
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
valid: true,
|
||||
statusCode,
|
||||
contentType,
|
||||
contentLength,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', (e: Error) => resolve({
|
||||
valid: false,
|
||||
error: e.message,
|
||||
}));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve({
|
||||
valid: false,
|
||||
error: 'Timeout',
|
||||
});
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ============== 查找可用 URL ==============
|
||||
|
||||
/**
|
||||
* 查找可用的下载 URL
|
||||
* 使用懒加载的快速镜像列表
|
||||
* @param originalUrl 原始 GitHub URL
|
||||
* @param options 选项
|
||||
*/
|
||||
export async function findAvailableDownloadUrl (
|
||||
originalUrl: string,
|
||||
options: {
|
||||
mirrors?: string[];
|
||||
timeout?: number;
|
||||
customMirror?: string;
|
||||
testMethod?: 'head' | 'get';
|
||||
/** 是否使用详细验证(验证 Content-Type 和 Content-Length) */
|
||||
validateContent?: boolean;
|
||||
/** 期望的最小文件大小(字节),用于验证 */
|
||||
minFileSize?: number;
|
||||
/** 是否使用懒加载的快速镜像列表 */
|
||||
useFastMirrors?: boolean;
|
||||
} = {}
|
||||
): Promise<string> {
|
||||
const {
|
||||
timeout = currentConfig.timeout,
|
||||
customMirror = currentConfig.customMirror,
|
||||
testMethod = 'head',
|
||||
validateContent = true, // 默认启用内容验证
|
||||
minFileSize,
|
||||
useFastMirrors = true, // 默认使用快速镜像列表
|
||||
} = options;
|
||||
|
||||
// 获取镜像列表
|
||||
let mirrors = options.mirrors;
|
||||
if (!mirrors) {
|
||||
if (useFastMirrors) {
|
||||
// 使用懒加载的快速镜像列表
|
||||
mirrors = await getFastMirrors();
|
||||
} else {
|
||||
mirrors = currentConfig.fileMirrors;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用增强验证或简单测试
|
||||
const testWithValidation = async (url: string): Promise<boolean> => {
|
||||
if (validateContent) {
|
||||
const result = await validateUrl(url, timeout);
|
||||
// 额外检查文件大小
|
||||
if (result.valid && minFileSize && result.contentLength && result.contentLength < minFileSize) {
|
||||
return false;
|
||||
}
|
||||
return result.valid;
|
||||
}
|
||||
return testMethod === 'head' ? testUrlHead(url, timeout) : testUrl(url, timeout);
|
||||
};
|
||||
|
||||
// 1. 如果设置了自定义镜像,优先使用
|
||||
if (customMirror) {
|
||||
const customUrl = buildMirrorUrl(originalUrl, customMirror);
|
||||
if (await testWithValidation(customUrl)) {
|
||||
return customUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 先测试原始 URL
|
||||
if (await testWithValidation(originalUrl)) {
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
// 3. 测试镜像源(已按延迟排序)
|
||||
let testedCount = 0;
|
||||
for (const mirror of mirrors) {
|
||||
if (!mirror) continue; // 跳过空字符串
|
||||
const mirrorUrl = buildMirrorUrl(originalUrl, mirror);
|
||||
testedCount++;
|
||||
if (await testWithValidation(mirrorUrl)) {
|
||||
return mirrorUrl;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`所有下载源都不可用(已测试 ${testedCount} 个镜像),请检查网络连接或配置自定义镜像`);
|
||||
}
|
||||
|
||||
// ============== 版本和 Release 相关(减少 API 依赖) ==============
|
||||
|
||||
/**
|
||||
* 语义化版本正则(简化版,用于排序)
|
||||
*/
|
||||
const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-.]+))?(?:\+([0-9A-Za-z-.]+))?$/;
|
||||
|
||||
/**
|
||||
* 解析语义化版本号
|
||||
*/
|
||||
function parseSemVerSimple (version: string): { major: number; minor: number; patch: number; prerelease: string; } | null {
|
||||
const match = version.match(SEMVER_REGEX);
|
||||
if (!match) return null;
|
||||
return {
|
||||
major: parseInt(match[1] ?? '0', 10),
|
||||
minor: parseInt(match[2] ?? '0', 10),
|
||||
patch: parseInt(match[3] ?? '0', 10),
|
||||
prerelease: match[4] || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个版本号
|
||||
*/
|
||||
function compareSemVerSimple (a: string, b: string): number {
|
||||
const pa = parseSemVerSimple(a);
|
||||
const pb = parseSemVerSimple(b);
|
||||
if (!pa && !pb) return 0;
|
||||
if (!pa) return -1;
|
||||
if (!pb) return 1;
|
||||
|
||||
if (pa.major !== pb.major) return pa.major - pb.major;
|
||||
if (pa.minor !== pb.minor) return pa.minor - pb.minor;
|
||||
if (pa.patch !== pb.patch) return pa.patch - pb.patch;
|
||||
|
||||
// 预发布版本排在正式版本前面
|
||||
if (pa.prerelease && !pb.prerelease) return -1;
|
||||
if (!pa.prerelease && pb.prerelease) return 1;
|
||||
|
||||
return pa.prerelease.localeCompare(pb.prerelease);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 tags 列表中获取最新的 release tag
|
||||
* 不依赖 GitHub API
|
||||
*/
|
||||
export async function getLatestReleaseTag (owner: string, repo: string): Promise<string> {
|
||||
const result = await getAllGitHubTags(owner, repo);
|
||||
|
||||
// 过滤出符合 semver 的 tags
|
||||
const releaseTags = result.tags.filter(tag => SEMVER_REGEX.test(tag));
|
||||
|
||||
if (releaseTags.length === 0) {
|
||||
throw new Error('未找到有效的 release tag');
|
||||
}
|
||||
|
||||
// 按版本号排序,取最新的
|
||||
releaseTags.sort(compareSemVerSimple);
|
||||
const latest = releaseTags[releaseTags.length - 1];
|
||||
|
||||
if (!latest) {
|
||||
throw new Error('未找到有效的 release tag');
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接构建 GitHub release 下载 URL
|
||||
* 不需要调用 API,直接基于 tag 和 asset 名称构建
|
||||
*/
|
||||
export function buildReleaseDownloadUrl (
|
||||
owner: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
assetName: string
|
||||
): string {
|
||||
return `https://github.com/${owner}/${repo}/releases/download/${tag}/${assetName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 GitHub release 信息(优先使用非 API 方法)
|
||||
*
|
||||
* 策略:
|
||||
* 1. 先通过 git refs 获取 tags
|
||||
* 2. 直接构建下载 URL,不依赖 API
|
||||
* 3. 仅当需要 changelog 时才使用 API
|
||||
*/
|
||||
export async function getGitHubRelease (
|
||||
owner: string,
|
||||
repo: string,
|
||||
tag: string = 'latest',
|
||||
options: {
|
||||
/** 需要获取的 asset 名称列表 */
|
||||
assetNames?: string[];
|
||||
/** 是否需要获取 changelog(需要调用 API) */
|
||||
fetchChangelog?: boolean;
|
||||
} = {}
|
||||
): Promise<{
|
||||
tag_name: string;
|
||||
assets: Array<{
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
}>;
|
||||
body?: string;
|
||||
}> {
|
||||
const { assetNames = [], fetchChangelog = false } = options;
|
||||
|
||||
// 1. 获取实际的 tag 名称
|
||||
let actualTag: string;
|
||||
if (tag === 'latest') {
|
||||
actualTag = await getLatestReleaseTag(owner, repo);
|
||||
} else {
|
||||
actualTag = tag;
|
||||
}
|
||||
|
||||
// 2. 构建 assets 列表(不需要 API)
|
||||
const assets = assetNames.map(name => ({
|
||||
name,
|
||||
browser_download_url: buildReleaseDownloadUrl(owner, repo, actualTag, name),
|
||||
}));
|
||||
|
||||
// 3. 如果不需要 changelog 且有 assetNames,直接返回
|
||||
if (!fetchChangelog && assetNames.length > 0) {
|
||||
return {
|
||||
tag_name: actualTag,
|
||||
assets,
|
||||
body: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 需要更多信息时,尝试调用 API(作为备选)
|
||||
const endpoint = `https://api.github.com/repos/${owner}/${repo}/releases/tags/${actualTag}`;
|
||||
|
||||
for (const apiBase of currentConfig.apiMirrors) {
|
||||
try {
|
||||
const url = endpoint.replace('https://api.github.com', apiBase);
|
||||
const response = await PromiseTimer(
|
||||
RequestUtil.HttpGetJson<any>(url, 'GET', undefined, {
|
||||
'User-Agent': 'NapCat',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}),
|
||||
currentConfig.timeout
|
||||
);
|
||||
return response;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. API 全部失败,但如果有 assetNames,仍然返回构建的 URL
|
||||
if (assetNames.length > 0) {
|
||||
return {
|
||||
tag_name: actualTag,
|
||||
assets,
|
||||
body: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('无法获取 release 信息,所有 API 源都不可用');
|
||||
}
|
||||
|
||||
// ============== Tags 缓存 ==============
|
||||
|
||||
interface TagsCache {
|
||||
tags: string[];
|
||||
mirror: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// 缓存 tags 结果(5 分钟有效)
|
||||
const TAGS_CACHE_TTL = 5 * 60 * 1000;
|
||||
const tagsCache: Map<string, TagsCache> = new Map();
|
||||
|
||||
/**
|
||||
* 获取所有 GitHub tags(带缓存)
|
||||
* 使用懒加载的快速镜像列表,按测速延迟排序依次尝试
|
||||
*/
|
||||
export async function getAllGitHubTags (owner: string, repo: string): Promise<{ tags: string[], mirror: string; }> {
|
||||
const cacheKey = `${owner}/${repo}`;
|
||||
|
||||
// 检查缓存
|
||||
const cached = tagsCache.get(cacheKey);
|
||||
if (cached && (Date.now() - cached.timestamp) < TAGS_CACHE_TTL) {
|
||||
return { tags: cached.tags, mirror: cached.mirror };
|
||||
}
|
||||
|
||||
const baseUrl = `https://github.com/${owner}/${repo}.git/info/refs?service=git-upload-pack`;
|
||||
|
||||
// 解析 tags 的辅助函数
|
||||
const parseTags = (raw: string): string[] => {
|
||||
return raw
|
||||
.split('\n')
|
||||
.map((line: string) => {
|
||||
const match = line.match(/refs\/tags\/(.+)$/);
|
||||
return match ? match[1] : undefined;
|
||||
})
|
||||
.filter((tag): tag is string => tag !== undefined && !tag.endsWith('^{}'));
|
||||
};
|
||||
|
||||
// 尝试从 URL 获取 tags
|
||||
const fetchFromUrl = async (url: string): Promise<string[] | null> => {
|
||||
try {
|
||||
const raw = await PromiseTimer(
|
||||
RequestUtil.HttpGetText(url),
|
||||
currentConfig.timeout
|
||||
);
|
||||
|
||||
// 检查返回内容是否有效(不是 HTML 错误页面)
|
||||
if (raw.includes('<!DOCTYPE') || raw.includes('<html')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tags = parseTags(raw);
|
||||
if (tags.length > 0) {
|
||||
return tags;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取快速镜像列表(懒加载,首次调用会测速,已按延迟排序)
|
||||
let fastMirrors: string[] = [];
|
||||
try {
|
||||
fastMirrors = await getFastMirrors();
|
||||
} catch (e) {
|
||||
// 忽略错误,继续使用空列表
|
||||
}
|
||||
|
||||
// 构建 URL 列表(快速镜像 + 原始 URL)
|
||||
const mirrorUrls = fastMirrors.filter(m => m).map(m => ({ url: buildMirrorUrl(baseUrl, m), mirror: m }));
|
||||
mirrorUrls.push({ url: baseUrl, mirror: 'github.com' }); // 添加原始 URL
|
||||
|
||||
// 按顺序尝试每个镜像(已按延迟排序),成功即返回
|
||||
for (const { url, mirror } of mirrorUrls) {
|
||||
const tags = await fetchFromUrl(url);
|
||||
if (tags && tags.length > 0) {
|
||||
// 缓存结果
|
||||
tagsCache.set(cacheKey, { tags, mirror, timestamp: Date.now() });
|
||||
return { tags, mirror };
|
||||
}
|
||||
}
|
||||
|
||||
// 如果快速镜像都失败,回退到原始镜像列表
|
||||
const allMirrors = currentConfig.fileMirrors.filter(m => m);
|
||||
for (const mirror of allMirrors) {
|
||||
// 跳过已经尝试过的镜像
|
||||
if (fastMirrors.includes(mirror)) continue;
|
||||
|
||||
const url = buildMirrorUrl(baseUrl, mirror);
|
||||
const tags = await fetchFromUrl(url);
|
||||
if (tags && tags.length > 0) {
|
||||
// 缓存结果
|
||||
tagsCache.set(cacheKey, { tags, mirror, timestamp: Date.now() });
|
||||
return { tags, mirror };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('无法获取 tags,所有源都不可用');
|
||||
}
|
||||
|
||||
// ============== Action Artifacts 支持 ==============
|
||||
|
||||
export interface ActionArtifact {
|
||||
id: number;
|
||||
name: string;
|
||||
size_in_bytes: number;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
archive_download_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 GitHub Action 最新运行的 artifacts
|
||||
* 用于下载 nightly/dev 版本
|
||||
*/
|
||||
export async function getLatestActionArtifacts (
|
||||
owner: string,
|
||||
repo: string,
|
||||
workflow: string = 'build.yml',
|
||||
branch: string = 'main'
|
||||
): Promise<ActionArtifact[]> {
|
||||
const endpoint = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflow}/runs?branch=${branch}&status=success&per_page=1`;
|
||||
|
||||
try {
|
||||
const runsResponse = await RequestUtil.HttpGetJson<{
|
||||
workflow_runs: Array<{ id: number; }>;
|
||||
}>(endpoint, 'GET', undefined, {
|
||||
'User-Agent': 'NapCat',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
});
|
||||
|
||||
const workflowRuns = runsResponse.workflow_runs;
|
||||
if (!workflowRuns || workflowRuns.length === 0) {
|
||||
throw new Error('No successful workflow runs found');
|
||||
}
|
||||
|
||||
const firstRun = workflowRuns[0];
|
||||
if (!firstRun) {
|
||||
throw new Error('No workflow run found');
|
||||
}
|
||||
const runId = firstRun.id;
|
||||
const artifactsEndpoint = `https://api.github.com/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`;
|
||||
|
||||
const artifactsResponse = await RequestUtil.HttpGetJson<{
|
||||
artifacts: ActionArtifact[];
|
||||
}>(artifactsEndpoint, 'GET', undefined, {
|
||||
'User-Agent': 'NapCat',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
});
|
||||
|
||||
return artifactsResponse.artifacts || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
4
packages/napcat-core/external/appid.json
vendored
4
packages/napcat-core/external/appid.json
vendored
@@ -498,5 +498,9 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -126,5 +126,9 @@
|
||||
"6.9.86-42941-arm64": {
|
||||
"send": "2346108",
|
||||
"recv": "09675F0"
|
||||
},
|
||||
"9.9.26-44175-x64": {
|
||||
"send": "0A0F2EC",
|
||||
"recv": "1D3AD4D"
|
||||
}
|
||||
}
|
||||
4
packages/napcat-core/external/packet.json
vendored
4
packages/napcat-core/external/packet.json
vendored
@@ -638,5 +638,9 @@
|
||||
"6.9.86-42941-arm64": {
|
||||
"send": "3DDDAD0",
|
||||
"recv": "3DE03E0"
|
||||
},
|
||||
"9.9.26-44175-x64": {
|
||||
"send": "2CD84A0",
|
||||
"recv": "2CDBA20"
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ export class NapCatCore {
|
||||
container.bind(TypedEventEmitter).toConstantValue(this.event);
|
||||
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
|
||||
container.bind(ServiceClass).toSelf();
|
||||
console.log(`Registering service handler for: ${serviceName}`);
|
||||
//console.log(`Registering service handler for: ${serviceName}`);
|
||||
this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => {
|
||||
const serviceInstance = container.get(ServiceClass);
|
||||
return serviceInstance.handler(seq, hex_data);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 用于后续使用
|
||||
@@ -79,7 +80,10 @@ export async function NCoreInitFramework (
|
||||
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 {
|
||||
|
||||
@@ -2,14 +2,8 @@
|
||||
const path = require('path');
|
||||
|
||||
async function initializeNapCat (session, loginService, registerCallback) {
|
||||
// const logFile = path.join(currentPath, 'napcat.log');
|
||||
|
||||
console.log('[NapCat] [Info] 开始初始化NapCat');
|
||||
|
||||
// fs.writeFileSync(logFile, '', { flag: 'w' });
|
||||
|
||||
// fs.writeFileSync(logFile, '[NapCat] [Info] NapCat 初始化成功\n', { flag: 'a' });
|
||||
|
||||
try {
|
||||
const currentPath = path.dirname(__filename);
|
||||
const { NCoreInitFramework } = await import('file://' + path.join(currentPath, './napcat.mjs'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
35
packages/napcat-onebot/event/notice/OB11GroupGrayTipEvent.ts
Normal file
35
packages/napcat-onebot/event/notice/OB11GroupGrayTipEvent.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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, '../'),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%" %*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -455,7 +455,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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,18 +152,31 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
// ------------注册中间件------------
|
||||
// 使用express的json中间件
|
||||
app.use(express.json());
|
||||
// 启用gzip压缩(对所有响应启用,阈值1KB)
|
||||
app.use(compression({
|
||||
level: 6, // 压缩级别 1-9,6 是性能和压缩率的平衡点
|
||||
threshold: 1024, // 只压缩大于 1KB 的响应
|
||||
filter: (req, res) => {
|
||||
// 不压缩 SSE 和 WebSocket 升级请求
|
||||
if (req.headers['accept'] === 'text/event-stream') {
|
||||
return false;
|
||||
}
|
||||
// 使用默认过滤器
|
||||
return compression.filter(req, res);
|
||||
},
|
||||
}));
|
||||
|
||||
// CORS中间件
|
||||
// TODO:
|
||||
app.use(cors);
|
||||
|
||||
// 如果是webui字体文件,挂载字体文件
|
||||
app.use('/webui/fonts/AaCute.woff', async (_req, res, next) => {
|
||||
const isFontExist = await WebUiConfig.CheckWebUIFontExist();
|
||||
if (isFontExist) {
|
||||
res.sendFile(WebUiConfig.GetWebUIFontPath());
|
||||
// 自定义字体文件路由 - 返回用户上传的字体文件
|
||||
app.use('/webui/fonts/CustomFont.woff', async (_req, res) => {
|
||||
const fontPath = await WebUiConfig.GetWebUIFontPath();
|
||||
if (fontPath) {
|
||||
res.sendFile(fontPath);
|
||||
} else {
|
||||
next();
|
||||
res.status(404).send('Custom font not found');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -175,6 +198,32 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
res.send(css);
|
||||
});
|
||||
|
||||
// 动态生成 sw.js
|
||||
app.get('/webui/sw.js', async (_req, res) => {
|
||||
try {
|
||||
// 读取模板文件
|
||||
let templatePath = resolve(__dirname, 'static', 'sw_template.js');
|
||||
if (!existsSync(templatePath)) {
|
||||
templatePath = resolve(__dirname, 'src', 'assets', 'sw_template.js');
|
||||
}
|
||||
|
||||
let swContent = readFileSync(templatePath, 'utf-8');
|
||||
|
||||
// 替换版本号
|
||||
// 使用 napCatVersion,如果为 alpha 则尝试加上时间戳或其他标识以避免缓存冲突,或者直接使用
|
||||
// 用户要求控制 sw.js 版本,napCatVersion 是核心控制点
|
||||
swContent = swContent.replace('{{VERSION}}', napCatVersion);
|
||||
|
||||
res.header('Content-Type', 'application/javascript');
|
||||
res.header('Service-Worker-Allowed', '/webui/');
|
||||
res.header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.send(swContent);
|
||||
} catch (error) {
|
||||
console.error('[NapCat] [WebUi] Error generating sw.js', error);
|
||||
res.status(500).send('Error generating service worker');
|
||||
}
|
||||
});
|
||||
|
||||
// ------------中间件结束------------
|
||||
|
||||
// ------------挂载路由------------
|
||||
@@ -187,7 +236,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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3,7 +3,8 @@ 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';
|
||||
|
||||
export const GetNapCatVersion: RequestHandler = (_, res) => {
|
||||
const data = WebUiDataRuntime.GetNapCatVersion();
|
||||
@@ -15,7 +16,121 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的版本(release + action artifacts)
|
||||
* 支持分页
|
||||
*/
|
||||
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 includeActions = req.query['includeActions'] !== 'false';
|
||||
const typeFilter = req.query['type'] as string | undefined; // 'release' | 'action' | 'all'
|
||||
const searchQuery = (req.query['search'] as string || '').toLowerCase().trim();
|
||||
|
||||
let tags: string[] = [];
|
||||
let usedMirror = '';
|
||||
try {
|
||||
const result = await getAllTags();
|
||||
tags = result.tags;
|
||||
usedMirror = result.mirror;
|
||||
} catch {
|
||||
// 如果获取 tags 失败,返回空列表而不是抛出错误
|
||||
tags = [];
|
||||
}
|
||||
|
||||
// 解析版本信息
|
||||
const versions: VersionInfo[] = tags.map(tag => {
|
||||
// 检查是否是预发布版本
|
||||
const isPrerelease = /-(alpha|beta|rc|dev|pre|snapshot)/i.test(tag);
|
||||
return {
|
||||
tag,
|
||||
type: isPrerelease ? 'prerelease' : 'release',
|
||||
};
|
||||
});
|
||||
|
||||
// 使用语义化版本排序(最新的在前)
|
||||
versions.sort((a, b) => -compareSemVer(a.tag, b.tag));
|
||||
|
||||
// 获取 Action Artifacts(如果请求)
|
||||
let actionVersions: VersionInfo[] = [];
|
||||
if (includeActions) {
|
||||
try {
|
||||
const artifacts = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main');
|
||||
actionVersions = artifacts
|
||||
.filter(a => a.name.includes('NapCat'))
|
||||
.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,
|
||||
}));
|
||||
} catch {
|
||||
// 忽略 action artifacts 获取失败
|
||||
}
|
||||
}
|
||||
|
||||
// 合并版本列表(action 在最前面)
|
||||
let allVersions = [...actionVersions, ...versions];
|
||||
|
||||
// 按类型过滤
|
||||
if (typeFilter && typeFilter !== 'all') {
|
||||
if (typeFilter === 'release') {
|
||||
allVersions = allVersions.filter(v => v.type === 'release' || v.type === 'prerelease');
|
||||
} else if (typeFilter === 'action') {
|
||||
allVersions = allVersions.filter(v => v.type === 'action');
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
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' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
406
packages/napcat-webui-backend/src/api/Debug.ts
Normal file
406
packages/napcat-webui-backend/src/api/Debug.ts
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,86 @@ 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);
|
||||
|
||||
// 确定目标版本 tag
|
||||
// 如果指定了版本,使用指定版本;否则使用 'latest'
|
||||
const targetTag = targetVersion || 'latest';
|
||||
webUiLogger?.log(`[NapCat Update] Target version: ${targetTag}`);
|
||||
|
||||
// 使用 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}文件`);
|
||||
}
|
||||
|
||||
// 检查是否需要强制更新(降级警告)
|
||||
const currentVersion = WebUiDataRuntime.GetNapCatVersion();
|
||||
webUiLogger?.log(`[NapCat Update] Current version: ${currentVersion}, Target version: ${release.tag_name}`);
|
||||
|
||||
if (!force && currentVersion) {
|
||||
// 简单的版本比较(可选的降级保护)
|
||||
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(release.tag_name);
|
||||
|
||||
const isDowngrade =
|
||||
targetMajor < currMajor ||
|
||||
(targetMajor === currMajor && targetMinor < currMinor) ||
|
||||
(targetMajor === currMajor && targetMinor === currMinor && targetPatch < currPatch);
|
||||
|
||||
if (isDowngrade) {
|
||||
webUiLogger?.log(`[NapCat Update] Downgrade from ${currentVersion} to ${release.tag_name}, force=${force}`);
|
||||
// 不阻止降级,只是记录日志
|
||||
}
|
||||
}
|
||||
|
||||
webUiLogger?.log(`[NapCat Update] Updating to version: ${release.tag_name}`);
|
||||
|
||||
// 创建临时目录
|
||||
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);
|
||||
// 使用 mirror 模块查找可用的下载 URL
|
||||
// 启用内容验证,确保返回的是有效文件而非错误页面
|
||||
const downloadUrl = await findAvailableDownloadUrl(shellZipAsset.browser_download_url, {
|
||||
validateContent: true, // 验证 Content-Type 和状态码
|
||||
minFileSize: 1024 * 1024, // 最小 1MB,确保不是错误页面
|
||||
timeout: 10000, // 10秒超时
|
||||
});
|
||||
|
||||
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 +221,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 +239,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 +250,16 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
|
||||
// 如果有替换失败的文件,创建更新配置文件
|
||||
if (failedFiles.length > 0) {
|
||||
const updateConfig: UpdateConfig = {
|
||||
version: latestRelease.tag_name,
|
||||
version: release.tag_name,
|
||||
updateTime: new Date().toISOString(),
|
||||
files: failedFiles,
|
||||
changelog: latestRelease.body || ''
|
||||
changelog: release.body || ''
|
||||
};
|
||||
|
||||
// 保存更新配置文件
|
||||
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 +269,36 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
|
||||
sendSuccess(res, {
|
||||
status: 'completed',
|
||||
message,
|
||||
newVersion: latestRelease.tag_name,
|
||||
newVersion: release.tag_name,
|
||||
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 +310,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 +325,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 +341,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);
|
||||
}
|
||||
}
|
||||
|
||||
132
packages/napcat-webui-backend/src/assets/sw_template.js
Normal file
132
packages/napcat-webui-backend/src/assets/sw_template.js
Normal file
@@ -0,0 +1,132 @@
|
||||
const CACHE_NAME = 'napcat-webui-v{{VERSION}}';
|
||||
const ASSETS_TO_CACHE = [
|
||||
'/webui/'
|
||||
];
|
||||
|
||||
// 安装阶段:预缓存核心文件
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting(); // 强制立即接管
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
// 这里的资源如果加载失败不应该阻断 SW 安装
|
||||
return cache.addAll(ASSETS_TO_CACHE).catch(err => console.warn('Failed to cache core assets', err));
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// 激活阶段:清理旧缓存
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName.startsWith('napcat-webui-') && cacheName !== CACHE_NAME) {
|
||||
console.log('[SW] Deleting old cache:', cacheName);
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
self.clients.claim(); // 立即控制所有客户端
|
||||
});
|
||||
|
||||
// 拦截请求
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// 1. API 请求:仅网络 (Network Only)
|
||||
if (url.pathname.startsWith('/api/') || url.pathname.includes('/socket')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 强缓存策略 (Cache First)
|
||||
// - 外部 QQ 头像 (q1.qlogo.cn)
|
||||
// - 静态资源 (assets, fonts)
|
||||
// - 常见静态文件后缀
|
||||
const isQLogo = url.hostname === 'q1.qlogo.cn';
|
||||
const isCustomFont = url.pathname.includes('CustomFont.woff'); // 用户自定义字体,不强缓存
|
||||
const isThemeCss = url.pathname.includes('files/theme.css'); // 主题 CSS,不强缓存
|
||||
const isStaticAsset = url.pathname.includes('/webui/assets/') ||
|
||||
url.pathname.includes('/webui/fonts/');
|
||||
const isStaticFile = /\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|ico)$/i.test(url.pathname);
|
||||
|
||||
if (!isCustomFont && !isThemeCss && (isQLogo || isStaticAsset || isStaticFile)) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((response) => {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// 跨域请求 (qlogo) 需要 mode: 'no-cors' 才能缓存 opaque response,
|
||||
// 但 fetch(event.request) 默认会继承 request 的 mode。
|
||||
// 如果是 img标签发起的请求,通常 mode 是 no-cors 或 cors。
|
||||
// 对于 opaque response (status 0), cache API 允许缓存。
|
||||
return fetch(event.request).then((response) => {
|
||||
// 对 qlogo 允许 status 0 (opaque)
|
||||
// 对其他资源要求 status 200
|
||||
const isValidResponse = response && (
|
||||
response.status === 200 ||
|
||||
response.type === 'basic' ||
|
||||
(isQLogo && response.type === 'opaque')
|
||||
);
|
||||
|
||||
if (!isValidResponse) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const responseToCache = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. HTML 页面 / 导航请求 -> 网络优先 (Network First)
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
const responseToCache = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
return caches.match(event.request);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 其他 Same-Origin 请求 -> Stale-While-Revalidate
|
||||
// 优先返回缓存,同时后台更新缓存,保证下次访问是新的
|
||||
if (url.origin === self.location.origin) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cachedResponse) => {
|
||||
const fetchPromise = fetch(event.request).then((networkResponse) => {
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
const responseToCache = networkResponse.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
}
|
||||
return networkResponse;
|
||||
});
|
||||
// 如果有缓存,返回缓存;否则等待网络
|
||||
return cachedResponse || fetchPromise;
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 默认:网络优先
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match(event.request))
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -4,9 +4,11 @@ export const themeType = Type.Object(
|
||||
{
|
||||
dark: Type.Record(Type.String(), Type.String()),
|
||||
light: Type.Record(Type.String(), Type.String()),
|
||||
fontMode: Type.String({ default: 'system' }),
|
||||
},
|
||||
{
|
||||
default: {
|
||||
fontMode: 'system',
|
||||
dark: {
|
||||
'--heroui-background': '0 0% 0%',
|
||||
'--heroui-foreground-50': '240 5.88% 10%',
|
||||
@@ -124,11 +126,11 @@ export const themeType = Type.Object(
|
||||
'--heroui-border-width-medium': '2px',
|
||||
'--heroui-border-width-large': '3px',
|
||||
'--heroui-box-shadow-small':
|
||||
'0px 0px 5px 0px rgba(0, 0, 0, .05), 0px 2px 10px 0px rgba(0, 0, 0, .2), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
|
||||
'0px 0px 5px 0px rgba(0, 0, 0, .05), 0px 2px 10px 0px rgba(0, 0, 0, .2), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
|
||||
'--heroui-box-shadow-medium':
|
||||
'0px 0px 15px 0px rgba(0, 0, 0, .06), 0px 2px 30px 0px rgba(0, 0, 0, .22), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
|
||||
'0px 0px 15px 0px rgba(0, 0, 0, .06), 0px 2px 30px 0px rgba(0, 0, 0, .22), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
|
||||
'--heroui-box-shadow-large':
|
||||
'0px 0px 30px 0px rgba(0, 0, 0, .07), 0px 30px 60px 0px rgba(0, 0, 0, .26), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
|
||||
'0px 0px 30px 0px rgba(0, 0, 0, .07), 0px 30px 60px 0px rgba(0, 0, 0, .26), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
|
||||
'--heroui-hover-opacity': '.9',
|
||||
},
|
||||
light: {
|
||||
@@ -248,11 +250,11 @@ export const themeType = Type.Object(
|
||||
'--heroui-border-width-medium': '2px',
|
||||
'--heroui-border-width-large': '3px',
|
||||
'--heroui-box-shadow-small':
|
||||
'0px 0px 5px 0px rgba(0, 0, 0, .02), 0px 2px 10px 0px rgba(0, 0, 0, .06), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
|
||||
'0px 0px 5px 0px rgba(0, 0, 0, .02), 0px 2px 10px 0px rgba(0, 0, 0, .06), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
|
||||
'--heroui-box-shadow-medium':
|
||||
'0px 0px 15px 0px rgba(0, 0, 0, .03), 0px 2px 30px 0px rgba(0, 0, 0, .08), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
|
||||
'0px 0px 15px 0px rgba(0, 0, 0, .03), 0px 2px 30px 0px rgba(0, 0, 0, .08), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
|
||||
'--heroui-box-shadow-large':
|
||||
'0px 0px 30px 0px rgba(0, 0, 0, .04), 0px 30px 60px 0px rgba(0, 0, 0, .12), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
|
||||
'0px 0px 30px 0px rgba(0, 0, 0, .04), 0px 30px 60px 0px rgba(0, 0, 0, .12), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
|
||||
'--heroui-hover-opacity': '.8',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,30 +4,51 @@ import fs from 'fs';
|
||||
import type { Request, Response } from 'express';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
|
||||
// 支持的字体格式
|
||||
const SUPPORTED_FONT_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf'];
|
||||
|
||||
// 清理旧的字体文件
|
||||
const cleanOldFontFiles = (fontsPath: string) => {
|
||||
for (const ext of SUPPORTED_FONT_EXTENSIONS) {
|
||||
const fontPath = path.join(fontsPath, `webui${ext}`);
|
||||
try {
|
||||
if (fs.existsSync(fontPath)) {
|
||||
fs.unlinkSync(fontPath);
|
||||
}
|
||||
} catch {
|
||||
// 忽略删除失败
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const webUIFontStorage = multer.diskStorage({
|
||||
destination: (_, __, cb) => {
|
||||
try {
|
||||
const fontsPath = path.dirname(WebUiConfig.GetWebUIFontPath());
|
||||
const fontsPath = path.dirname(WebUiConfig.GetWebUIFontPathSync());
|
||||
// 确保字体目录存在
|
||||
fs.mkdirSync(fontsPath, { recursive: true });
|
||||
// 清理旧的字体文件
|
||||
cleanOldFontFiles(fontsPath);
|
||||
cb(null, fontsPath);
|
||||
} catch (error) {
|
||||
// 确保错误信息被正确传递
|
||||
cb(new Error(`创建字体目录失败:${(error as Error).message}`), '');
|
||||
}
|
||||
},
|
||||
filename: (_, __, cb) => {
|
||||
// 统一保存为webui.woff
|
||||
cb(null, 'webui.woff');
|
||||
filename: (_, file, cb) => {
|
||||
// 保留原始扩展名,统一文件名为 webui
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, `webui${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const webUIFontUpload = multer({
|
||||
storage: webUIFontStorage,
|
||||
fileFilter: (_, file, cb) => {
|
||||
// 再次验证文件类型
|
||||
if (!file.originalname.toLowerCase().endsWith('.woff')) {
|
||||
cb(new Error('只支持WOFF格式的字体文件'));
|
||||
// 验证文件类型
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!SUPPORTED_FONT_EXTENSIONS.includes(ext)) {
|
||||
cb(new Error('只支持 WOFF/WOFF2/TTF/OTF 格式的字体文件'));
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
@@ -41,8 +62,6 @@ const webUIFontUploader = (req: Request, res: Response) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
webUIFontUpload(req, res, (error) => {
|
||||
if (error) {
|
||||
// 错误处理
|
||||
// sendError(res, error.message, true);
|
||||
return reject(error);
|
||||
}
|
||||
return resolve(true);
|
||||
|
||||
@@ -1 +1 @@
|
||||
VITE_DEBUG_BACKEND_URL="http://127.0.0.1:6099"
|
||||
VITE_DEBUG_BACKEND_URL="http://127.0.0.1:6099"
|
||||
@@ -5,12 +5,19 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host=0.0.0.0",
|
||||
"build": "tsc && vite build",
|
||||
"build": "vite build",
|
||||
"build:full": "tsc && vite build",
|
||||
"fontmin": "node scripts/fontmin.cjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -46,11 +53,10 @@
|
||||
"@heroui/theme": "2.4.6",
|
||||
"@heroui/tooltip": "2.2.8",
|
||||
"@monaco-editor/loader": "^1.4.0",
|
||||
"@monaco-editor/react": "4.7.0-rc.0",
|
||||
"@react-aria/visually-hidden": "^3.8.19",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
@@ -59,10 +65,7 @@
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"echarts": "^5.5.1",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"framer-motion": "^12.0.6",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"motion": "^12.0.6",
|
||||
"path-browserify": "^1.0.1",
|
||||
"qface": "^1.4.1",
|
||||
@@ -79,7 +82,6 @@
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-photo-view": "^1.2.7",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-router-dom": "^7.1.4",
|
||||
"react-use-websocket": "^4.11.1",
|
||||
"react-window": "^1.8.11",
|
||||
@@ -107,10 +109,15 @@
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-prettier": "5.2.3",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"fontmin": "^0.9.9",
|
||||
"glob": "^10.3.10",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^3.4.2",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-image-optimizer": "^2.0.3",
|
||||
"vite-plugin-static-copy": "^2.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
@@ -124,4 +131,4 @@
|
||||
"react-dom": "$react-dom"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
137
packages/napcat-webui-frontend/scripts/fontmin.cjs
Normal file
137
packages/napcat-webui-frontend/scripts/fontmin.cjs
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Fontmin Script - 动态裁剪字体
|
||||
* 扫描 src 目录中所有中文字符,生成字体子集
|
||||
*/
|
||||
const Fontmin = require('fontmin');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const glob = require('glob');
|
||||
|
||||
// 配置
|
||||
const SOURCE_FONT = path.resolve(__dirname, '../src/assets/fonts/AaCute-full.ttf');
|
||||
const SOURCE_TTF_ORIGINAL = path.resolve(__dirname, '../src/assets/fonts/AaCute.ttf');
|
||||
const OUTPUT_DIR = path.resolve(__dirname, '../public/fonts');
|
||||
const OUTPUT_NAME = 'AaCute.woff';
|
||||
const SRC_DIR = path.resolve(__dirname, '../src');
|
||||
|
||||
// 基础字符集(常用汉字 + 标点 + 数字 + 字母)
|
||||
const BASE_CHARS = `
|
||||
0123456789
|
||||
abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ
|
||||
,。!?、;:""''()【】《》…—·
|
||||
,.:;!?'"()[]<>-_+=*/\\|@#$%^&~\`
|
||||
基础信息系统版本网络配置服务器客户端终端日志调试关于设置主题
|
||||
登录退出确定取消保存删除编辑新建刷新加载更新下载上传
|
||||
成功失败错误警告提示信息状态在线离线连接断开
|
||||
用户名密码账号验证码记住自动
|
||||
文件管理打开关闭复制粘贴剪切重命名移动
|
||||
发送消息输入内容搜索查找筛选排序
|
||||
帮助文档教程反馈问题建议
|
||||
开启关闭启用禁用显示隐藏展开收起
|
||||
返回前进上一步下一步完成跳过
|
||||
今天昨天明天时间日期年月日时分秒
|
||||
总量使用占用剩余内存内核主频型号
|
||||
有新版本可用当前最新立即稍后
|
||||
`;
|
||||
|
||||
/**
|
||||
* 从源码文件中提取所有中文字符
|
||||
*/
|
||||
function extractCharsFromSource () {
|
||||
const chars = new Set(BASE_CHARS.replace(/\s/g, ''));
|
||||
|
||||
// 匹配所有 .tsx, .ts, .jsx, .js, .css 文件
|
||||
const files = glob.sync(`${SRC_DIR}/**/*.{tsx,ts,jsx,js,css}`, {
|
||||
ignore: ['**/node_modules/**']
|
||||
});
|
||||
|
||||
// 中文字符正则
|
||||
const chineseRegex = /[\u4e00-\u9fa5]/g;
|
||||
|
||||
files.forEach(file => {
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const matches = content.match(chineseRegex);
|
||||
if (matches) {
|
||||
matches.forEach(char => chars.add(char));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Warning: Could not read file ${file}`);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(chars).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 fontmin
|
||||
*/
|
||||
async function run () {
|
||||
console.log('🔍 Scanning source files for Chinese characters...');
|
||||
const text = extractCharsFromSource();
|
||||
console.log(`📝 Found ${text.length} unique characters`);
|
||||
|
||||
// 检查源字体是否存在
|
||||
let sourceFont = SOURCE_FONT;
|
||||
if (!fs.existsSync(SOURCE_FONT)) {
|
||||
// 尝试查找原始 TTF 并复制(不重命名,保留原始)
|
||||
if (fs.existsSync(SOURCE_TTF_ORIGINAL)) {
|
||||
console.log('📦 Copying original font to AaCute-full.ttf...');
|
||||
fs.copyFileSync(SOURCE_TTF_ORIGINAL, SOURCE_FONT);
|
||||
} else {
|
||||
console.error(`❌ Source font not found: ${SOURCE_FONT}`);
|
||||
console.log('💡 Please ensure AaCute.ttf exists in src/assets/fonts/');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✂️ Subsetting font...');
|
||||
|
||||
// 确保输出目录存在
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const fontmin = new Fontmin()
|
||||
.src(sourceFont)
|
||||
.use(Fontmin.glyph({ text }))
|
||||
.use(Fontmin.ttf2woff())
|
||||
.dest(OUTPUT_DIR);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fontmin.run((err, files) => {
|
||||
if (err) {
|
||||
console.error('❌ Fontmin error:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
// 重命名输出文件
|
||||
const generatedWoff = path.join(OUTPUT_DIR, 'AaCute-full.woff');
|
||||
const targetFile = path.join(OUTPUT_DIR, OUTPUT_NAME);
|
||||
|
||||
if (fs.existsSync(generatedWoff)) {
|
||||
// 如果目标文件存在,先删除
|
||||
if (fs.existsSync(targetFile)) {
|
||||
fs.unlinkSync(targetFile);
|
||||
}
|
||||
fs.renameSync(generatedWoff, targetFile);
|
||||
}
|
||||
|
||||
// 清理生成的 TTF 文件
|
||||
const generatedTtf = path.join(OUTPUT_DIR, 'AaCute-full.ttf');
|
||||
if (fs.existsSync(generatedTtf)) {
|
||||
fs.unlinkSync(generatedTtf);
|
||||
}
|
||||
|
||||
if (fs.existsSync(targetFile)) {
|
||||
const stats = fs.statSync(targetFile);
|
||||
const sizeKB = (stats.size / 1024).toFixed(2);
|
||||
console.log(`✅ Font subset created: ${targetFile} (${sizeKB} KB)`);
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
BIN
packages/napcat-webui-frontend/src/assets/fonts/AaCute-full.ttf
Normal file
BIN
packages/napcat-webui-frontend/src/assets/fonts/AaCute-full.ttf
Normal file
Binary file not shown.
BIN
packages/napcat-webui-frontend/src/assets/fonts/AaCute.ttf
Normal file
BIN
packages/napcat-webui-frontend/src/assets/fonts/AaCute.ttf
Normal file
Binary file not shown.
BIN
packages/napcat-webui-frontend/src/assets/fonts/AaCute.woff
Normal file
BIN
packages/napcat-webui-frontend/src/assets/fonts/AaCute.woff
Normal file
Binary file not shown.
@@ -10,21 +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'
|
||||
size='sm'
|
||||
className="bg-primary/10 text-primary"
|
||||
>
|
||||
构造消息
|
||||
</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'
|
||||
|
||||
@@ -1,46 +1,126 @@
|
||||
import Editor, { OnMount, loader } from '@monaco-editor/react';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import React, { useImperativeHandle, useEffect, useState } from 'react';
|
||||
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import monaco from '@/monaco';
|
||||
const getLanguageExtension = (lang?: string) => {
|
||||
switch (lang) {
|
||||
case 'json': return json();
|
||||
default: return [];
|
||||
}
|
||||
};
|
||||
|
||||
loader.config({
|
||||
monaco,
|
||||
});
|
||||
|
||||
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
|
||||
test?: string;
|
||||
export interface CodeEditorProps {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
language?: string;
|
||||
defaultLanguage?: string;
|
||||
onChange?: (value: string | undefined) => void;
|
||||
height?: string;
|
||||
options?: any;
|
||||
onMount?: any;
|
||||
}
|
||||
|
||||
export type CodeEditorRef = monaco.editor.IStandaloneCodeEditor;
|
||||
export interface CodeEditorRef {
|
||||
getValue: () => string;
|
||||
}
|
||||
|
||||
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>(
|
||||
(props, ref) => {
|
||||
const { isDark } = useTheme();
|
||||
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>((props, ref) => {
|
||||
const { isDark } = useTheme();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [val, setVal] = useState(props.value || props.defaultValue || '');
|
||||
const internalRef = React.useRef<ReactCodeMirrorRef>(null);
|
||||
|
||||
const handleEditorDidMount: OnMount = (editor, monaco) => {
|
||||
if (ref) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(editor);
|
||||
} else {
|
||||
(ref as React.RefObject<CodeEditorRef>).current = editor;
|
||||
}
|
||||
}
|
||||
if (props.onMount) {
|
||||
props.onMount(editor, monaco);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (props.value !== undefined) {
|
||||
setVal(props.value);
|
||||
}
|
||||
}, [props.value]);
|
||||
|
||||
return (
|
||||
<Editor
|
||||
{...props}
|
||||
onMount={handleEditorDidMount}
|
||||
theme={isDark ? 'vs-dark' : 'light'}
|
||||
useImperativeHandle(ref, () => ({
|
||||
getValue: () => {
|
||||
// Prefer getting dynamic value from view, fallback to state
|
||||
return internalRef.current?.view?.state.doc.toString() || val;
|
||||
}
|
||||
}));
|
||||
|
||||
const customTheme = EditorView.theme({
|
||||
"&": {
|
||||
fontSize: "14px",
|
||||
height: "100% !important",
|
||||
},
|
||||
".cm-scroller": {
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
|
||||
lineHeight: "1.6",
|
||||
overflow: "auto !important",
|
||||
height: "100% !important",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "transparent",
|
||||
borderRight: "none",
|
||||
color: isDark ? "#ffffff50" : "#00000040",
|
||||
},
|
||||
".cm-gutterElement": {
|
||||
paddingLeft: "12px",
|
||||
paddingRight: "12px",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "transparent",
|
||||
color: isDark ? "#fff" : "#000",
|
||||
},
|
||||
".cm-content": {
|
||||
caretColor: isDark ? "#fff" : "#000",
|
||||
paddingTop: "12px",
|
||||
paddingBottom: "12px",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: isDark ? "#ffffff10" : "#00000008",
|
||||
},
|
||||
".cm-selectionMatch": {
|
||||
backgroundColor: isDark ? "#ffffff20" : "#00000010",
|
||||
},
|
||||
});
|
||||
|
||||
const extensions = [
|
||||
customTheme,
|
||||
getLanguageExtension(props.language || props.defaultLanguage),
|
||||
props.options?.wordWrap === 'on' ? EditorView.lineWrapping : [],
|
||||
props.options?.readOnly ? EditorView.editable.of(false) : [],
|
||||
].flat();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ fontSize: props.options?.fontSize || 14, height: props.height || '100%', display: 'flex', flexDirection: 'column' }}
|
||||
className={clsx(
|
||||
'rounded-xl border overflow-hidden transition-colors',
|
||||
isDark
|
||||
? 'border-white/10 bg-[#282c34]'
|
||||
: 'border-default-200 bg-white'
|
||||
)}
|
||||
>
|
||||
<CodeMirror
|
||||
ref={internalRef}
|
||||
value={props.value ?? props.defaultValue}
|
||||
height="100%"
|
||||
className="h-full w-full"
|
||||
theme={isDark ? oneDark : 'light'}
|
||||
extensions={extensions}
|
||||
onChange={(value) => {
|
||||
setVal(value);
|
||||
props.onChange?.(value);
|
||||
}}
|
||||
readOnly={props.options?.readOnly}
|
||||
basicSetup={{
|
||||
lineNumbers: props.options?.lineNumbers !== 'off',
|
||||
foldGutter: props.options?.folding !== false,
|
||||
highlightActiveLine: props.options?.renderLineHighlight !== 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default CodeEditor;
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface NetworkDisplayCardProps<T extends keyof NetworkType> {
|
||||
onEnable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onEnableDebug: () => Promise<void>;
|
||||
showType?: boolean;
|
||||
}
|
||||
|
||||
const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
@@ -36,6 +37,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
onEnable,
|
||||
onDelete,
|
||||
onEnableDebug,
|
||||
showType,
|
||||
}: NetworkDisplayCardProps<T>) => {
|
||||
const { name, enable, debug } = data;
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -59,15 +61,16 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
|
||||
return (
|
||||
<DisplayCardContainer
|
||||
className="w-full max-w-[420px]"
|
||||
className='w-full max-w-[420px]'
|
||||
tag={showType ? typeLabel : undefined}
|
||||
action={
|
||||
<div className="flex gap-2 w-full">
|
||||
<div className='flex gap-2 w-full'>
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className="flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-warning/20 hover:text-warning transition-colors"
|
||||
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-warning/20 hover:text-warning transition-colors'
|
||||
startContent={<FiEdit3 size={16} />}
|
||||
onPress={onEdit}
|
||||
isDisabled={editing}
|
||||
@@ -81,10 +84,10 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className={clsx(
|
||||
"flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors",
|
||||
'flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors',
|
||||
debug
|
||||
? "hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary"
|
||||
: "hover:bg-success/20 hover:text-success data-[hover=true]:text-success"
|
||||
? 'hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary'
|
||||
: 'hover:bg-success/20 hover:text-success data-[hover=true]:text-success'
|
||||
)}
|
||||
startContent={<CgDebug size={16} />}
|
||||
onPress={handleEnableDebug}
|
||||
@@ -112,11 +115,11 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
isSelected={enable}
|
||||
onChange={handleEnable}
|
||||
classNames={{
|
||||
wrapper: "group-data-[selected=true]:bg-primary-400",
|
||||
wrapper: 'group-data-[selected=true]:bg-primary-400',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={typeLabel}
|
||||
title={name}
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
{(() => {
|
||||
@@ -124,29 +127,30 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
|
||||
if (targetFullField) {
|
||||
// 模式1:存在全宽字段(如URL),布局为:
|
||||
// Row 1: 名称 (全宽)
|
||||
// Row 1: 类型 (全宽)
|
||||
// Row 2: 全宽字段 (全宽)
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
|
||||
>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>名称</span>
|
||||
<div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
|
||||
{name}
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>类型</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{typeLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
|
||||
>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{targetFullField.label}</span>
|
||||
<div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{targetFullField.render
|
||||
? targetFullField.render(targetFullField.value)
|
||||
: (
|
||||
<span className={clsx(
|
||||
typeof targetFullField.value === 'string' && (targetFullField.value.startsWith('http') || targetFullField.value.includes('.') || targetFullField.value.includes(':')) ? 'font-mono' : ''
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
{String(targetFullField.value)}
|
||||
</span>
|
||||
)}
|
||||
@@ -156,7 +160,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
);
|
||||
} else {
|
||||
// 模式2:无全宽字段,布局为 4 个小块 (2行 x 2列)
|
||||
// Row 1: 名称 | Field 0
|
||||
// Row 1: 类型 | Field 0
|
||||
// Row 2: Field 1 | Field 2
|
||||
const displayFields = fields.slice(0, 3);
|
||||
return (
|
||||
@@ -164,9 +168,9 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
<div
|
||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
|
||||
>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>名称</span>
|
||||
<div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
|
||||
{name}
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>类型</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{typeLabel}
|
||||
</div>
|
||||
</div>
|
||||
{displayFields.map((field, index) => (
|
||||
@@ -175,7 +179,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
|
||||
>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{field.label}</span>
|
||||
<div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{field.render
|
||||
? (
|
||||
field.render(field.value)
|
||||
@@ -183,7 +187,8 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
: (
|
||||
<span className={clsx(
|
||||
typeof field.value === 'string' && (field.value.startsWith('http') || field.value.includes('.') || field.value.includes(':')) ? 'font-mono' : ''
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
{String(field.value)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import key from '@/const/key';
|
||||
|
||||
|
||||
export interface ContainerProps {
|
||||
title: string;
|
||||
tag?: React.ReactNode;
|
||||
@@ -24,7 +23,6 @@ export interface DisplayCardProps {
|
||||
const DisplayCardContainer: React.FC<ContainerProps> = ({
|
||||
title: _title,
|
||||
action,
|
||||
tag,
|
||||
enableSwitch,
|
||||
children,
|
||||
className,
|
||||
@@ -40,11 +38,6 @@ const DisplayCardContainer: React.FC<ContainerProps> = ({
|
||||
)}
|
||||
>
|
||||
<CardHeader className='p-4 pb-2 flex items-center justify-between gap-3'>
|
||||
{tag && (
|
||||
<div className='text-center text-default-500 font-medium mb-1 absolute top-0 left-1/2 -translate-x-1/2 text-xs pointer-events-none bg-default-200/50 dark:bg-default-100/50 backdrop-blur-sm px-3 py-0.5 rounded-b-lg shadow-sm z-10'>
|
||||
{tag}
|
||||
</div>
|
||||
)}
|
||||
<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'>
|
||||
|
||||
@@ -24,7 +24,7 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
|
||||
className={clsx(
|
||||
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all',
|
||||
hasBackground
|
||||
? 'bg-white/20 dark:bg-black/10 hover:bg-white/40 dark:hover:bg-black/20'
|
||||
? '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'
|
||||
@@ -35,16 +35,18 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
|
||||
<CardBody className='items-center md:gap-1 p-1 md:p-2'>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex-1 font-mono font-bold text-default-700 dark:text-gray-200',
|
||||
'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 font-medium text-default-500',
|
||||
'whitespace-nowrap text-nowrap flex-shrink-0 font-medium',
|
||||
size === 'md' ? 'text-sm' : 'text-xs',
|
||||
hasBackground ? 'text-white/80' : 'text-default-500'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
} from '@heroui/modal';
|
||||
|
||||
interface CreateFileModalProps {
|
||||
isOpen: boolean
|
||||
fileType: 'file' | 'directory'
|
||||
newFileName: string
|
||||
onTypeChange: (type: 'file' | 'directory') => void
|
||||
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onClose: () => void
|
||||
onCreate: () => void
|
||||
isOpen: boolean;
|
||||
fileType: 'file' | 'directory';
|
||||
newFileName: string;
|
||||
onTypeChange: (type: 'file' | 'directory') => void;
|
||||
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onClose: () => void;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
export default function CreateFileModal ({
|
||||
@@ -28,12 +28,12 @@ export default function CreateFileModal ({
|
||||
onCreate,
|
||||
}: CreateFileModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>新建</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<ButtonGroup color='primary'>
|
||||
<ButtonGroup radius='sm' color='primary'>
|
||||
<Button
|
||||
variant={fileType === 'file' ? 'solid' : 'flat'}
|
||||
onPress={() => onTypeChange('file')}
|
||||
@@ -47,14 +47,14 @@ export default function CreateFileModal ({
|
||||
目录
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<Input label='名称' value={newFileName} onChange={onNameChange} />
|
||||
<Input radius='sm' label='名称' value={newFileName} onChange={onNameChange} />
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color='primary' onPress={onCreate}>
|
||||
<Button radius='sm' color='primary' onPress={onCreate}>
|
||||
创建
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
import CodeEditor from '@/components/code_editor';
|
||||
|
||||
interface FileEditModalProps {
|
||||
isOpen: boolean
|
||||
file: { path: string; content: string } | null
|
||||
onClose: () => void
|
||||
onSave: () => void
|
||||
onContentChange: (newContent?: string) => void
|
||||
isOpen: boolean;
|
||||
file: { path: string; content: string; } | null;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onContentChange: (newContent?: string) => void;
|
||||
}
|
||||
|
||||
export default function FileEditModal ({
|
||||
@@ -63,14 +63,22 @@ export default function FileEditModal ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal size='full' isOpen={isOpen} onClose={onClose}>
|
||||
<Modal radius='sm' size='full' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader className='flex items-center gap-2 bg-content2 bg-opacity-50'>
|
||||
<ModalHeader className='flex items-center gap-2 border-b border-default-200/50'>
|
||||
<span>编辑文件</span>
|
||||
<Code className='text-xs'>{file?.path}</Code>
|
||||
<Code radius='sm' className='text-xs'>{file?.path}</Code>
|
||||
<div className="ml-auto text-xs text-default-400 font-normal px-2">
|
||||
按 <span className="px-1 py-0.5 rounded border border-default-300 bg-default-100">Ctrl/Cmd + S</span> 保存
|
||||
</div>
|
||||
</ModalHeader>
|
||||
<ModalBody className='p-0'>
|
||||
<div className='h-full'>
|
||||
<ModalBody className='p-4 bg-content2/50'>
|
||||
<div className='h-full' onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
onSave();
|
||||
}
|
||||
}}>
|
||||
<CodeEditor
|
||||
height='100%'
|
||||
value={file?.content || ''}
|
||||
@@ -80,11 +88,11 @@ export default function FileEditModal ({
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
<ModalFooter className="border-t border-default-200/50">
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color='primary' onPress={onSave}>
|
||||
<Button radius='sm' color='primary' onPress={onSave}>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -14,9 +14,9 @@ import { useEffect } from 'react';
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
isOpen: boolean
|
||||
filePath: string
|
||||
onClose: () => void
|
||||
isOpen: boolean;
|
||||
filePath: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const videoExts = ['.mp4', '.webm'];
|
||||
@@ -75,14 +75,14 @@ export default function FilePreviewModal ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior='inside' size='3xl'>
|
||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose} scrollBehavior='inside' size='3xl'>
|
||||
<ModalContent>
|
||||
<ModalHeader>文件预览</ModalHeader>
|
||||
<ModalBody className='flex justify-center items-center'>
|
||||
{contentElement}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -105,6 +105,7 @@ export default function FileTable ({
|
||||
/>
|
||||
<Table
|
||||
aria-label='文件列表'
|
||||
radius='sm'
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={onSortChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
@@ -175,6 +176,7 @@ export default function FileTable ({
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
radius='sm'
|
||||
variant='light'
|
||||
onPress={() =>
|
||||
file.isDirectory
|
||||
@@ -202,7 +204,7 @@ export default function FileTable ({
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>{new Date(file.mtime).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<ButtonGroup size='sm' variant='light'>
|
||||
<ButtonGroup radius='sm' size='sm' variant='light'>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='default'
|
||||
|
||||
@@ -10,17 +10,17 @@ import FileManager from '@/controllers/file_manager';
|
||||
import FileIcon from '../file_icon';
|
||||
|
||||
export interface PreviewImage {
|
||||
key: string
|
||||
src: string
|
||||
alt: string
|
||||
key: string;
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
export const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp'];
|
||||
|
||||
export interface ImageNameButtonProps {
|
||||
name: string
|
||||
filePath: string
|
||||
onPreview: () => void
|
||||
onAddPreview: (image: PreviewImage) => void
|
||||
name: string;
|
||||
filePath: string;
|
||||
onPreview: () => void;
|
||||
onAddPreview: (image: PreviewImage) => void;
|
||||
}
|
||||
|
||||
export default function ImageNameButton ({
|
||||
@@ -61,6 +61,7 @@ export default function ImageNameButton ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
radius='sm'
|
||||
variant='light'
|
||||
className='text-left justify-start'
|
||||
onPress={onPreview}
|
||||
|
||||
@@ -83,15 +83,16 @@ function DirectoryTree ({
|
||||
return (
|
||||
<div className='ml-4'>
|
||||
<Button
|
||||
radius='sm'
|
||||
onPress={handleClick}
|
||||
className='py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md'
|
||||
className='py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-sm'
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant={variant}
|
||||
startContent={
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-md',
|
||||
'rounded-sm',
|
||||
isSeleted ? 'bg-primary-600' : 'bg-primary-50'
|
||||
)}
|
||||
>
|
||||
@@ -140,11 +141,11 @@ export default function MoveModal ({
|
||||
onSelect,
|
||||
}: MoveModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>选择目标目录</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='rounded-md p-2 border border-default-300 overflow-auto max-h-60'>
|
||||
<div className='rounded-sm p-2 border border-default-300 overflow-auto max-h-60'>
|
||||
<DirectoryTree
|
||||
basePath='/'
|
||||
onSelect={onSelect}
|
||||
@@ -157,10 +158,10 @@ export default function MoveModal ({
|
||||
<p className='text-sm text-default-500'>移动项:{selectionInfo}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color='primary' onPress={onMove}>
|
||||
<Button radius='sm' color='primary' onPress={onMove}>
|
||||
确定
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
} from '@heroui/modal';
|
||||
|
||||
interface RenameModalProps {
|
||||
isOpen: boolean
|
||||
newFileName: string
|
||||
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onClose: () => void
|
||||
onRename: () => void
|
||||
isOpen: boolean;
|
||||
newFileName: string;
|
||||
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onClose: () => void;
|
||||
onRename: () => void;
|
||||
}
|
||||
|
||||
export default function RenameModal ({
|
||||
@@ -24,17 +24,17 @@ export default function RenameModal ({
|
||||
onRename,
|
||||
}: RenameModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>重命名</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input label='新名称' value={newFileName} onChange={onNameChange} />
|
||||
<Input radius='sm' label='新名称' value={newFileName} onChange={onNameChange} />
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color='primary' onPress={onRename}>
|
||||
<Button radius='sm' color='primary' onPress={onRename}>
|
||||
确定
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable @stylistic/jsx-closing-bracket-location */
|
||||
/* eslint-disable @stylistic/jsx-closing-tag-location */
|
||||
import { Button } from '@heroui/button';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
@@ -19,7 +21,6 @@ export default function Hitokoto () {
|
||||
loading,
|
||||
run,
|
||||
} = useRequest(() => request.get<IHitokoto>('https://hitokoto.152710.xyz/'), {
|
||||
pollingInterval: 10000,
|
||||
throttleWait: 1000,
|
||||
});
|
||||
const backupData = {
|
||||
@@ -41,30 +42,36 @@ export default function Hitokoto () {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className='relative flex flex-col items-center justify-center p-6 min-h-[120px]'>
|
||||
<div className='overflow-hidden'>
|
||||
<div className='relative flex flex-col items-center justify-center p-4 md:p-6'>
|
||||
{loading && !data && <PageLoading />}
|
||||
{data && (
|
||||
<>
|
||||
<IoMdQuote className={clsx(
|
||||
"text-4xl mb-4",
|
||||
hasBackground ? "text-white/30" : "text-primary/20"
|
||||
)} />
|
||||
'text-4xl mb-4',
|
||||
hasBackground ? 'text-white/30' : 'text-primary/20'
|
||||
)}
|
||||
/>
|
||||
<div className={clsx(
|
||||
"text-xl font-medium tracking-wide leading-relaxed italic",
|
||||
hasBackground ? "text-white drop-shadow-sm" : "text-default-700 dark:text-gray-200"
|
||||
)}>
|
||||
'text-xl font-medium tracking-wide leading-relaxed italic',
|
||||
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
|
||||
)}
|
||||
>
|
||||
" {data?.hitokoto} "
|
||||
</div>
|
||||
<div className='mt-4 flex flex-col items-center text-sm'>
|
||||
<span className={clsx(
|
||||
'font-bold',
|
||||
hasBackground ? 'text-white/90' : 'text-primary-500/80'
|
||||
)}>—— {data?.from}</span>
|
||||
)}
|
||||
>—— {data?.from}
|
||||
</span>
|
||||
{data?.from_who && <span className={clsx(
|
||||
"text-xs mt-1",
|
||||
hasBackground ? "text-white/70" : "text-default-400"
|
||||
)}>{data?.from_who}</span>}
|
||||
'text-xs mt-1',
|
||||
hasBackground ? 'text-white/70' : 'text-default-400'
|
||||
)}
|
||||
> {data?.from_who}
|
||||
</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -73,8 +80,8 @@ export default function Hitokoto () {
|
||||
<Tooltip content='刷新' placement='top'>
|
||||
<Button
|
||||
className={clsx(
|
||||
"transition-colors",
|
||||
hasBackground ? "text-white/60 hover:text-white" : "text-default-400 hover:text-primary"
|
||||
'transition-colors',
|
||||
hasBackground ? 'text-white/60 hover:text-white' : 'text-default-400 hover:text-primary'
|
||||
)}
|
||||
onPress={run}
|
||||
size='sm'
|
||||
@@ -89,8 +96,8 @@ export default function Hitokoto () {
|
||||
<Tooltip content='复制' placement='top'>
|
||||
<Button
|
||||
className={clsx(
|
||||
"transition-colors",
|
||||
hasBackground ? "text-white/60 hover:text-white" : "text-default-400 hover:text-success"
|
||||
'transition-colors',
|
||||
hasBackground ? 'text-white/60 hover:text-white' : 'text-default-400 hover:text-success'
|
||||
)}
|
||||
onPress={onCopy}
|
||||
size='sm'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Snippet } from '@heroui/snippet';
|
||||
import { Modal, ModalBody, ModalContent, ModalHeader } from '@heroui/modal';
|
||||
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 { useEffect, useRef, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoLink, IoSend, IoSettingsSharp } from 'react-icons/io5';
|
||||
import { TbApi, TbCode } from 'react-icons/tb';
|
||||
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';
|
||||
@@ -18,7 +19,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';
|
||||
@@ -26,10 +27,11 @@ import DisplayStruct from './display_struct';
|
||||
export interface OneBotApiDebugProps {
|
||||
path: OneBotHttpApiPath;
|
||||
data: OneBotHttpApiContent;
|
||||
adapterName?: string;
|
||||
}
|
||||
|
||||
const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
const { path, data } = props;
|
||||
const { path, data, adapterName } = props;
|
||||
const currentURL = new URL(window.location.origin);
|
||||
currentURL.port = '3000';
|
||||
const defaultHttpUrl = currentURL.href;
|
||||
@@ -37,20 +39,61 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
url: defaultHttpUrl,
|
||||
token: '',
|
||||
});
|
||||
|
||||
const [requestBody, setRequestBody] = useState('{}');
|
||||
const [responseContent, setResponseContent] = useState('');
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [isStructOpen, setIsStructOpen] = 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);
|
||||
const [responseHeight, setResponseHeight] = 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 () => {
|
||||
if (isFetching) return;
|
||||
setIsFetching(true);
|
||||
setResponseStatus(null);
|
||||
const r = toast.loading('正在发送请求...');
|
||||
|
||||
try {
|
||||
const parsedRequestBody = JSON.parse(requestBody);
|
||||
|
||||
// 如果有 adapterName,走后端转发
|
||||
if (adapterName) {
|
||||
request.post(`/api/Debug/call/${adapterName}`, {
|
||||
action: path.replace(/^\//, ''), // 去掉开头的 /
|
||||
params: parsedRequestBody
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
}).then((res) => {
|
||||
if (res.data.code === 0) {
|
||||
setResponseContent(JSON.stringify(res.data.data, null, 2));
|
||||
setResponseStatus({ code: 200, text: 'OK' });
|
||||
} else {
|
||||
setResponseContent(JSON.stringify(res.data, null, 2));
|
||||
setResponseStatus({ code: 500, text: res.data.message });
|
||||
}
|
||||
setResponseExpanded(true);
|
||||
toast.success('请求成功');
|
||||
}).catch((err) => {
|
||||
toast.error('请求失败:' + err.message);
|
||||
setResponseContent(JSON.stringify({ error: err.message }, null, 2));
|
||||
setResponseStatus({ code: 500, text: 'Error' });
|
||||
setResponseExpanded(true);
|
||||
}).finally(() => {
|
||||
setIsFetching(false);
|
||||
toast.dismiss(r);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 回退到旧逻辑 (直接请求)
|
||||
const requestURL = new URL(httpConfig.url);
|
||||
requestURL.pathname = path;
|
||||
request
|
||||
@@ -58,22 +101,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);
|
||||
responseRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
toast.dismiss(r);
|
||||
});
|
||||
} catch (_error) {
|
||||
@@ -86,88 +130,80 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
useEffect(() => {
|
||||
setRequestBody(generateDefaultJson(data.request));
|
||||
setResponseContent('');
|
||||
setResponseStatus(null);
|
||||
}, [path]);
|
||||
|
||||
// Height Resizing Logic
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startHeight = responseHeight;
|
||||
|
||||
const handleMouseMove = (mv: MouseEvent) => {
|
||||
const delta = startY - mv.clientY;
|
||||
// 向上拖动 -> 增加高度
|
||||
setResponseHeight(Math.max(100, Math.min(window.innerHeight - 200, startHeight + delta)));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [responseHeight, setResponseHeight]);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
// 阻止默认滚动行为可能需要谨慎,这里尽量只阻止 handle 上的
|
||||
// e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
const startY = touch.clientY;
|
||||
const startHeight = responseHeight;
|
||||
|
||||
const handleTouchMove = (mv: TouchEvent) => {
|
||||
const mvTouch = mv.touches[0];
|
||||
const delta = startY - mvTouch.clientY;
|
||||
setResponseHeight(Math.max(100, Math.min(window.innerHeight - 200, startHeight + delta)));
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', handleTouchMove);
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
}, [responseHeight, setResponseHeight]);
|
||||
|
||||
|
||||
return (
|
||||
<section className='h-full flex flex-col gap-3 md:gap-4 p-3 md:p-6 bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm rounded-2xl overflow-hidden'>
|
||||
<div className='flex flex-col md:flex-row md:items-center justify-between border-b border-white/10 pb-3 md:pb-4 gap-3'>
|
||||
<div className='flex items-center gap-2 md:gap-4 overflow-hidden'>
|
||||
<h1 className='text-lg md:text-xl font-bold flex items-center gap-2 text-primary-500 flex-shrink-0'>
|
||||
<TbApi size={24} />
|
||||
<span className='truncate'>{data.description}</span>
|
||||
</h1>
|
||||
<Snippet
|
||||
className='bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border border-white/20 hidden md:flex'
|
||||
symbol={<IoLink size={16} className='inline-block mr-1' />}
|
||||
tooltipProps={{ content: '点击复制地址' }}
|
||||
size="sm"
|
||||
>
|
||||
{path}
|
||||
</Snippet>
|
||||
<section className='h-full flex flex-col overflow-hidden bg-transparent'>
|
||||
{/* URL Bar */}
|
||||
<div className='flex flex-wrap md:flex-nowrap items-center gap-2 p-2 md:p-4 pb-2 flex-shrink-0'>
|
||||
<div className={clsx(
|
||||
'flex-grow flex items-center gap-2 px-3 md:px-4 h-10 rounded-xl transition-all w-full md:w-auto',
|
||||
hasBackground ? 'bg-white/5' : 'bg-black/5 dark:bg-white/5'
|
||||
)}>
|
||||
<Chip size="sm" variant="shadow" color="primary" className="font-bold text-[10px] h-5 min-w-[40px]">POST</Chip>
|
||||
<span className={clsx(
|
||||
'text-xs font-mono truncate select-all flex-1 opacity-50',
|
||||
hasBackground ? 'text-white' : 'text-default-600'
|
||||
)}>{path}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2 items-center flex-shrink-0'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color='default'
|
||||
radius='full'
|
||||
isIconOnly
|
||||
className='bg-white/40 dark:bg-white/10 md:hidden font-medium text-default-700'
|
||||
onPress={() => setIsStructOpen(true)}
|
||||
>
|
||||
<TbCode className="text-lg" />
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color='default'
|
||||
radius='full'
|
||||
className='bg-white/40 dark:bg-white/10 hidden md:flex font-medium text-default-700'
|
||||
startContent={<TbCode className="text-lg" />}
|
||||
onPress={() => setIsStructOpen(true)}
|
||||
>
|
||||
数据定义
|
||||
</Button>
|
||||
|
||||
<Popover placement='bottom-end'>
|
||||
<div className='flex items-center gap-2 flex-shrink-0 ml-auto'>
|
||||
<Popover placement='bottom-end' backdrop='blur'>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color='default'
|
||||
radius='full'
|
||||
className='bg-white/40 dark:bg-white/10 text-default-700 font-medium'
|
||||
startContent={<IoSettingsSharp className="animate-spin-slow-on-hover text-lg" />}
|
||||
>
|
||||
配置
|
||||
<Button size='sm' variant='light' radius='full' isIconOnly className='h-10 w-10 opacity-40 hover:opacity-100'>
|
||||
<IoSettingsSharp className="text-lg" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[340px] p-4 bg-white/80 dark:bg-black/80 backdrop-blur-xl border border-white/20 shadow-xl rounded-2xl'>
|
||||
<div className='flex flex-col gap-4 w-full'>
|
||||
<h3 className='font-bold text-lg text-default-700'>请求配置</h3>
|
||||
<Input
|
||||
label='HTTP URL'
|
||||
placeholder='输入 HTTP URL'
|
||||
value={httpConfig.url}
|
||||
onChange={(e) => setHttpConfig({ ...httpConfig, url: e.target.value })}
|
||||
variant='bordered'
|
||||
labelPlacement='outside'
|
||||
classNames={{
|
||||
inputWrapper: 'bg-default-100/50 backdrop-blur-sm border-default-200/50',
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label='Token'
|
||||
placeholder='输入 Token'
|
||||
value={httpConfig.token}
|
||||
onChange={(e) => setHttpConfig({ ...httpConfig, token: e.target.value })}
|
||||
variant='bordered'
|
||||
labelPlacement='outside'
|
||||
classNames={{
|
||||
inputWrapper: 'bg-default-100/50 backdrop-blur-sm border-default-200/50',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent className='w-[260px] p-3 rounded-xl 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' value={httpConfig.url} onChange={(e) => setHttpConfig({ ...httpConfig, url: e.target.value })} size='sm' variant='flat' />
|
||||
<Input label='Token' value={httpConfig.token} onChange={(e) => setHttpConfig({ ...httpConfig, token: e.target.value })} size='sm' variant='flat' />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -176,133 +212,169 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
onPress={sendRequest}
|
||||
color='primary'
|
||||
radius='full'
|
||||
className='font-bold px-6 shadow-lg shadow-primary/30'
|
||||
size='sm'
|
||||
className='h-10 px-6 font-bold shadow-md shadow-primary/20 hover:scale-[1.02] active:scale-[0.98]'
|
||||
isLoading={isFetching}
|
||||
startContent={!isFetching && <IoSend />}
|
||||
startContent={!isFetching && <IoSend className="text-xs" />}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 min-h-0 overflow-auto'>
|
||||
{/* Request Column */}
|
||||
<Card className='bg-white/40 dark:bg-white/5 backdrop-blur-md border border-white/20 shadow-sm h-full flex flex-col'>
|
||||
<CardHeader className='font-bold text-lg gap-2 pb-2 px-4 pt-4 border-b border-white/10 flex-shrink-0 justify-between items-center'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='w-2 h-6 rounded-full bg-primary-500'></span>
|
||||
请求体 (Request)
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<ChatInputModal />
|
||||
<div className='flex-1 flex flex-col min-h-0 bg-transparent'>
|
||||
<div className='px-4 flex flex-wrap items-center justify-between flex-shrink-0 min-h-[36px] gap-2 py-1'>
|
||||
<Tabs
|
||||
size="sm"
|
||||
variant="underlined"
|
||||
selectedKey={activeTab}
|
||||
onSelectionChange={setActiveTab}
|
||||
classNames={{
|
||||
cursor: 'bg-primary h-0.5',
|
||||
tab: 'px-0 mr-5 h-8',
|
||||
tabList: 'p-0 border-none',
|
||||
tabContent: 'text-[11px] font-bold opacity-30 group-data-[selected=true]:opacity-80 transition-opacity'
|
||||
}}
|
||||
>
|
||||
<Tab key="request" title="请求参数" />
|
||||
<Tab key="docs" title="接口定义" />
|
||||
</Tabs>
|
||||
<div className='flex items-center gap-1 ml-auto'>
|
||||
<ChatInputModal>
|
||||
{(onOpen) => (
|
||||
<Tooltip content="构造消息 (CQ码)" closeDelay={0}>
|
||||
<Button
|
||||
isIconOnly
|
||||
size='sm'
|
||||
variant='light'
|
||||
radius='full'
|
||||
className='h-7 w-7 text-primary/80 bg-primary/10 hover:bg-primary/20'
|
||||
onPress={onOpen}
|
||||
>
|
||||
<TbMessageCode size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ChatInputModal>
|
||||
|
||||
<Tooltip content="生成示例参数" closeDelay={0}>
|
||||
<Button
|
||||
isIconOnly
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
variant='light'
|
||||
radius='full'
|
||||
className="bg-primary/10 text-primary"
|
||||
className='h-7 w-7 text-default-400 hover:text-primary hover:bg-default-100/50'
|
||||
onPress={() => setRequestBody(generateDefaultJson(data.request))}
|
||||
>
|
||||
内置示例
|
||||
<TbCode size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className='p-0 flex-1 relative'>
|
||||
<div className='absolute inset-0'>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 min-h-0 relative px-3 pb-2 mt-1'>
|
||||
<div className={clsx(
|
||||
'h-full transition-all',
|
||||
activeTab !== 'request' && 'rounded-xl overflow-y-auto no-scrollbar',
|
||||
hasBackground ? 'bg-transparent' : (activeTab !== 'request' && 'bg-white/10 dark:bg-black/10')
|
||||
)}>
|
||||
{activeTab === 'request' ? (
|
||||
<CodeEditor
|
||||
value={requestBody}
|
||||
onChange={(value) => setRequestBody(value ?? '')}
|
||||
language='json'
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
padding: { top: 10, bottom: 10 },
|
||||
fontSize: 12,
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
padding: { top: 12 },
|
||||
lineNumbersMinChars: 3
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className='p-6 space-y-10'>
|
||||
<section>
|
||||
<h3 className='text-[10px] font-bold opacity-20 uppercase tracking-[0.2em] mb-4'>Request - 请求数据结构</h3>
|
||||
<DisplayStruct schema={parsedRequest} />
|
||||
</section>
|
||||
<div className='h-px bg-white/5 w-full' />
|
||||
<section>
|
||||
<h3 className='text-[10px] font-bold opacity-20 uppercase tracking-[0.2em] mb-4'>Response - 返回数据结构</h3>
|
||||
<DisplayStruct schema={parsedResponse} />
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Area */}
|
||||
<div className='flex-shrink-0 px-3 pb-3'>
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-xl transition-all overflow-hidden border border-white/5 flex flex-col',
|
||||
hasBackground ? 'bg-white/5' : 'bg-white/5 dark:bg-black/5'
|
||||
)}
|
||||
>
|
||||
{/* Header & Resize Handle */}
|
||||
<div
|
||||
className='flex items-center justify-between px-4 py-2 cursor-pointer hover:bg-white/5 transition-all select-none relative group'
|
||||
onClick={() => setResponseExpanded(!responseExpanded)}
|
||||
>
|
||||
{/* Invisble Resize Area that becomes visible/active */}
|
||||
{responseExpanded && (
|
||||
<div
|
||||
className="absolute -top-1 left-0 w-full h-3 cursor-ns-resize z-50 flex items-center justify-center opacity-0 hover:opacity-100 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e); }}
|
||||
onTouchStart={(e) => { e.stopPropagation(); handleTouchStart(e); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-12 h-1 bg-white/20 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<IoChevronDown className={clsx('text-[10px] transition-transform duration-300 opacity-20', !responseExpanded && '-rotate-90')} />
|
||||
<span className='text-[10px] font-semibold tracking-wide opacity-30 uppercase'>Response</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
{responseStatus && (
|
||||
<Chip size="sm" variant="flat" color={responseStatus.code >= 200 && responseStatus.code < 300 ? 'success' : 'danger'} className="h-4 text-[9px] font-mono px-1.5 opacity-50">
|
||||
{responseStatus.code}
|
||||
</Chip>
|
||||
)}
|
||||
<Button size='sm' variant='light' isIconOnly radius='full' className='h-6 w-6 opacity-20 hover:opacity-80 transition-opacity' onClick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(responseContent); toast.success('已复制'); }}>
|
||||
<IoCopy size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Content - Code Editor */}
|
||||
{responseExpanded && (
|
||||
<div style={{ height: responseHeight }} className="relative bg-transparent">
|
||||
<PageLoading loading={isFetching} />
|
||||
<CodeEditor
|
||||
value={responseContent || '// Waiting for response...'}
|
||||
language='json'
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 11,
|
||||
lineNumbers: 'off',
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
readOnly: true,
|
||||
folding: true,
|
||||
padding: { top: 8, bottom: 8 },
|
||||
renderLineHighlight: 'none',
|
||||
automaticLayout: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className='bg-white/40 dark:bg-white/5 backdrop-blur-md border border-white/20 shadow-sm h-full flex flex-col'>
|
||||
<PageLoading loading={isFetching} />
|
||||
<CardHeader className='font-bold text-lg gap-2 pb-2 px-4 pt-4 border-b border-white/10 flex-shrink-0 justify-between items-center'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='w-2 h-6 rounded-full bg-secondary-500'></span>
|
||||
响应 (Response)
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
radius='full'
|
||||
className="bg-primary/10 text-primary"
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(responseContent);
|
||||
toast.success('已复制');
|
||||
}}
|
||||
>
|
||||
复制内容
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardBody className='p-0 flex-1 relative bg-black/5 dark:bg-black/30'>
|
||||
<div className='absolute inset-0 overflow-auto p-4'>
|
||||
<pre className='text-xs font-mono whitespace-pre-wrap break-all'>
|
||||
{responseContent || <span className='text-default-400 italic'>等待请求响应...</span>}
|
||||
</pre>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Struct Display - maybe put in a modal or separate tab?
|
||||
For now, putting it in a collapsed/compact area at bottom is tricky with "h-[calc(100vh)]".
|
||||
User wants "Thorough optimization".
|
||||
I will make Struct Display a Drawer or Modal, OR put it below if we want scrolling.
|
||||
But I set height to fixed full screen.
|
||||
Let's put Struct Display in a Tab or Toggle at Top?
|
||||
Or just let the main container scroll and remove fixed height?
|
||||
Layout choice: Fixed height editors are good for workflow. Structure is reference.
|
||||
I will leave Struct Display OUT of the fixed view, or add a toggle to show it.
|
||||
Let's add a "View Structure" button in header that opens a Modal.
|
||||
Yes, that's cleaner.
|
||||
*/}
|
||||
<Modal
|
||||
isOpen={isStructOpen}
|
||||
onOpenChange={setIsStructOpen}
|
||||
size='5xl'
|
||||
scrollBehavior='inside'
|
||||
backdrop='blur'
|
||||
classNames={{
|
||||
base: 'bg-white/80 dark:bg-black/80 backdrop-blur-xl border border-white/20',
|
||||
header: 'border-b border-white/10',
|
||||
body: 'p-6',
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
{() => (
|
||||
<>
|
||||
<ModalHeader className='flex flex-col gap-1'>
|
||||
API 数据结构定义
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-6'>
|
||||
<div>
|
||||
<h2 className='text-xl font-bold mb-4 text-primary-500'>请求体结构 (Request)</h2>
|
||||
<DisplayStruct schema={parsedRequest} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className='text-xl font-bold mb-4 text-secondary-500'>响应体结构 (Response)</h2>
|
||||
<DisplayStruct schema={parsedResponse} />
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Input } from '@heroui/input';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { ScrollShadow } from "@heroui/scroll-shadow";
|
||||
import { motion } from 'motion/react';
|
||||
import { useState } from 'react';
|
||||
import { TbApi, TbLayoutSidebarLeftCollapseFilled, TbSearch } from 'react-icons/tb';
|
||||
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 {
|
||||
@@ -20,53 +23,81 @@ export interface OneBotApiNavListProps {
|
||||
const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
||||
const { data, selectedApi, onSelect, openSideBar, onToggle } = props;
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const rawGroups = [
|
||||
{ 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 (
|
||||
<>
|
||||
{/* Mobile backdrop overlay */}
|
||||
{openSideBar && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 backdrop-blur-[1px] z-10 md:hidden"
|
||||
onClick={() => onToggle?.(false)}
|
||||
/>
|
||||
)}
|
||||
{/* 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-20 flex-shrink-0 border border-white/10 dark:border-white/5 bg-white/60 dark:bg-black/60 backdrop-blur-2xl shadow-xl overflow-hidden rounded-2xl',
|
||||
'fixed md:relative left-0 top-0 md:top-auto md:left-auto'
|
||||
'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 ? 280 : 0, opacity: openSideBar ? 1 : 0 }}
|
||||
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-[280px] h-full flex flex-col'>
|
||||
<div className='p-3 md:p-4 flex justify-between items-center border-b border-white/10'>
|
||||
<span className='font-bold text-lg px-2 flex items-center gap-2'>
|
||||
<TbApi className="text-primary" /> API 列表
|
||||
</span>
|
||||
{onToggle && (
|
||||
<Button
|
||||
isIconOnly
|
||||
size='sm'
|
||||
variant='light'
|
||||
onPress={() => onToggle(false)}
|
||||
className="text-default-500 hover:text-default-800"
|
||||
>
|
||||
<TbLayoutSidebarLeftCollapseFilled size={20} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='p-3 pb-0'>
|
||||
<div className='w-[260px] h-full flex flex-col'>
|
||||
<div className='p-3'>
|
||||
<Input
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-white/40 dark:bg-white/10 backdrop-blur-md border border-white/20 hover:bg-white/60 dark:hover:bg-white/20 transition-all shadow-sm',
|
||||
input: 'bg-transparent text-default-700 placeholder:text-default-400',
|
||||
'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='搜索 API...'
|
||||
startContent={<TbSearch className="text-default-400" />}
|
||||
placeholder='搜索接口...'
|
||||
startContent={<TbSearch size={14} className="opacity-30" />}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onClear={() => setSearchValue('')}
|
||||
@@ -74,53 +105,72 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollShadow className='flex-1 p-3 flex flex-col gap-2 overflow-y-auto scroll-smooth' size={40}>
|
||||
{Object.entries(data).map(([apiName, api]) => {
|
||||
const isMatch = apiName.toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||
api.description?.toLowerCase().includes(searchValue.toLowerCase());
|
||||
if (!isMatch) return null;
|
||||
|
||||
const isSelected = apiName === selectedApi;
|
||||
|
||||
<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={apiName}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(apiName as OneBotHttpApiPath)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onSelect(apiName as OneBotHttpApiPath)}
|
||||
className="cursor-pointer focus:outline-none"
|
||||
>
|
||||
<Card
|
||||
shadow='none'
|
||||
className={clsx(
|
||||
'w-full border border-transparent transition-all duration-200 group min-h-[60px]',
|
||||
isSelected
|
||||
? 'bg-primary/10 border-primary/20 shadow-sm'
|
||||
: 'bg-transparent hover:bg-white/40 dark:hover:bg-white/5'
|
||||
)}
|
||||
<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)}
|
||||
>
|
||||
<CardBody className='p-3 text-left'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className={clsx(
|
||||
'font-medium text-sm transition-colors',
|
||||
isSelected ? 'text-primary-600 dark:text-primary-400' : 'text-default-700 dark:text-default-200 group-hover:text-default-900'
|
||||
)}>
|
||||
{api.description}
|
||||
</span>
|
||||
<span className={clsx(
|
||||
'text-xs font-mono truncate transition-colors',
|
||||
isSelected ? 'text-primary-400 dark:text-primary-300' : 'text-default-400 group-hover:text-default-500'
|
||||
)}>
|
||||
{apiName}
|
||||
</span>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<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 border-transparent select-none',
|
||||
isSelected
|
||||
? (hasBackground ? '' : 'bg-primary/20 border-primary/20 shadow-sm')
|
||||
: 'hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<span className={clsx(
|
||||
'text-[12px] font-medium transition-colors truncate',
|
||||
isSelected ? 'text-primary' : 'opacity-60'
|
||||
)}>
|
||||
{api.description}
|
||||
</span>
|
||||
<span className={clsx(
|
||||
'text-[10px] font-mono truncate transition-all',
|
||||
isSelected ? 'text-primary/60' : 'opacity-20'
|
||||
)}>
|
||||
{api.path}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
|
||||
@@ -13,18 +13,18 @@ import type {
|
||||
import { renderMessageContent } from '../render_message';
|
||||
|
||||
export interface OneBotMessageProps {
|
||||
data: OB11Message
|
||||
data: OB11Message;
|
||||
}
|
||||
|
||||
export interface OneBotMessageGroupProps {
|
||||
data: OB11GroupMessage
|
||||
data: OB11GroupMessage;
|
||||
}
|
||||
|
||||
export interface OneBotMessagePrivateProps {
|
||||
data: OB11PrivateMessage
|
||||
data: OB11PrivateMessage;
|
||||
}
|
||||
|
||||
const MessageContent: React.FC<{ data: OB11Message }> = ({ data }) => {
|
||||
const MessageContent: React.FC<{ data: OB11Message; }> = ({ data }) => {
|
||||
return (
|
||||
<div className='h-full flex flex-col overflow-hidden flex-1'>
|
||||
<div className='flex gap-2 items-center flex-shrink-0'>
|
||||
@@ -35,8 +35,8 @@ const MessageContent: React.FC<{ data: OB11Message }> = ({ data }) => {
|
||||
<span
|
||||
className={clsx(
|
||||
isOB11GroupMessage(data) &&
|
||||
data.sender.card &&
|
||||
'text-default-400 font-normal'
|
||||
data.sender.card &&
|
||||
'text-default-400 font-normal'
|
||||
)}
|
||||
>
|
||||
{data.sender.nickname}
|
||||
@@ -73,7 +73,7 @@ const OneBotMessageGroup: React.FC<OneBotMessageGroupProps> = ({ data }) => {
|
||||
<div className='h-full overflow-hidden flex flex-col w-full'>
|
||||
<div className='flex items-center p-1 flex-shrink-0'>
|
||||
<Avatar
|
||||
src={`https://p.qlogo.cn/gh/${data.group_id}/${data.group_id}/640/`}
|
||||
src={`https://p.qlogo.cn/gh/${data.group_id}/${data.group_id}/0/`}
|
||||
alt='群头像'
|
||||
size='sm'
|
||||
className='flex-shrink-0 mr-2'
|
||||
|
||||
@@ -3,8 +3,8 @@ import { SharedSelection } from '@heroui/system';
|
||||
import type { Selection } from '@react-types/shared';
|
||||
|
||||
export interface FilterMessageTypeProps {
|
||||
filterTypes: Selection
|
||||
onSelectionChange: (keys: SharedSelection) => void
|
||||
filterTypes: Selection;
|
||||
onSelectionChange: (keys: SharedSelection) => void;
|
||||
}
|
||||
const items = [
|
||||
{ label: '元事件', value: 'meta_event' },
|
||||
@@ -26,6 +26,7 @@ const FilterMessageType: React.FC<FilterMessageTypeProps> = (props) => {
|
||||
}}
|
||||
label='筛选消息类型'
|
||||
selectionMode='multiple'
|
||||
className='w-full'
|
||||
items={items}
|
||||
renderValue={(value) => {
|
||||
if (value.length === items.length) {
|
||||
|
||||
@@ -43,7 +43,7 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={onOpen} color='primary' radius='full' variant='flat'>
|
||||
<Button onPress={onOpen} color='primary' radius='full' variant='flat' size='sm' className="font-medium">
|
||||
构造请求
|
||||
</Button>
|
||||
<Modal
|
||||
@@ -61,7 +61,7 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
|
||||
构造请求
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='h-96 dark:bg-[rgb(30,30,30)] p-2 rounded-md border border-default-100'>
|
||||
<div className='h-96'>
|
||||
<CodeEditor
|
||||
height='100%'
|
||||
defaultLanguage='json'
|
||||
|
||||
@@ -1,34 +1,15 @@
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
const PageBackground = () => {
|
||||
return (
|
||||
<div className='fixed inset-0 w-full h-full -z-10 overflow-hidden bg-gradient-to-br from-indigo-50 via-white to-pink-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900'>
|
||||
{/* 动态呼吸光斑 - ACG风格 */}
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
rotate: [0, 90, 0],
|
||||
opacity: [0.3, 0.5, 0.3]
|
||||
}}
|
||||
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
|
||||
{/* 静态光斑 - ACG风格 */}
|
||||
<div
|
||||
className='absolute top-[-10%] left-[-10%] w-[500px] h-[500px] rounded-full bg-primary-200/40 blur-[100px]'
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.3, 1],
|
||||
x: [0, 100, 0],
|
||||
opacity: [0.3, 0.6, 0.3]
|
||||
}}
|
||||
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut", delay: 2 }}
|
||||
<div
|
||||
className='absolute top-[20%] right-[-10%] w-[400px] h-[400px] rounded-full bg-secondary-200/40 blur-[90px]'
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.1, 1],
|
||||
y: [0, -50, 0],
|
||||
opacity: [0.2, 0.4, 0.2]
|
||||
}}
|
||||
transition={{ duration: 12, repeat: Infinity, ease: "easeInOut", delay: 5 }}
|
||||
<div
|
||||
className='absolute bottom-[-10%] left-[20%] w-[600px] h-[600px] rounded-full bg-pink-200/30 blur-[110px]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,13 @@ import { Spinner } from '@heroui/spinner';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface PageLoadingProps {
|
||||
loading?: boolean
|
||||
loading?: boolean;
|
||||
}
|
||||
const PageLoading: React.FC<PageLoadingProps> = ({ loading }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute top-0 left-0 w-full h-full bg-zinc-500 bg-opacity-10 z-50 flex justify-center items-center backdrop-blur',
|
||||
'absolute top-0 left-0 w-full h-full bg-zinc-500 bg-opacity-10 z-30 flex justify-center items-center backdrop-blur',
|
||||
{
|
||||
hidden: !loading,
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
: (
|
||||
<CardBody className='flex-row items-center gap-4 overflow-hidden relative p-4'>
|
||||
{!hasBackground && (
|
||||
<div className='absolute right-[-10px] bottom-[-10px] text-7xl text-default-400/10 rotate-12 pointer-events-none'>
|
||||
<div className='absolute right-[-10px] bottom-[-10px] text-7xl text-default-400/10 rotate-12 pointer-events-none dark:hidden'>
|
||||
<BsTencentQq />
|
||||
</div>
|
||||
)}
|
||||
@@ -48,7 +48,7 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
<Image
|
||||
src={
|
||||
data?.avatarUrl ??
|
||||
`https://q1.qlogo.cn/g?b=qq&nk=${data?.uin}&s=1`
|
||||
`https://q1.qlogo.cn/g?b=qq&nk=${data?.uin}&s=0`
|
||||
}
|
||||
className='shadow-sm rounded-full w-14 aspect-square ring-2 ring-white/50 dark:ring-white/10'
|
||||
/>
|
||||
@@ -63,13 +63,15 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
<div className={clsx(
|
||||
'text-xl font-bold truncate mb-0.5',
|
||||
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-800 dark:text-gray-100'
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
{data?.nick || '未知用户'}
|
||||
</div>
|
||||
<div className={clsx(
|
||||
'font-mono text-xs tracking-wider',
|
||||
hasBackground ? 'text-white/80' : 'text-default-500 opacity-80'
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
{data?.uin || 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,17 +7,17 @@ import { IoMdRefresh } from 'react-icons/io';
|
||||
import { isQQQuickNewItem } from '@/utils/qq';
|
||||
|
||||
export interface QQItem {
|
||||
uin: string
|
||||
uin: string;
|
||||
}
|
||||
|
||||
interface QuickLoginProps {
|
||||
qqList: (QQItem | LoginListItem)[]
|
||||
refresh: boolean
|
||||
isLoading: boolean
|
||||
selectedQQ: string
|
||||
onUpdateQQList: () => void
|
||||
handleSelectionChange: React.ChangeEventHandler<HTMLSelectElement>
|
||||
onSubmit: () => void
|
||||
qqList: (QQItem | LoginListItem)[];
|
||||
refresh: boolean;
|
||||
isLoading: boolean;
|
||||
selectedQQ: string;
|
||||
onUpdateQQList: () => void;
|
||||
handleSelectionChange: React.ChangeEventHandler<HTMLSelectElement>;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
const QuickLogin: React.FC<QuickLoginProps> = ({
|
||||
|
||||
@@ -24,8 +24,10 @@ const SideBar: React.FC<SideBarProps> = (props) => {
|
||||
const { open, items, onClose } = props;
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const { revokeAuth } = useAuth();
|
||||
const [b64img] = useLocalStorage(key.backgroundImage, '');
|
||||
const dialog = useDialog();
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const onRevokeAuth = () => {
|
||||
dialog.confirm({
|
||||
title: '退出登录',
|
||||
@@ -50,9 +52,11 @@ const SideBar: React.FC<SideBarProps> = (props) => {
|
||||
</AnimatePresence>
|
||||
<motion.div
|
||||
className={clsx(
|
||||
'overflow-hidden fixed top-0 left-0 h-full z-50 md:static shadow-md md:shadow-none rounded-r-md md:rounded-none',
|
||||
b64img ? 'bg-black/20 backdrop-blur-md border-r border-white/10' : 'bg-background',
|
||||
'md:bg-transparent md:border-r-0 md:backdrop-blur-none'
|
||||
'overflow-hidden fixed top-0 left-0 h-full z-50 md:static md:shadow-none rounded-r-2xl md:rounded-none',
|
||||
hasBackground
|
||||
? 'bg-transparent backdrop-blur-md'
|
||||
: 'bg-content1/70 backdrop-blur-xl backdrop-saturate-150 shadow-xl',
|
||||
'md:bg-transparent md:backdrop-blur-none md:backdrop-saturate-100 md:shadow-none'
|
||||
)}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: open ? '16rem' : 0 }}
|
||||
|
||||
@@ -97,11 +97,12 @@ const renderItems = (items: MenuItem[], children = false) => {
|
||||
: (
|
||||
<div
|
||||
className={clsx(
|
||||
'w-3 h-1.5 rounded-full ml-auto shadow-lg',
|
||||
'w-3 h-1.5 rounded-full ml-auto',
|
||||
isActive
|
||||
? 'bg-primary-500 animate-spinner-ease-spin'
|
||||
: 'bg-primary-200 dark:bg-white'
|
||||
? 'bg-primary-500 animate-nav-spin'
|
||||
: 'bg-primary-200 dark:bg-white shadow-lg'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Button } from '@heroui/button';
|
||||
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import { Pagination } from '@heroui/pagination';
|
||||
import { Tabs, Tab } from '@heroui/tabs';
|
||||
import { Input } from '@heroui/input';
|
||||
import { useLocalStorage, useDebounce } from '@uidotdev/usehooks';
|
||||
import { useRequest } from 'ahooks';
|
||||
import clsx from 'clsx';
|
||||
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6';
|
||||
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io';
|
||||
import { FaCircleInfo, FaQq } from 'react-icons/fa6';
|
||||
import { IoLogoChrome, IoLogoOctocat, IoSearch } from 'react-icons/io5';
|
||||
import { RiMacFill } from 'react-icons/ri';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import key from '@/const/key';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import Modal from '@/components/modal';
|
||||
import { hasNewVersion, compareVersion } from '@/utils/version';
|
||||
|
||||
|
||||
export interface SystemInfoItemProps {
|
||||
@@ -23,6 +29,8 @@ export interface SystemInfoItemProps {
|
||||
value?: React.ReactNode;
|
||||
endContent?: React.ReactNode;
|
||||
hasBackground?: boolean;
|
||||
onClick?: () => void;
|
||||
clickable?: boolean;
|
||||
}
|
||||
|
||||
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
||||
@@ -31,21 +39,27 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
||||
icon,
|
||||
endContent,
|
||||
hasBackground = false,
|
||||
onClick,
|
||||
clickable = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx(
|
||||
'flex text-sm gap-2 p-3 items-center rounded-lg border border-white/20 transition-colors',
|
||||
hasBackground
|
||||
? 'bg-white/10 hover:bg-white/20 text-white/90'
|
||||
: 'bg-white/50 dark:bg-white/5 hover:bg-white/70 dark:hover:bg-white/10 text-default-600 dark:text-gray-300'
|
||||
)}>
|
||||
<div className="text-lg opacity-80">{icon}</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex text-sm gap-3 py-2 items-center transition-colors',
|
||||
hasBackground
|
||||
? 'text-white/90'
|
||||
: 'text-default-600 dark:text-gray-300',
|
||||
clickable && 'cursor-pointer hover:bg-default-100/50 dark:hover:bg-default-800/30 rounded-lg -mx-2 px-2'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="text-lg opacity-70">{icon}</div>
|
||||
<div className='w-24 font-medium'>{title}</div>
|
||||
<div className={clsx(
|
||||
'text-xs font-mono',
|
||||
hasBackground ? 'text-white/70' : 'text-default-500'
|
||||
'text-xs font-mono flex-1',
|
||||
hasBackground ? 'text-white/80' : 'text-default-500'
|
||||
)}>{value}</div>
|
||||
<div className='ml-auto'>{endContent}</div>
|
||||
<div>{endContent}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -54,221 +68,562 @@ export interface NewVersionTipProps {
|
||||
currentVersion?: string;
|
||||
}
|
||||
|
||||
// const NewVersionTip = (props: NewVersionTipProps) => {
|
||||
// const { currentVersion } = props;
|
||||
// const dialog = useDialog();
|
||||
// const { data: releaseData, error } = useRequest(() =>
|
||||
// request.get<GithubRelease[]>(
|
||||
// 'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
|
||||
// )
|
||||
// );
|
||||
// 更新状态类型
|
||||
type UpdateStatus = 'idle' | 'updating' | 'success' | 'error';
|
||||
|
||||
// if (error) {
|
||||
// return (
|
||||
// <Tooltip content='检查新版本失败'>
|
||||
// <Button
|
||||
// isIconOnly
|
||||
// radius='full'
|
||||
// color='primary'
|
||||
// variant='shadow'
|
||||
// className='!w-5 !h-5 !min-w-0 text-small shadow-md'
|
||||
// onPress={() => {
|
||||
// dialog.alert({
|
||||
// title: '检查新版本失败',
|
||||
// content: error.message,
|
||||
// });
|
||||
// }}
|
||||
// >
|
||||
// <FaInfo />
|
||||
// </Button>
|
||||
// </Tooltip>
|
||||
// );
|
||||
// }
|
||||
// 更新对话框内容组件
|
||||
const UpdateDialogContent: React.FC<{
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
status: UpdateStatus;
|
||||
errorMessage?: string;
|
||||
}> = ({ currentVersion, latestVersion, status, errorMessage }) => {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* 版本对比 */}
|
||||
<div className="flex items-center justify-between px-6 py-8 bg-default-50 dark:bg-default-100/5 rounded-xl border border-default-100 dark:border-default-100/10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-xs text-default-500 font-medium uppercase tracking-wider">当前版本</span>
|
||||
<Chip size="lg" variant="flat" color="default" classNames={{ content: "font-mono font-bold text-lg" }}>
|
||||
v{currentVersion}
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
// const latestVersion = releaseData?.data?.[0]?.tag_name;
|
||||
<div className="flex flex-col items-center text-primary-500 px-4">
|
||||
<div className="p-2 rounded-full bg-primary-50 dark:bg-primary-900/20">
|
||||
<svg className="w-6 h-6 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// if (!latestVersion || !currentVersion) {
|
||||
// return null;
|
||||
// }
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-xs text-primary-500 font-medium uppercase tracking-wider">最新版本</span>
|
||||
<Chip size="lg" color="primary" variant="shadow" classNames={{ content: "font-mono font-bold text-lg" }}>
|
||||
v{latestVersion}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// if (compareVersion(latestVersion, currentVersion) <= 0) {
|
||||
// return null;
|
||||
// }
|
||||
{/* 更新状态显示 */}
|
||||
{status === 'updating' && (
|
||||
<div className='flex flex-col items-center justify-center gap-3 py-4 px-4 rounded-lg bg-primary-50/50 dark:bg-primary-900/20 border border-primary-200/50 dark:border-primary-700/30'>
|
||||
<Spinner size='md' color='primary' />
|
||||
<div className='text-center'>
|
||||
<p className='text-sm font-medium text-primary-600 dark:text-primary-400'>
|
||||
正在更新中...
|
||||
</p>
|
||||
<p className='text-xs text-default-500 mt-1'>
|
||||
请耐心等待,更新可能需要几分钟
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
// const middleVersions: GithubRelease[] = [];
|
||||
{status === 'success' && (
|
||||
<div className='flex flex-col items-center justify-center gap-3 py-4 px-4 rounded-lg bg-success-50/50 dark:bg-success-900/20 border border-success-200/50 dark:border-success-700/30'>
|
||||
<div className='w-12 h-12 rounded-full bg-success-100 dark:bg-success-900/40 flex items-center justify-center'>
|
||||
<svg className='w-6 h-6 text-success-600 dark:text-success-400' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
|
||||
</svg>
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<p className='text-sm font-medium text-success-600 dark:text-success-400'>
|
||||
更新完成
|
||||
</p>
|
||||
<p className='text-xs text-default-500 mt-1'>
|
||||
请重启 NapCat 以应用新版本
|
||||
</p>
|
||||
</div>
|
||||
<div className='mt-2 p-3 rounded-lg bg-warning-50/50 dark:bg-warning-900/20 border border-warning-200/50 dark:border-warning-700/30'>
|
||||
<p className='text-xs text-warning-700 dark:text-warning-400 flex items-center gap-1'>
|
||||
<svg className='w-4 h-4' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' />
|
||||
</svg>
|
||||
<span>请手动重启 NapCat,更新才会生效</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
// for (let i = 0; i < releaseData.data.length; i++) {
|
||||
// const versionInfo = releaseData.data[i];
|
||||
// if (compareVersion(versionInfo.tag_name, currentVersion) > 0) {
|
||||
// middleVersions.push(versionInfo);
|
||||
// } else {
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
|
||||
// const AISummaryComponent = () => {
|
||||
// const {
|
||||
// data: aiSummaryData,
|
||||
// loading: aiSummaryLoading,
|
||||
// error: aiSummaryError,
|
||||
// run: runAiSummary,
|
||||
// } = useRequest(
|
||||
// (version) =>
|
||||
// request.get<ServerResponse<string | null>>(
|
||||
// `https://release.nc.152710.xyz/?version=${version}`,
|
||||
// {
|
||||
// timeout: 30000,
|
||||
// }
|
||||
// ),
|
||||
// {
|
||||
// manual: true,
|
||||
// }
|
||||
// );
|
||||
|
||||
// useEffect(() => {
|
||||
// runAiSummary(currentVersion);
|
||||
// }, [currentVersion, runAiSummary]);
|
||||
|
||||
// if (aiSummaryLoading) {
|
||||
// return (
|
||||
// <div className='flex justify-center py-1'>
|
||||
// <Spinner size='sm' />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
// if (aiSummaryError) {
|
||||
// return <div className='text-center text-primary-500'>AI 摘要获取失败</div>;
|
||||
// }
|
||||
// return <span className='text-default-700'>{aiSummaryData?.data.data}</span>;
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <Tooltip content='有新版本可用'>
|
||||
// <Button
|
||||
// isIconOnly
|
||||
// radius='full'
|
||||
// color='primary'
|
||||
// variant='shadow'
|
||||
// className='!w-5 !h-5 !min-w-0 text-small shadow-md'
|
||||
// onPress={() => {
|
||||
// dialog.confirm({
|
||||
// title: '有新版本可用',
|
||||
// content: (
|
||||
// <div className='space-y-2'>
|
||||
// <div className='text-sm space-x-2'>
|
||||
// <span>当前版本</span>
|
||||
// <Chip color='primary' variant='flat'>
|
||||
// v{currentVersion}
|
||||
// </Chip>
|
||||
// </div>
|
||||
// <div className='text-sm space-x-2'>
|
||||
// <span>最新版本</span>
|
||||
// <Chip color='primary'>{latestVersion}</Chip>
|
||||
// </div>
|
||||
// <div className='p-2 rounded-md bg-content2 text-sm'>
|
||||
// <div className='text-primary-400 font-bold flex items-center gap-1 mb-1'>
|
||||
// <BsStars />
|
||||
// <span>AI总结</span>
|
||||
// </div>
|
||||
// <AISummaryComponent />
|
||||
// </div>
|
||||
// <div className='text-sm space-y-2 !mt-4'>
|
||||
// {middleVersions.map((versionInfo) => (
|
||||
// <div
|
||||
// key={versionInfo.tag_name}
|
||||
// className='p-4 bg-content1 rounded-md shadow-small'
|
||||
// >
|
||||
// <TailwindMarkdown content={versionInfo.body} />
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// </div>
|
||||
// ),
|
||||
// scrollBehavior: 'inside',
|
||||
// size: '3xl',
|
||||
// confirmText: '前往下载',
|
||||
// onConfirm () {
|
||||
// window.open(
|
||||
// 'https://github.com/NapNeko/NapCatQQ/releases',
|
||||
// '_blank',
|
||||
// 'noopener'
|
||||
// );
|
||||
// },
|
||||
// });
|
||||
// }}
|
||||
// >
|
||||
// <FaInfo />
|
||||
// </Button>
|
||||
// </Tooltip>
|
||||
// );
|
||||
// };
|
||||
{status === 'error' && (
|
||||
<div className='flex flex-col items-center justify-center gap-3 py-4 px-4 rounded-lg bg-danger-50/50 dark:bg-danger-900/20 border border-danger-200/50 dark:border-danger-700/30'>
|
||||
<div className='w-12 h-12 rounded-full bg-danger-100 dark:bg-danger-900/40 flex items-center justify-center'>
|
||||
<svg className='w-6 h-6 text-danger-600 dark:text-danger-400' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
|
||||
</svg>
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<p className='text-sm font-medium text-danger-600 dark:text-danger-400'>
|
||||
更新失败
|
||||
</p>
|
||||
<p className='text-xs text-default-500 mt-1'>
|
||||
{errorMessage || '请稍后重试或手动更新'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NewVersionTip = (props: NewVersionTipProps) => {
|
||||
const { currentVersion } = props;
|
||||
const dialog = useDialog();
|
||||
const { data: latestVersion, error } = useRequest(WebUIManager.getLatestTag);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const { data: latestVersion, error } = useRequest(WebUIManager.getLatestTag, {
|
||||
cacheKey: 'napcat-latest-tag',
|
||||
staleTime: 10 * 60 * 1000,
|
||||
cacheTime: 30 * 60 * 1000,
|
||||
});
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
|
||||
|
||||
if (error || !latestVersion || !currentVersion || latestVersion === currentVersion) {
|
||||
// 使用 SemVer 规范比较版本号
|
||||
if (error || !latestVersion || !currentVersion || !hasNewVersion(currentVersion, latestVersion)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setUpdateStatus('updating');
|
||||
|
||||
try {
|
||||
await WebUIManager.UpdateNapCat();
|
||||
setUpdateStatus('success');
|
||||
// 显示更新成功对话框
|
||||
dialog.alert({
|
||||
title: '更新完成',
|
||||
content: (
|
||||
<UpdateDialogContent
|
||||
currentVersion={currentVersion}
|
||||
latestVersion={latestVersion}
|
||||
status='success'
|
||||
/>
|
||||
),
|
||||
confirmText: '我知道了',
|
||||
size: 'md',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Update failed:', err);
|
||||
const errMessage = err instanceof Error ? err.message : '未知错误';
|
||||
setUpdateStatus('error');
|
||||
// 显示更新失败对话框
|
||||
dialog.alert({
|
||||
title: '更新失败',
|
||||
content: (
|
||||
<UpdateDialogContent
|
||||
currentVersion={currentVersion}
|
||||
latestVersion={latestVersion}
|
||||
status='error'
|
||||
errorMessage={errMessage}
|
||||
/>
|
||||
),
|
||||
confirmText: '确定',
|
||||
size: 'md',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const showUpdateDialog = () => {
|
||||
dialog.confirm({
|
||||
title: '发现新版本',
|
||||
content: (
|
||||
<UpdateDialogContent
|
||||
currentVersion={currentVersion}
|
||||
latestVersion={latestVersion}
|
||||
status='idle'
|
||||
/>
|
||||
),
|
||||
confirmText: '立即更新',
|
||||
cancelText: '稍后更新',
|
||||
size: 'md',
|
||||
onConfirm: handleUpdate,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip content='有新版本可用'>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
<div className="cursor-pointer" onClick={updateStatus === 'updating' ? undefined : showUpdateDialog}>
|
||||
<Chip
|
||||
size="sm"
|
||||
color="danger"
|
||||
variant="flat"
|
||||
classNames={{
|
||||
content: "font-bold text-[10px] px-1",
|
||||
base: "h-5 min-h-5"
|
||||
}}
|
||||
>
|
||||
{updateStatus === 'updating' ? <Spinner size="sm" color="danger" /> : 'New'}
|
||||
</Chip>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
// 版本信息类型
|
||||
interface VersionInfo {
|
||||
tag: string;
|
||||
type: 'release' | 'prerelease' | 'action';
|
||||
artifactId?: number;
|
||||
artifactName?: string;
|
||||
createdAt?: string;
|
||||
expiresAt?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// 版本选择对话框内容
|
||||
interface VersionSelectDialogProps {
|
||||
currentVersion: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
currentVersion,
|
||||
onClose,
|
||||
}) => {
|
||||
const dialog = useDialog();
|
||||
const [selectedVersion, setSelectedVersion] = useState<VersionInfo | null>(null);
|
||||
const [forceUpdate, setForceUpdate] = useState(false);
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [activeTab, setActiveTab] = useState<'release' | 'action'>('release');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
const pageSize = 15;
|
||||
|
||||
// 获取所有可用版本(带分页、过滤和搜索)
|
||||
const { data: releasesData, loading: releasesLoading, error: releasesError } = useRequest(
|
||||
() => WebUIManager.getAllReleases({
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
includeActions: true,
|
||||
type: activeTab,
|
||||
search: debouncedSearch
|
||||
}),
|
||||
{
|
||||
refreshDeps: [currentPage, activeTab, debouncedSearch],
|
||||
}
|
||||
);
|
||||
|
||||
// 版本列表已在后端过滤,直接使用
|
||||
const filteredVersions = releasesData?.versions || [];
|
||||
|
||||
// 检查是否是降级(使用语义化版本比较)
|
||||
const isDowngrade = useCallback((targetTag: string): boolean => {
|
||||
if (!currentVersion || !targetTag) return false;
|
||||
// Action 版本不算降级
|
||||
if (targetTag.startsWith('action-')) return false;
|
||||
return compareVersion(targetTag, currentVersion) < 0;
|
||||
}, [currentVersion]);
|
||||
|
||||
const selectedVersionTag = selectedVersion?.tag || '';
|
||||
const isSelectedDowngrade = isDowngrade(selectedVersionTag);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!selectedVersion) return;
|
||||
|
||||
if (isSelectedDowngrade && !forceUpdate) {
|
||||
dialog.confirm({
|
||||
title: '确认降级',
|
||||
content: (
|
||||
<div className='space-y-2'>
|
||||
<p className='text-warning-600'>
|
||||
您正在尝试从 <strong>v{currentVersion}</strong> 降级到 <strong>{selectedVersionTag}</strong>
|
||||
</p>
|
||||
<p className='text-sm text-default-500'>
|
||||
降级可能导致配置不兼容或功能丢失,请确认您了解相关风险。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
confirmText: '确认降级',
|
||||
cancelText: '取消',
|
||||
onConfirm: () => performUpdate(true),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await performUpdate(forceUpdate);
|
||||
};
|
||||
|
||||
const performUpdate = async (force: boolean) => {
|
||||
if (!selectedVersion) return;
|
||||
setUpdateStatus('updating');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
await WebUIManager.UpdateNapCatToVersion(selectedVersionTag, force);
|
||||
setUpdateStatus('success');
|
||||
} catch (err) {
|
||||
console.error('Update failed:', err);
|
||||
const errMsg = err instanceof Error ? err.message : '未知错误';
|
||||
setErrorMessage(errMsg);
|
||||
setUpdateStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理分页变化
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
if (updateStatus === 'success') {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center gap-3 py-4'>
|
||||
<div className='w-12 h-12 rounded-full bg-success-100 dark:bg-success-900/40 flex items-center justify-center'>
|
||||
<svg className='w-6 h-6 text-success-600 dark:text-success-400' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
|
||||
</svg>
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<p className='text-sm font-medium text-success-600 dark:text-success-400'>
|
||||
更新到 {selectedVersionTag} 完成
|
||||
</p>
|
||||
<p className='text-xs text-default-500 mt-1'>
|
||||
请重启 NapCat 以应用新版本
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (updateStatus === 'error') {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center gap-3 py-4'>
|
||||
<div className='w-12 h-12 rounded-full bg-danger-100 dark:bg-danger-900/40 flex items-center justify-center'>
|
||||
<svg className='w-6 h-6 text-danger-600 dark:text-danger-400' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
|
||||
</svg>
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<p className='text-sm font-medium text-danger-600 dark:text-danger-400'>
|
||||
更新失败
|
||||
</p>
|
||||
<p className='text-xs text-default-500 mt-1'>
|
||||
{errorMessage || '请稍后重试'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (updateStatus === 'updating') {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center gap-3 py-6'>
|
||||
<Spinner size='lg' color='primary' />
|
||||
<div className='text-center'>
|
||||
<p className='text-sm font-medium text-primary-600 dark:text-primary-400'>
|
||||
正在更新到 {selectedVersionTag}...
|
||||
</p>
|
||||
<p className='text-xs text-default-500 mt-1'>
|
||||
请耐心等待,更新可能需要几分钟
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pagination = releasesData?.pagination;
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{/* 当前版本 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-default-600'>当前版本:</span>
|
||||
<Chip color='primary' variant='flat' size='sm'>
|
||||
v{currentVersion}
|
||||
</Chip>
|
||||
</div>
|
||||
{releasesData?.mirror && (
|
||||
<div className='text-xs text-default-400 flex items-center gap-1'>
|
||||
<span className='w-2 h-2 rounded-full bg-success-500'></span>
|
||||
镜像: {releasesData.mirror}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 版本类型切换 */}
|
||||
<Tabs
|
||||
selectedKey={activeTab}
|
||||
onSelectionChange={(key) => {
|
||||
setActiveTab(key as 'release' | 'action');
|
||||
setCurrentPage(1);
|
||||
setSelectedVersion(null);
|
||||
setSearchQuery('');
|
||||
}}
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant='shadow'
|
||||
className='!w-5 !h-5 !min-w-0 text-small shadow-md'
|
||||
onPress={() => {
|
||||
dialog.confirm({
|
||||
title: '有新版本可用',
|
||||
content: (
|
||||
<div className='space-y-2'>
|
||||
<div className='text-sm space-x-2'>
|
||||
<span>当前版本</span>
|
||||
<Chip color='primary' variant='flat'>
|
||||
v{currentVersion}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className='text-sm space-x-2'>
|
||||
<span>最新版本</span>
|
||||
<Chip color='primary'>v{latestVersion}</Chip>
|
||||
</div>
|
||||
{updating && (
|
||||
<div className='flex justify-center'>
|
||||
<Spinner size='sm' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
confirmText: updating ? '更新中...' : '更新',
|
||||
onConfirm: async () => {
|
||||
setUpdating(true);
|
||||
toast('更新中,预计需要几分钟,请耐心等待', {
|
||||
duration: 3000,
|
||||
});
|
||||
try {
|
||||
await WebUIManager.UpdateNapCat();
|
||||
toast.success('更新完成,重启生效', {
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update failed:', error);
|
||||
toast.success('更新异常', {
|
||||
duration: 5000,
|
||||
});
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
variant='underlined'
|
||||
classNames={{
|
||||
tabList: 'gap-4',
|
||||
}}
|
||||
>
|
||||
<FaInfo />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tab key='release' title='正式版本' />
|
||||
<Tab key='action' title='临时版本 (Action)' />
|
||||
</Tabs>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<Input
|
||||
placeholder='搜索版本号...'
|
||||
size='sm'
|
||||
value={searchQuery}
|
||||
onValueChange={(value) => {
|
||||
setSearchQuery(value);
|
||||
setCurrentPage(1);
|
||||
setSelectedVersion(null);
|
||||
}}
|
||||
startContent={<IoSearch className='text-default-400' />}
|
||||
isClearable
|
||||
onClear={() => setSearchQuery('')}
|
||||
classNames={{
|
||||
inputWrapper: 'h-9',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 版本选择 */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='text-sm font-medium text-default-700'>选择目标版本</label>
|
||||
{releasesData?.pagination && (
|
||||
<span className='text-xs text-default-400'>
|
||||
共 {releasesData.pagination.total} 个版本
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{releasesLoading ? (
|
||||
<div className='flex items-center gap-2 py-2'>
|
||||
<Spinner size='sm' />
|
||||
<span className='text-sm text-default-500'>加载版本列表...</span>
|
||||
</div>
|
||||
) : releasesError ? (
|
||||
<div className='text-sm text-danger-500'>
|
||||
加载版本列表失败: {releasesError.message}
|
||||
</div>
|
||||
) : filteredVersions.length === 0 ? (
|
||||
<div className='text-sm text-default-500 py-4 text-center'>
|
||||
{searchQuery ? `未找到匹配 "${searchQuery}" 的版本` : '暂无可用版本'}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
label='选择版本'
|
||||
placeholder='请选择要更新的版本'
|
||||
selectedKeys={selectedVersion ? [selectedVersionTag] : []}
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedTag = Array.from(keys)[0] as string;
|
||||
const version = filteredVersions.find(v => v.tag === selectedTag);
|
||||
setSelectedVersion(version || null);
|
||||
}}
|
||||
classNames={{
|
||||
trigger: 'h-10',
|
||||
}}
|
||||
>
|
||||
{filteredVersions.map((version) => {
|
||||
const isCurrent = version.tag.replace(/^v/, '') === currentVersion;
|
||||
const downgrade = isDowngrade(version.tag);
|
||||
return (
|
||||
<SelectItem
|
||||
key={version.tag}
|
||||
textValue={version.tag}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>{version.tag}</span>
|
||||
{version.type === 'prerelease' && (
|
||||
<Chip size='sm' color='secondary' variant='flat'>预发布</Chip>
|
||||
)}
|
||||
{version.type === 'action' && (
|
||||
<Chip size='sm' color='default' variant='flat'>临时</Chip>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<Chip size='sm' color='success' variant='flat'>当前</Chip>
|
||||
)}
|
||||
{downgrade && !isCurrent && version.type !== 'action' && (
|
||||
<Chip size='sm' color='warning' variant='flat'>降级</Chip>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action 版本提示 */}
|
||||
{activeTab === 'action' && (
|
||||
<div className='p-3 rounded-lg bg-default-50 dark:bg-default-100/10 border border-default-200/50'>
|
||||
<p className='text-xs text-default-500'>
|
||||
临时版本来自 GitHub Actions 构建,可能不稳定,适合测试新功能。
|
||||
{selectedVersion?.expiresAt && (
|
||||
<span className='block mt-1 text-warning-600'>
|
||||
此版本将于 {new Date(selectedVersion.expiresAt).toLocaleDateString()} 过期
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 降级警告 */}
|
||||
{selectedVersion && isSelectedDowngrade && (
|
||||
<div className='p-3 rounded-lg bg-warning-50/50 dark:bg-warning-900/20 border border-warning-200/50 dark:border-warning-700/30'>
|
||||
<div className='flex items-start gap-2'>
|
||||
<svg className='w-5 h-5 text-warning-600 dark:text-warning-400 flex-shrink-0 mt-0.5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' />
|
||||
</svg>
|
||||
<div>
|
||||
<p className='text-sm font-medium text-warning-700 dark:text-warning-400'>
|
||||
版本降级警告
|
||||
</p>
|
||||
<p className='text-xs text-warning-600/80 dark:text-warning-500 mt-1'>
|
||||
降级到旧版本可能导致配置不兼容或功能丢失
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
<Switch
|
||||
size='sm'
|
||||
isSelected={forceUpdate}
|
||||
onValueChange={setForceUpdate}
|
||||
/>
|
||||
<span className='text-xs text-warning-700 dark:text-warning-400'>
|
||||
我了解风险,确认降级
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className='flex justify-center'>
|
||||
<Pagination
|
||||
total={pagination.totalPages}
|
||||
page={currentPage}
|
||||
onChange={handlePageChange}
|
||||
size='sm'
|
||||
showControls
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex justify-end gap-2 pt-4 border-t border-default-100 dark:border-default-100/10'>
|
||||
<button
|
||||
className='px-4 py-2 text-sm rounded-lg bg-default-100 hover:bg-default-200 transition-colors'
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
'px-4 py-2 text-sm rounded-lg transition-colors text-white shadow-sm',
|
||||
selectedVersion && (!isSelectedDowngrade || forceUpdate)
|
||||
? 'bg-primary-500 hover:bg-primary-600 shadow-primary-500/20'
|
||||
: 'bg-default-300 cursor-not-allowed'
|
||||
)}
|
||||
disabled={!selectedVersion || (isSelectedDowngrade && !forceUpdate)}
|
||||
onClick={handleUpdate}
|
||||
>
|
||||
{isSelectedDowngrade ? '确认降级更新' : '更新到此版本'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -277,34 +632,68 @@ interface NapCatVersionProps {
|
||||
}
|
||||
|
||||
const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false }) => {
|
||||
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
|
||||
const {
|
||||
data: packageData,
|
||||
loading: packageLoading,
|
||||
error: packageError,
|
||||
} = useRequest(WebUIManager.GetNapCatVersion);
|
||||
} = useRequest(WebUIManager.GetNapCatVersion, {
|
||||
cacheKey: 'napcat-version',
|
||||
staleTime: 60 * 60 * 1000,
|
||||
cacheTime: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const currentVersion = packageData?.version;
|
||||
|
||||
// 点击版本号时显示版本选择对话框
|
||||
const handleVersionClick = useCallback(() => {
|
||||
if (!currentVersion) return;
|
||||
setIsVersionModalOpen(true);
|
||||
}, [currentVersion]);
|
||||
|
||||
return (
|
||||
<SystemInfoItem
|
||||
title='NapCat 版本'
|
||||
icon={<IoLogoOctocat className='text-xl' />}
|
||||
hasBackground={hasBackground}
|
||||
value={
|
||||
packageError
|
||||
? (
|
||||
`错误:${packageError.message}`
|
||||
)
|
||||
: packageLoading
|
||||
<>
|
||||
<SystemInfoItem
|
||||
title='NapCat 版本'
|
||||
icon={<IoLogoOctocat className='text-xl' />}
|
||||
hasBackground={hasBackground}
|
||||
value={
|
||||
packageError
|
||||
? (
|
||||
<Spinner size='sm' />
|
||||
`错误:${packageError.message}`
|
||||
)
|
||||
: (
|
||||
currentVersion
|
||||
)
|
||||
}
|
||||
endContent={<NewVersionTip currentVersion={currentVersion} />}
|
||||
/>
|
||||
: packageLoading
|
||||
? (
|
||||
<Spinner size='sm' />
|
||||
)
|
||||
: (
|
||||
<Tooltip content='点击管理版本'>
|
||||
<span
|
||||
className='cursor-pointer hover:text-primary-500 transition-colors underline decoration-dashed underline-offset-2'
|
||||
onClick={handleVersionClick}
|
||||
>
|
||||
{currentVersion}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
endContent={<NewVersionTip currentVersion={currentVersion} />}
|
||||
/>
|
||||
{isVersionModalOpen && (
|
||||
<Modal
|
||||
title='版本管理'
|
||||
size='lg'
|
||||
hideFooter={true}
|
||||
onClose={() => setIsVersionModalOpen(false)}
|
||||
content={
|
||||
<VersionSelectDialogContent
|
||||
currentVersion={currentVersion || ''}
|
||||
onClose={() => setIsVersionModalOpen(false)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -317,7 +706,11 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
||||
data: qqVersionData,
|
||||
loading: qqVersionLoading,
|
||||
error: qqVersionError,
|
||||
} = useRequest(WebUIManager.getQQVersion);
|
||||
} = useRequest(WebUIManager.getQQVersion, {
|
||||
cacheKey: 'qq-version',
|
||||
staleTime: 60 * 60 * 1000,
|
||||
cacheTime: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
|
||||
@@ -28,20 +28,17 @@ const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'p-2 rounded-lg text-sm border border-white/20 transition-colors',
|
||||
'py-1.5 text-sm transition-colors',
|
||||
size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between',
|
||||
hasBackground
|
||||
? 'bg-white/10 hover:bg-white/20'
|
||||
: 'bg-white/50 dark:bg-white/5 hover:bg-white/70 dark:hover:bg-white/10'
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
'w-24 font-medium',
|
||||
hasBackground ? 'text-white/90' : 'text-default-600'
|
||||
hasBackground ? 'text-white/90' : 'text-default-600 dark:text-gray-300'
|
||||
)}>{title}</div>
|
||||
<div className={clsx(
|
||||
'font-mono text-xs',
|
||||
hasBackground ? 'text-white/70' : 'text-default-500'
|
||||
hasBackground ? 'text-white/80' : 'text-default-500'
|
||||
)}>
|
||||
{value}
|
||||
{unit && <span className="ml-0.5 opacity-70">{unit}</span>}
|
||||
@@ -145,11 +142,13 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
|
||||
systemUsage={Number(data?.cpu.usage.system) || 0}
|
||||
processUsage={Number(data?.cpu.usage.qq) || 0}
|
||||
title='CPU占用'
|
||||
hasBackground={hasBackground}
|
||||
/>
|
||||
<UsagePie
|
||||
systemUsage={memoryUsage.system}
|
||||
processUsage={memoryUsage.qq}
|
||||
title='内存占用'
|
||||
hasBackground={hasBackground}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
|
||||
@@ -1,143 +1,153 @@
|
||||
import * as echarts from 'echarts';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
interface UsagePieProps {
|
||||
systemUsage: number
|
||||
processUsage: number
|
||||
title?: string
|
||||
systemUsage: number;
|
||||
processUsage: number;
|
||||
title?: string;
|
||||
hasBackground?: boolean;
|
||||
}
|
||||
|
||||
const defaultOption: echarts.EChartsOption = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '<center>{b}<br/><b>{d}%</b></center>',
|
||||
borderRadius: 10,
|
||||
extraCssText: 'backdrop-filter: blur(10px);',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '系统占用',
|
||||
type: 'pie',
|
||||
radius: ['70%', '90%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'center',
|
||||
formatter: '系统占用',
|
||||
fontSize: 14,
|
||||
},
|
||||
itemStyle: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
},
|
||||
labelLine: {
|
||||
show: false,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: 100,
|
||||
name: '系统总量',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const UsagePie: React.FC<UsagePieProps> = ({
|
||||
systemUsage,
|
||||
processUsage,
|
||||
title,
|
||||
hasBackground,
|
||||
}) => {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstance = useRef<echarts.ECharts | null>(null);
|
||||
const { theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (chartRef.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
const option = defaultOption;
|
||||
chartInstance.current.setOption(option);
|
||||
const observer = new ResizeObserver(() => {
|
||||
chartInstance.current?.resize();
|
||||
});
|
||||
observer.observe(chartRef.current);
|
||||
return () => {
|
||||
chartInstance.current?.dispose();
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
// Ensure values are clean and consistent
|
||||
// Process usage cannot exceed system usage, and system usage cannot be less than process usage.
|
||||
const rawSystem = Math.max(systemUsage || 0, 0);
|
||||
const rawProcess = Math.max(processUsage || 0, 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.setOption({
|
||||
series: [
|
||||
{
|
||||
label: {
|
||||
formatter: title,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}, [title]);
|
||||
const cleanSystem = Math.min(Math.max(rawSystem, rawProcess), 100);
|
||||
const cleanProcess = Math.min(rawProcess, cleanSystem);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.setOption({
|
||||
darkMode: theme === 'dark',
|
||||
tooltip: {
|
||||
backgroundColor:
|
||||
theme === 'dark'
|
||||
? 'rgba(0, 0, 0, 0.8)'
|
||||
: 'rgba(255, 255, 255, 0.8)',
|
||||
textStyle: {
|
||||
color: theme === 'dark' ? '#fff' : '#333',
|
||||
},
|
||||
},
|
||||
color:
|
||||
theme === 'dark'
|
||||
? ['#D33FF0', '#EF8664', '#E25180']
|
||||
: ['#D33FF0', '#EA7D9B', '#FFC107'],
|
||||
series: [
|
||||
{
|
||||
itemStyle: {
|
||||
borderColor: theme === 'dark' ? '#333' : '#F0A9A7',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}, [theme]);
|
||||
// SVG Config
|
||||
const size = 100;
|
||||
const strokeWidth = 10;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const center = size / 2;
|
||||
|
||||
useEffect(() => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.setOption({
|
||||
series: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
value: processUsage,
|
||||
name: 'QQ占用',
|
||||
},
|
||||
{
|
||||
value: systemUsage - processUsage,
|
||||
name: '其他进程占用',
|
||||
},
|
||||
{
|
||||
value: 100 - systemUsage,
|
||||
name: '剩余系统总量',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}, [systemUsage, processUsage]);
|
||||
// Colors
|
||||
const colors = {
|
||||
qq: '#D33FF0',
|
||||
other: theme === 'dark' ? '#EF8664' : '#EA7D9B',
|
||||
track: theme === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)',
|
||||
};
|
||||
|
||||
return <div ref={chartRef} className='w-36 h-36 flex-shrink-0' />;
|
||||
// Dash Arrays
|
||||
// 1. Total System Usage (QQ + Others)
|
||||
const systemDash = useMemo(() => {
|
||||
return `${(cleanSystem / 100) * circumference} ${circumference}`;
|
||||
}, [cleanSystem, circumference]);
|
||||
|
||||
// 2. QQ Usage (Subset of System)
|
||||
const processDash = useMemo(() => {
|
||||
return `${(cleanProcess / 100) * circumference} ${circumference}`;
|
||||
}, [cleanProcess, circumference]);
|
||||
|
||||
// 计算其他进程占用(系统总占用 - QQ占用)
|
||||
const otherUsage = Math.max(cleanSystem - cleanProcess, 0);
|
||||
|
||||
// Tooltip 内容
|
||||
const tooltipContent = (
|
||||
<div className='flex flex-col gap-1 p-1 text-xs'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='w-2 h-2 rounded-full' style={{ backgroundColor: colors.qq }} />
|
||||
<span>QQ进程: {cleanProcess.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='w-2 h-2 rounded-full' style={{ backgroundColor: colors.other }} />
|
||||
<span>其他进程: {otherUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='w-2 h-2 rounded-full' style={{ backgroundColor: colors.track }} />
|
||||
<span>空闲: {(100 - cleanSystem).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipContent} placement='top'>
|
||||
<div className='relative w-36 h-36 flex items-center justify-center cursor-pointer'>
|
||||
<svg
|
||||
className='w-full h-full -rotate-90'
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
>
|
||||
{/* Track / Free Space */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill='none'
|
||||
stroke={colors.track}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
|
||||
{/* System Usage (Background for QQ) - effectively "Others" + "QQ" */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill='none'
|
||||
stroke={colors.other}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap='round'
|
||||
strokeDasharray={systemDash}
|
||||
className='transition-all duration-700 ease-out'
|
||||
/>
|
||||
|
||||
{/* QQ Usage - Layered on top */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill='none'
|
||||
stroke={colors.qq}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap='round'
|
||||
strokeDasharray={processDash}
|
||||
className='transition-all duration-700 ease-out'
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Center Content */}
|
||||
<div className='absolute inset-0 flex flex-col items-center justify-center pointer-events-none select-none'>
|
||||
{title && (
|
||||
<span className={clsx(
|
||||
'text-[10px] font-medium mb-0.5 opacity-80 uppercase tracking-widest scale-90',
|
||||
hasBackground ? 'text-white/80' : 'text-default-500 dark:text-default-400'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
<div className='flex items-baseline gap-0.5'>
|
||||
<span className={clsx(
|
||||
'text-2xl font-bold font-mono tracking-tight',
|
||||
hasBackground ? 'text-white' : 'text-default-900 dark:text-white'
|
||||
)}
|
||||
>
|
||||
{Math.round(cleanSystem)}
|
||||
</span>
|
||||
<span className={clsx(
|
||||
'text-xs font-bold',
|
||||
hasBackground ? 'text-white/60' : 'text-default-400 dark:text-default-500'
|
||||
)}
|
||||
>%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsagePie;
|
||||
|
||||
@@ -35,13 +35,27 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
const { className, onInput, onKey, onResize, ...rest } = props;
|
||||
const { theme } = useTheme();
|
||||
useEffect(() => {
|
||||
// 根据屏幕宽度决定字体大小,手机端使用更小的字体
|
||||
const width = window.innerWidth;
|
||||
// 按屏幕宽度自适应字体大小
|
||||
let fontSize = 16;
|
||||
if (width < 400) {
|
||||
fontSize = 4;
|
||||
} else if (width < 600) {
|
||||
fontSize = 5;
|
||||
} else if (width < 900) {
|
||||
fontSize = 6;
|
||||
} else if (width < 1280) {
|
||||
fontSize = 12;
|
||||
} // ≥1280: 16
|
||||
|
||||
const terminal = new Terminal({
|
||||
allowTransparency: true,
|
||||
fontFamily:
|
||||
'"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace',
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", "JetBrains Mono", monospace',
|
||||
cursorInactiveStyle: 'outline',
|
||||
drawBoldTextInBrightColors: false,
|
||||
fontSize: 14,
|
||||
fontSize: fontSize,
|
||||
lineHeight: 1.2,
|
||||
});
|
||||
terminalRef.current = terminal;
|
||||
@@ -56,6 +70,7 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(domRef.current!);
|
||||
|
||||
// 所有端都使用 Canvas 渲染器(包括手机端)
|
||||
terminal.loadAddon(new CanvasAddon());
|
||||
terminal.onData((data) => {
|
||||
if (onInput) {
|
||||
|
||||
@@ -54,11 +54,68 @@ export default class WebUIManager {
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本信息接口
|
||||
*/
|
||||
static readonly VersionTypes = {
|
||||
RELEASE: 'release',
|
||||
PRERELEASE: 'prerelease',
|
||||
ACTION: 'action',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 获取所有可用的版本列表(支持分页、过滤和搜索)
|
||||
*/
|
||||
public static async getAllReleases (options: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
includeActions?: boolean;
|
||||
type?: 'release' | 'action' | 'all';
|
||||
search?: string;
|
||||
} = {}) {
|
||||
const { page = 1, pageSize = 20, includeActions = true, type = 'all', search = '' } = options;
|
||||
const { data } = await serverRequest.get<ServerResponse<{
|
||||
versions: Array<{
|
||||
tag: string;
|
||||
type: 'release' | 'prerelease' | 'action';
|
||||
artifactId?: number;
|
||||
artifactName?: string;
|
||||
createdAt?: string;
|
||||
expiresAt?: string;
|
||||
size?: number;
|
||||
}>;
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
mirror?: string;
|
||||
}>>('/base/getAllReleases', {
|
||||
params: { page, pageSize, includeActions, type, search },
|
||||
});
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async UpdateNapCat () {
|
||||
const { data } = await serverRequest.post<ServerResponse<any>>(
|
||||
'/UpdateNapCat/update',
|
||||
{},
|
||||
{ timeout: 60000 } // 1分钟超时
|
||||
{ timeout: 120000 } // 2分钟超时
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新到指定版本
|
||||
* @param targetVersion 目标版本 tag,如 "v4.9.9" 或 "action-123456"
|
||||
* @param force 是否强制更新(允许降级)
|
||||
*/
|
||||
public static async UpdateNapCatToVersion (targetVersion: string, force: boolean = false) {
|
||||
const { data } = await serverRequest.post<ServerResponse<any>>(
|
||||
'/UpdateNapCat/update',
|
||||
{ targetVersion, force },
|
||||
{ timeout: 120000 } // 2分钟超时
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import '@/styles/globals.css';
|
||||
|
||||
import key from './const/key';
|
||||
import WebUIManager from './controllers/webui_manager';
|
||||
import { loadTheme } from './utils/theme';
|
||||
import { initFont, loadTheme } from './utils/theme';
|
||||
|
||||
WebUIManager.checkWebUiLogined();
|
||||
|
||||
@@ -24,6 +24,7 @@ if (theme && !theme.startsWith('"')) {
|
||||
}
|
||||
|
||||
loadTheme();
|
||||
initFont();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
// <React.StrictMode>
|
||||
@@ -34,3 +35,19 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
</BrowserRouter>
|
||||
// </React.StrictMode>
|
||||
);
|
||||
|
||||
if (!import.meta.env.DEV) {
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
const baseUrl = import.meta.env.BASE_URL;
|
||||
const swUrl = `${baseUrl}sw.js`;
|
||||
navigator.serviceWorker.register(swUrl, { scope: baseUrl })
|
||||
.then((registration) => {
|
||||
console.log('SW registered: ', registration);
|
||||
})
|
||||
.catch((registrationError) => {
|
||||
console.log('SW registration failed: ', registrationError);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import * as monaco from 'monaco-editor';
|
||||
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
|
||||
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
|
||||
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
||||
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker (_: unknown, label: string) {
|
||||
if (label === 'json') {
|
||||
// eslint-disable-next-line new-cap
|
||||
return new jsonWorker();
|
||||
}
|
||||
if (label === 'css' || label === 'scss' || label === 'less') {
|
||||
// eslint-disable-next-line new-cap
|
||||
return new cssWorker();
|
||||
}
|
||||
if (label === 'html' || label === 'handlebars' || label === 'razor') {
|
||||
// eslint-disable-next-line new-cap
|
||||
return new htmlWorker();
|
||||
}
|
||||
if (label === 'typescript' || label === 'javascript') {
|
||||
// eslint-disable-next-line new-cap
|
||||
return new tsWorker();
|
||||
}
|
||||
// eslint-disable-next-line new-cap
|
||||
return new editorWorker();
|
||||
},
|
||||
};
|
||||
|
||||
monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true);
|
||||
|
||||
export default monaco;
|
||||
@@ -1,28 +1,33 @@
|
||||
import { Accordion, AccordionItem } from '@heroui/accordion';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { useRequest } from 'ahooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||
import { Controller, useForm, useWatch } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
import { FaUserAstronaut } from 'react-icons/fa';
|
||||
import { FaFont, FaUserAstronaut, FaCheck } from 'react-icons/fa';
|
||||
import { FaPaintbrush } from 'react-icons/fa6';
|
||||
import { IoIosColorPalette } from 'react-icons/io';
|
||||
import { IoIosColorPalette, IoMdRefresh } from 'react-icons/io';
|
||||
import { MdDarkMode, MdLightMode } from 'react-icons/md';
|
||||
|
||||
import themes from '@/const/themes';
|
||||
|
||||
import ColorPicker from '@/components/ColorPicker';
|
||||
import SaveButtons from '@/components/button/save_buttons';
|
||||
import FileInput from '@/components/input/file_input';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
|
||||
import { colorKeys, generateTheme, loadTheme } from '@/utils/theme';
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
import { applyFont, colorKeys, generateTheme, loadTheme, updateFontCache } from '@/utils/theme';
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
|
||||
export type PreviewThemeCardProps = {
|
||||
theme: ThemeInfo;
|
||||
onPreview: () => void;
|
||||
isSelected?: boolean;
|
||||
};
|
||||
|
||||
const values = [
|
||||
@@ -47,7 +52,7 @@ const colors = [
|
||||
'default',
|
||||
];
|
||||
|
||||
function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
|
||||
function PreviewThemeCard ({ theme, onPreview, isSelected }: PreviewThemeCardProps) {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = generateTheme(theme.theme, theme.name);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
@@ -64,8 +69,19 @@ function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
|
||||
radius='sm'
|
||||
isPressable
|
||||
onPress={onPreview}
|
||||
className={clsx('text-primary bg-primary-50', theme.name)}
|
||||
className={clsx(
|
||||
'text-primary bg-primary-50 relative transition-all',
|
||||
theme.name,
|
||||
isSelected && 'ring-2 ring-primary ring-offset-2'
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className='absolute top-1 right-1 z-10'>
|
||||
<Chip size='sm' color='primary' variant='solid'>
|
||||
<FaCheck size={10} />
|
||||
</Chip>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader className='pb-0 flex flex-col items-start gap-1'>
|
||||
<div className='px-1 rounded-md bg-primary text-primary-foreground'>
|
||||
{theme.name}
|
||||
@@ -74,20 +90,20 @@ function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
|
||||
<FaUserAstronaut />
|
||||
{theme.author ?? '未知'}
|
||||
</div>
|
||||
<div className='text-xs text-primary-200'>{theme.description}</div>
|
||||
<div className='text-xs text-primary-200 whitespace-nowrap overflow-hidden text-ellipsis w-full'>{theme.description}</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className='flex flex-col gap-1'>
|
||||
{colors.map((color) => (
|
||||
<div className='flex gap-1 items-center flex-wrap' key={color}>
|
||||
<div className='text-xs w-4 text-right'>
|
||||
<div className='flex gap-1 items-center flex-nowrap' key={color}>
|
||||
<div className='text-xs w-4 text-right flex-shrink-0'>
|
||||
{color[0].toUpperCase()}
|
||||
</div>
|
||||
{values.map((value) => (
|
||||
<div
|
||||
key={value}
|
||||
className={clsx(
|
||||
'w-2 h-2 rounded-full shadow-small',
|
||||
'w-2 h-2 rounded-full shadow-small flex-shrink-0',
|
||||
`bg-${color}${value}`
|
||||
)}
|
||||
/>
|
||||
@@ -100,6 +116,29 @@ function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// 比较两个主题配置是否相同(不比较 fontMode)
|
||||
const isThemeColorsEqual = (a: ThemeConfig, b: ThemeConfig): boolean => {
|
||||
if (!a || !b) return false;
|
||||
const aKeys = [...Object.keys(a.light || {}), ...Object.keys(a.dark || {})];
|
||||
const bKeys = [...Object.keys(b.light || {}), ...Object.keys(b.dark || {})];
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
|
||||
for (const key of Object.keys(a.light || {})) {
|
||||
if (a.light?.[key as keyof ThemeConfigItem] !== b.light?.[key as keyof ThemeConfigItem]) return false;
|
||||
}
|
||||
for (const key of Object.keys(a.dark || {})) {
|
||||
if (a.dark?.[key as keyof ThemeConfigItem] !== b.dark?.[key as keyof ThemeConfigItem]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 字体模式显示名称映射
|
||||
const fontModeNames: Record<string, string> = {
|
||||
aacute: 'Aa 偷吃可爱长大的',
|
||||
system: '系统默认',
|
||||
custom: '自定义字体',
|
||||
};
|
||||
|
||||
const ThemeConfigCard = () => {
|
||||
const { data, loading, error, refreshAsync } = useRequest(
|
||||
WebUIManager.getThemeConfig
|
||||
@@ -116,19 +155,29 @@ const ThemeConfigCard = () => {
|
||||
theme: {
|
||||
dark: {},
|
||||
light: {},
|
||||
fontMode: 'aacute',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [dataLoaded, setDataLoaded] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
// 使用 useRef 存储 style 标签引用
|
||||
const styleTagRef = useRef<HTMLStyleElement | null>(null);
|
||||
const originalDataRef = useRef<ThemeConfig | null>(null);
|
||||
|
||||
// 在组件挂载时创建 style 标签,并在卸载时清理
|
||||
// 同时在卸载时恢复字体到已保存的状态(避免"伪自动保存"问题)
|
||||
useEffect(() => {
|
||||
const styleTag = document.createElement('style');
|
||||
document.head.appendChild(styleTag);
|
||||
styleTagRef.current = styleTag;
|
||||
return () => {
|
||||
// 组件卸载时,恢复到已保存的字体设置
|
||||
if (originalDataRef.current?.fontMode) {
|
||||
applyFont(originalDataRef.current.fontMode);
|
||||
}
|
||||
if (styleTagRef.current) {
|
||||
document.head.removeChild(styleTagRef.current);
|
||||
}
|
||||
@@ -137,13 +186,45 @@ const ThemeConfigCard = () => {
|
||||
|
||||
const theme = useWatch({ control, name: 'theme' });
|
||||
|
||||
const reset = () => {
|
||||
if (data) setOnebotValue('theme', data);
|
||||
};
|
||||
// 检测是否有未保存的更改
|
||||
useEffect(() => {
|
||||
if (originalDataRef.current && dataLoaded) {
|
||||
const colorsChanged = !isThemeColorsEqual(theme, originalDataRef.current);
|
||||
const fontChanged = theme.fontMode !== originalDataRef.current.fontMode;
|
||||
setHasUnsavedChanges(colorsChanged || fontChanged);
|
||||
}
|
||||
}, [theme, dataLoaded]);
|
||||
|
||||
const onSubmit = handleOnebotSubmit(async (data) => {
|
||||
const reset = useCallback(() => {
|
||||
if (data) {
|
||||
setOnebotValue('theme', data);
|
||||
originalDataRef.current = data;
|
||||
// 应用已保存的字体设置
|
||||
if (data.fontMode) {
|
||||
applyFont(data.fontMode);
|
||||
}
|
||||
}
|
||||
setDataLoaded(true);
|
||||
setHasUnsavedChanges(false);
|
||||
}, [data, setOnebotValue]);
|
||||
|
||||
// 实时应用字体预设(预览)
|
||||
useEffect(() => {
|
||||
if (dataLoaded && theme.fontMode) {
|
||||
applyFont(theme.fontMode);
|
||||
}
|
||||
}, [theme.fontMode, dataLoaded]);
|
||||
|
||||
const onSubmit = handleOnebotSubmit(async (formData) => {
|
||||
try {
|
||||
await WebUIManager.setThemeConfig(data.theme);
|
||||
await WebUIManager.setThemeConfig(formData.theme);
|
||||
// 更新原始数据引用
|
||||
originalDataRef.current = formData.theme;
|
||||
// 更新字体缓存
|
||||
if (formData.theme.fontMode) {
|
||||
updateFontCache(formData.theme.fontMode);
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
toast.success('保存成功');
|
||||
loadTheme();
|
||||
} catch (error) {
|
||||
@@ -164,7 +245,7 @@ const ThemeConfigCard = () => {
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [data]);
|
||||
}, [data, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme && styleTagRef.current) {
|
||||
@@ -173,6 +254,23 @@ const ThemeConfigCard = () => {
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// 找到当前选中的主题(预览中的)
|
||||
const selectedThemeName = useMemo(() => {
|
||||
return themes.find(t => isThemeColorsEqual(t.theme, theme))?.name;
|
||||
}, [theme]);
|
||||
|
||||
// 找到已保存的主题名称
|
||||
const savedThemeName = useMemo(() => {
|
||||
if (!originalDataRef.current) return null;
|
||||
return themes.find(t => isThemeColorsEqual(t.theme, originalDataRef.current!))?.name || '自定义';
|
||||
}, [dataLoaded, hasUnsavedChanges]);
|
||||
|
||||
// 已保存的字体模式显示名称
|
||||
const savedFontModeDisplayName = useMemo(() => {
|
||||
const mode = originalDataRef.current?.fontMode || 'aacute';
|
||||
return fontModeNames[mode] || mode;
|
||||
}, [dataLoaded, hasUnsavedChanges]);
|
||||
|
||||
if (loading) return <PageLoading loading />;
|
||||
|
||||
if (error) {
|
||||
@@ -185,96 +283,209 @@ const ThemeConfigCard = () => {
|
||||
<>
|
||||
<title>主题配置 - NapCat WebUI</title>
|
||||
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
refresh={onRefresh}
|
||||
className='items-end w-full p-4'
|
||||
/>
|
||||
<div className='px-4 text-sm text-default-600'>实时预览,记得保存!</div>
|
||||
<Accordion variant='splitted' defaultExpandedKeys={['select']}>
|
||||
<AccordionItem
|
||||
key='select'
|
||||
aria-label='Pick Color'
|
||||
title='选择主题'
|
||||
subtitle='可以切换夜间/白昼模式查看对应颜色'
|
||||
className='shadow-small'
|
||||
startContent={<IoIosColorPalette />}
|
||||
>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{themes.map((theme) => (
|
||||
<PreviewThemeCard
|
||||
key={theme.name}
|
||||
theme={theme}
|
||||
onPreview={() => {
|
||||
setOnebotValue('theme', theme.theme);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* 顶部操作栏 */}
|
||||
<div className='sticky top-0 z-20 bg-background/80 backdrop-blur-md border-b border-divider'>
|
||||
<div className='flex items-center justify-between p-4'>
|
||||
<div className='flex items-center gap-3 flex-wrap'>
|
||||
<div className='flex items-center gap-2 text-sm'>
|
||||
<span className='text-default-400'>当前主题:</span>
|
||||
<Chip size='sm' color='primary' variant='flat'>
|
||||
{savedThemeName || '加载中...'}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-sm'>
|
||||
<span className='text-default-400'>字体:</span>
|
||||
<Chip size='sm' color='secondary' variant='flat'>
|
||||
{savedFontModeDisplayName}
|
||||
</Chip>
|
||||
</div>
|
||||
{hasUnsavedChanges && (
|
||||
<Chip size='sm' color='warning' variant='solid'>
|
||||
有未保存的更改
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
radius='full'
|
||||
variant='flat'
|
||||
className='font-medium bg-default-100 text-default-600 dark:bg-default-50/50'
|
||||
onPress={() => {
|
||||
reset();
|
||||
toast.success('已重置');
|
||||
}}
|
||||
isDisabled={!hasUnsavedChanges}
|
||||
>
|
||||
取消更改
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
color='primary'
|
||||
radius='full'
|
||||
className='font-medium shadow-md shadow-primary/20'
|
||||
isLoading={isSubmitting}
|
||||
onPress={() => onSubmit()}
|
||||
isDisabled={!hasUnsavedChanges}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
variant='flat'
|
||||
className='text-default-500 bg-default-100 dark:bg-default-50/50'
|
||||
onPress={onRefresh}
|
||||
>
|
||||
<IoMdRefresh size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccordionItem
|
||||
key='pick'
|
||||
aria-label='Pick Color'
|
||||
title='自定义配色'
|
||||
className='shadow-small'
|
||||
startContent={<FaPaintbrush />}
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
{(['dark', 'light'] as const).map((mode) => (
|
||||
<div
|
||||
key={mode}
|
||||
className={clsx(
|
||||
'p-2 rounded-md',
|
||||
mode === 'dark' ? 'text-white' : 'text-black',
|
||||
mode === 'dark'
|
||||
? 'bg-content1-foreground dark:bg-content1'
|
||||
: 'bg-content1 dark:bg-content1-foreground'
|
||||
)}
|
||||
>
|
||||
<h3 className='text-center p-2 rounded-md bg-content2 mb-2 text-default-800 flex items-center justify-center'>
|
||||
{mode === 'dark'
|
||||
? (
|
||||
<MdDarkMode size={24} />
|
||||
)
|
||||
: (
|
||||
<MdLightMode size={24} />
|
||||
)}
|
||||
{mode === 'dark' ? '夜间模式主题' : '白昼模式主题'}
|
||||
</h3>
|
||||
{colorKeys.map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className='grid grid-cols-2 items-center mb-2 gap-2'
|
||||
<div className='p-4'>
|
||||
<Accordion variant='splitted' defaultExpandedKeys={['font', 'select']}>
|
||||
<AccordionItem
|
||||
key='font'
|
||||
aria-label='Font Settings'
|
||||
title='字体设置'
|
||||
subtitle='自定义WebUI显示的字体'
|
||||
className='shadow-small'
|
||||
startContent={<FaFont />}
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<Controller
|
||||
control={control}
|
||||
name='theme.fontMode'
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
label='字体预设'
|
||||
selectedKeys={field.value ? [field.value] : ['aacute']}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
className='max-w-xs'
|
||||
disallowEmptySelection
|
||||
>
|
||||
<label className='text-right'>{key}</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`theme.${mode}.${key}`}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const hslArray = value?.split(' ') ?? [0, 0, 0];
|
||||
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
|
||||
return (
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(result) => {
|
||||
onChange(
|
||||
`${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<SelectItem key='aacute'>Aa 偷吃可爱长大的</SelectItem>
|
||||
<SelectItem key='system'>系统默认</SelectItem>
|
||||
<SelectItem key='custom'>自定义字体</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<div className='p-3 rounded-lg bg-default-100 dark:bg-default-50/30'>
|
||||
<div className='text-sm text-default-500 mb-2'>
|
||||
上传自定义字体(仅在选择"自定义字体"时生效)
|
||||
</div>
|
||||
<FileInput
|
||||
label='上传字体文件'
|
||||
placeholder='选择字体文件 (.woff/.woff2/.ttf/.otf)'
|
||||
accept='.ttf,.otf,.woff,.woff2'
|
||||
onChange={async (file) => {
|
||||
try {
|
||||
await FileManager.uploadWebUIFont(file);
|
||||
toast.success('上传成功,即将刷新页面');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
toast.error('上传失败: ' + (error as Error).message);
|
||||
}
|
||||
}}
|
||||
onDelete={async () => {
|
||||
try {
|
||||
await FileManager.deleteWebUIFont();
|
||||
toast.success('删除成功,即将刷新页面');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
toast.error('删除失败: ' + (error as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
key='select'
|
||||
aria-label='Pick Color'
|
||||
title='选择主题'
|
||||
subtitle='点击主题卡片即可预览,记得保存'
|
||||
className='shadow-small'
|
||||
startContent={<IoIosColorPalette />}
|
||||
>
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3'>
|
||||
{themes.map((t) => (
|
||||
<PreviewThemeCard
|
||||
key={t.name}
|
||||
theme={t}
|
||||
isSelected={selectedThemeName === t.name}
|
||||
onPreview={() => {
|
||||
setOnebotValue('theme', { ...t.theme, fontMode: theme.fontMode });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
key='pick'
|
||||
aria-label='Pick Color'
|
||||
title='自定义配色'
|
||||
subtitle='精细调整每个颜色变量'
|
||||
className='shadow-small'
|
||||
startContent={<FaPaintbrush />}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
{(['light', 'dark'] as const).map((mode) => (
|
||||
<div
|
||||
key={mode}
|
||||
className={clsx(
|
||||
'p-4 rounded-lg',
|
||||
mode === 'dark' ? 'bg-zinc-900 text-white' : 'bg-zinc-100 text-black'
|
||||
)}
|
||||
>
|
||||
<h3 className='flex items-center justify-center gap-2 p-2 rounded-md bg-opacity-20 mb-4 font-medium'>
|
||||
{mode === 'dark' ? <MdDarkMode size={20} /> : <MdLightMode size={20} />}
|
||||
{mode === 'dark' ? '深色模式' : '浅色模式'}
|
||||
</h3>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
|
||||
{colorKeys.map((colorKey) => (
|
||||
<div
|
||||
key={colorKey}
|
||||
className='flex items-center gap-2 p-2 rounded bg-black/5 dark:bg-white/5'
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`theme.${mode}.${colorKey}`}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const hslArray = value?.split(' ') ?? [0, 0, 0];
|
||||
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
|
||||
return (
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(result) => {
|
||||
onChange(
|
||||
`${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className='text-xs font-mono truncate flex-1' title={colorKey}>
|
||||
{colorKey.replace('--heroui-', '')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,11 +7,9 @@ import toast from 'react-hot-toast';
|
||||
import key from '@/const/key';
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons';
|
||||
import FileInput from '@/components/input/file_input';
|
||||
import ImageInput from '@/components/input/image_input';
|
||||
|
||||
import { siteConfig } from '@/config/site';
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
|
||||
// Base64URL to Uint8Array converter
|
||||
@@ -37,10 +35,10 @@ const WebUIConfigCard = () => {
|
||||
handleSubmit: handleWebuiSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue: setWebuiValue,
|
||||
} = useForm<IConfig['webui']>({
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
background: '',
|
||||
customIcons: {},
|
||||
customIcons: {} as Record<string, string>,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -92,39 +90,6 @@ const WebUIConfigCard = () => {
|
||||
return (
|
||||
<>
|
||||
<title>WebUI配置 - NapCat WebUI</title>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>WebUI字体</div>
|
||||
<div className='text-sm text-default-400'>
|
||||
此项不需要手动保存,上传成功后需清空网页缓存并刷新
|
||||
<FileInput
|
||||
label='中文字体'
|
||||
placeholder='选择字体文件'
|
||||
accept='.ttf,.otf,.woff,.woff2'
|
||||
onChange={async (file) => {
|
||||
try {
|
||||
await FileManager.uploadWebUIFont(file);
|
||||
toast.success('上传成功');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
toast.error('上传失败: ' + (error as Error).message);
|
||||
}
|
||||
}}
|
||||
onDelete={async () => {
|
||||
try {
|
||||
await FileManager.deleteWebUIFont();
|
||||
toast.success('删除成功');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
toast.error('删除失败: ' + (error as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>背景图</div>
|
||||
<Controller
|
||||
|
||||
@@ -1,63 +1,197 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
import { TbSquareRoundedChevronLeftFilled } from 'react-icons/tb';
|
||||
|
||||
import key from '@/const/key';
|
||||
import oneBotHttpApi from '@/const/ob_api';
|
||||
import type { OneBotHttpApi } from '@/const/ob_api';
|
||||
import type { OneBotHttpApiPath } from '@/const/ob_api';
|
||||
|
||||
import OneBotApiDebug from '@/components/onebot/api/debug';
|
||||
import OneBotApiNavList from '@/components/onebot/api/nav_list';
|
||||
|
||||
export default function HttpDebug () {
|
||||
const [selectedApi, setSelectedApi] =
|
||||
useState<keyof OneBotHttpApi>('/set_qq_profile');
|
||||
const data = oneBotHttpApi[selectedApi];
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [activeApi, setActiveApi] = useState<OneBotHttpApiPath | null>('/set_qq_profile');
|
||||
const [openApis, setOpenApis] = useState<OneBotHttpApiPath[]>(['/set_qq_profile']);
|
||||
const [openSideBar, setOpenSideBar] = useState(true);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const [adapterName, setAdapterName] = useState<string>('');
|
||||
|
||||
// Auto-collapse sidebar on mobile initial load
|
||||
useEffect(() => {
|
||||
contentRef?.current?.scrollTo?.({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [selectedApi]);
|
||||
if (window.innerWidth < 768) {
|
||||
setOpenSideBar(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize Debug Adapter
|
||||
useEffect(() => {
|
||||
let currentAdapterName = '';
|
||||
|
||||
const initAdapter = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/Debug/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.code === 0) {
|
||||
currentAdapterName = data.data.adapterName;
|
||||
setAdapterName(currentAdapterName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create debug adapter:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initAdapter();
|
||||
|
||||
return () => {
|
||||
// 不再主动关闭 adapter,由后端自动管理活跃状态
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
const handleSelectApi = (api: OneBotHttpApiPath) => {
|
||||
if (!openApis.includes(api)) {
|
||||
setOpenApis([...openApis, api]);
|
||||
}
|
||||
setActiveApi(api);
|
||||
if (window.innerWidth < 768) {
|
||||
setOpenSideBar(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseTab = (e: React.MouseEvent, apiToRemove: OneBotHttpApiPath) => {
|
||||
e.stopPropagation();
|
||||
const newOpenApis = openApis.filter((api) => api !== apiToRemove);
|
||||
setOpenApis(newOpenApis);
|
||||
|
||||
if (activeApi === apiToRemove) {
|
||||
if (newOpenApis.length > 0) {
|
||||
// Switch to the last opened tab or the previous one?
|
||||
// Usually the one to the right or left. Let's pick the last one for simplicity or neighbor.
|
||||
// Finding index of removed api to pick neighbor is better UX, but last one is acceptable.
|
||||
setActiveApi(newOpenApis[newOpenApis.length - 1]);
|
||||
} else {
|
||||
setActiveApi(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>HTTP调试 - NapCat WebUI</title>
|
||||
<div className='flex h-[calc(100vh-3.5rem)] overflow-hidden relative p-2 md:p-4 gap-2 md:gap-4'>
|
||||
<OneBotApiNavList
|
||||
data={oneBotHttpApi}
|
||||
selectedApi={selectedApi}
|
||||
onSelect={(api) => {
|
||||
setSelectedApi(api);
|
||||
// Auto-close sidebar on mobile after selection
|
||||
if (window.innerWidth < 768) {
|
||||
setOpenSideBar(false);
|
||||
}
|
||||
}}
|
||||
openSideBar={openSideBar}
|
||||
onToggle={setOpenSideBar}
|
||||
/>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className='flex-1 h-full overflow-hidden flex flex-col relative'
|
||||
>
|
||||
{/* Toggle Button Container - positioned on top-left of content if sidebar is closed */}
|
||||
<div className='absolute top-2 left-2 z-30'>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="flat"
|
||||
className={clsx("bg-white/40 dark:bg-black/40 backdrop-blur-md transition-opacity rounded-full shadow-sm", openSideBar ? "opacity-0 pointer-events-none md:opacity-0" : "opacity-100")}
|
||||
onPress={() => setOpenSideBar(true)}
|
||||
>
|
||||
<TbSquareRoundedChevronLeftFilled className="transform rotate-180" />
|
||||
</Button>
|
||||
<div className='h-[calc(100vh-3.5rem)] p-0 md:p-4'>
|
||||
<div className={clsx(
|
||||
'h-full flex flex-col overflow-hidden transition-all relative',
|
||||
'rounded-none md:rounded-2xl',
|
||||
hasBackground
|
||||
? 'bg-white/5 dark:bg-black/5 backdrop-blur-sm'
|
||||
: 'bg-white/20 dark:bg-black/10 backdrop-blur-sm shadow-sm'
|
||||
)}>
|
||||
{/* Unifed Header */}
|
||||
<div className='h-12 border-b border-white/10 flex items-center justify-between px-4 z-50 bg-white/5 flex-shrink-0'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
className={clsx(
|
||||
"opacity-50 hover:opacity-100 transition-all",
|
||||
openSideBar && "text-primary opacity-100"
|
||||
)}
|
||||
onPress={() => setOpenSideBar(!openSideBar)}
|
||||
>
|
||||
<TbSquareRoundedChevronLeftFilled className={clsx("text-lg transform transition-transform", !openSideBar && "rotate-180")} />
|
||||
</Button>
|
||||
<h1 className={clsx(
|
||||
'text-sm font-bold tracking-tight',
|
||||
hasBackground ? 'text-white/80' : 'text-default-700 dark:text-gray-200'
|
||||
)}>接口调试</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OneBotApiDebug path={selectedApi} data={data} />
|
||||
<div className='flex-1 flex flex-row overflow-hidden relative'>
|
||||
<OneBotApiNavList
|
||||
data={oneBotHttpApi}
|
||||
selectedApi={activeApi || '' as any}
|
||||
onSelect={handleSelectApi}
|
||||
openSideBar={openSideBar}
|
||||
onToggle={setOpenSideBar}
|
||||
/>
|
||||
|
||||
<div
|
||||
className='flex-1 h-full overflow-hidden flex flex-col relative'
|
||||
>
|
||||
{/* Tab Bar */}
|
||||
<div className='flex items-center w-full overflow-x-auto no-scrollbar border-b border-white/5 bg-white/5 flex-shrink-0'>
|
||||
{openApis.map((api) => {
|
||||
const isActive = api === activeApi;
|
||||
const item = oneBotHttpApi[api];
|
||||
return (
|
||||
<div
|
||||
key={api}
|
||||
onClick={() => setActiveApi(api)}
|
||||
className={clsx(
|
||||
'group flex items-center gap-2 px-4 h-9 cursor-pointer border-r border-white/5 select-none transition-all min-w-[120px] max-w-[200px]',
|
||||
isActive
|
||||
? (hasBackground ? 'bg-white/10 text-white' : 'bg-white/40 dark:bg-white/5 text-primary font-medium')
|
||||
: 'opacity-50 hover:opacity-100 hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<span className={clsx(
|
||||
'text-[10px] font-bold uppercase tracking-wider',
|
||||
isActive ? 'opacity-100' : 'opacity-50'
|
||||
)}>POST</span>
|
||||
<span className='text-xs truncate flex-1'>{item?.description || api}</span>
|
||||
<div
|
||||
className={clsx(
|
||||
'p-0.5 rounded-full hover:bg-black/10 dark:hover:bg-white/20 transition-opacity',
|
||||
isActive ? 'opacity-50 hover:opacity-100' : 'opacity-0 group-hover:opacity-50'
|
||||
)}
|
||||
onClick={(e) => handleCloseTab(e, api)}
|
||||
>
|
||||
<IoClose size={12} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content Panels */}
|
||||
<div className='flex-1 relative overflow-hidden'>
|
||||
{activeApi === null && (
|
||||
<div className='h-full flex items-center justify-center text-default-400 text-sm opacity-50 select-none'>
|
||||
选择一个接口开始调试
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openApis.map((api) => (
|
||||
<div
|
||||
key={api}
|
||||
className={clsx(
|
||||
'h-full w-full absolute top-0 left-0 transition-opacity duration-200',
|
||||
api === activeApi ? 'opacity-100 z-10' : 'opacity-0 z-0 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<OneBotApiDebug
|
||||
path={api}
|
||||
data={oneBotHttpApi[api]}
|
||||
adapterName={adapterName}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Button } from '@heroui/button';
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Input } from '@heroui/input';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useCallback, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoFlash, IoFlashOff, IoRefresh } from 'react-icons/io5';
|
||||
|
||||
import key from '@/const/key';
|
||||
|
||||
@@ -25,17 +27,74 @@ export default function WSDebug () {
|
||||
const [inputUrl, setInputUrl] = useState(socketConfig.url);
|
||||
const [inputToken, setInputToken] = useState(socketConfig.token);
|
||||
const [shouldConnect, setShouldConnect] = useState(false);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const { sendMessage, readyState, FilterMessagesType, filteredMessages, clearMessages } =
|
||||
useWebSocketDebug(socketConfig.url, socketConfig.token, shouldConnect);
|
||||
|
||||
// Auto fetch adapter and set URL
|
||||
useEffect(() => {
|
||||
// 检查是否应该覆盖 URL
|
||||
const isDefaultUrl = socketConfig.url === defaultWsUrl || socketConfig.url === '';
|
||||
const isWebDebugUrl = socketConfig.url && socketConfig.url.includes('/api/Debug/ws');
|
||||
|
||||
if (!isDefaultUrl && !isWebDebugUrl) {
|
||||
setInputUrl(socketConfig.url);
|
||||
setInputToken(socketConfig.token);
|
||||
return; // 已经有自定义/有效的配置,跳过自动创建
|
||||
}
|
||||
|
||||
const initAdapter = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/Debug/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.code === 0) {
|
||||
//const adapterName = data.data.adapterName;
|
||||
const token = data.data.token;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
|
||||
if (token) {
|
||||
// URL 中不再包含 Token,Token 单独放入输入框
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/Debug/ws`;
|
||||
|
||||
setSocketConfig({
|
||||
url: wsUrl,
|
||||
token: token
|
||||
});
|
||||
setInputUrl(wsUrl);
|
||||
setInputToken(token);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create debug adapter:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initAdapter();
|
||||
}, []);
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
if (!inputUrl.startsWith('ws://') && !inputUrl.startsWith('wss://')) {
|
||||
// 允许以 / 开头的相对路径(如代理情况),以及标准的 ws/wss
|
||||
let finalUrl = inputUrl;
|
||||
if (finalUrl.startsWith('/')) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
finalUrl = `${protocol}//${window.location.host}${finalUrl}`;
|
||||
}
|
||||
|
||||
if (!finalUrl.startsWith('ws://') && !finalUrl.startsWith('wss://')) {
|
||||
toast.error('WebSocket URL 不合法');
|
||||
return;
|
||||
}
|
||||
|
||||
setSocketConfig({
|
||||
url: inputUrl,
|
||||
url: finalUrl,
|
||||
token: inputToken,
|
||||
});
|
||||
setShouldConnect(true);
|
||||
@@ -45,64 +104,128 @@ export default function WSDebug () {
|
||||
setShouldConnect(false);
|
||||
}, []);
|
||||
|
||||
const handleResetConfig = useCallback(() => {
|
||||
setSocketConfig({ url: '', token: '' });
|
||||
// 刷新页面以重新触发初始逻辑
|
||||
window.location.reload();
|
||||
}, [setSocketConfig]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>Websocket调试 - NapCat WebUI</title>
|
||||
<div className='h-[calc(100vh-4rem)] overflow-hidden flex flex-col p-2 md:p-0'>
|
||||
<Card className='md:mx-2 md:mt-2 flex-shrink-0 bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm'>
|
||||
<CardBody className='gap-2'>
|
||||
<div className='grid gap-2 items-center md:grid-cols-5'>
|
||||
<div className='h-[calc(100vh-4rem)] overflow-hidden flex flex-col p-2 md:p-4 gap-2 md:gap-4'>
|
||||
{/* Config Card */}
|
||||
<Card className={clsx(
|
||||
'flex-shrink-0 backdrop-blur-xl border shadow-sm',
|
||||
hasBackground
|
||||
? 'bg-white/10 dark:bg-black/10 border-white/40 dark:border-white/10'
|
||||
: 'bg-white/60 dark:bg-black/40 border-white/40 dark:border-white/10'
|
||||
)}>
|
||||
<CardBody className='gap-3 p-3 md:p-4'>
|
||||
{/* Connection Config */}
|
||||
<div className='grid gap-3 items-end md:grid-cols-[1fr_1fr_auto]'>
|
||||
<Input
|
||||
className='col-span-2'
|
||||
label='WebSocket URL'
|
||||
type='text'
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
placeholder='输入 WebSocket URL'
|
||||
size='sm'
|
||||
variant='bordered'
|
||||
classNames={{
|
||||
inputWrapper: clsx(
|
||||
'backdrop-blur-sm border',
|
||||
hasBackground
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-default-100/50 border-default-200/50'
|
||||
),
|
||||
label: hasBackground ? 'text-white/80' : '',
|
||||
input: hasBackground ? 'text-white placeholder:text-white/50' : '',
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className='col-span-2'
|
||||
label='Token'
|
||||
type='text'
|
||||
value={inputToken}
|
||||
onChange={(e) => setInputToken(e.target.value)}
|
||||
placeholder='输入 Token'
|
||||
placeholder='输入 Token (可选)'
|
||||
size='sm'
|
||||
variant='bordered'
|
||||
classNames={{
|
||||
inputWrapper: clsx(
|
||||
'backdrop-blur-sm border',
|
||||
hasBackground
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-default-100/50 border-default-200/50'
|
||||
),
|
||||
label: hasBackground ? 'text-white/80' : '',
|
||||
input: hasBackground ? 'text-white placeholder:text-white/50' : '',
|
||||
}}
|
||||
/>
|
||||
<div className='flex-shrink-0 flex gap-2 col-span-2 md:col-span-1'>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="md"
|
||||
radius="full"
|
||||
color="warning"
|
||||
variant="flat"
|
||||
onPress={handleResetConfig}
|
||||
title="重置配置"
|
||||
>
|
||||
<IoRefresh className="text-xl" />
|
||||
</Button>
|
||||
<Button
|
||||
onPress={shouldConnect ? handleDisconnect : handleConnect}
|
||||
size='lg'
|
||||
size='md'
|
||||
radius='full'
|
||||
color={shouldConnect ? 'danger' : 'primary'}
|
||||
className='w-full md:w-auto'
|
||||
className='font-bold shadow-lg min-w-[100px] flex-1'
|
||||
startContent={shouldConnect ? <IoFlashOff /> : <IoFlash />}
|
||||
>
|
||||
{shouldConnect ? '断开' : '连接'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='p-2 rounded-lg bg-white/50 dark:bg-white/5 border border-white/20 transition-colors'>
|
||||
<div className='grid gap-2 md:grid-cols-5 items-center md:w-fit'>
|
||||
<WSStatus state={readyState} />
|
||||
<div className='md:w-64 max-w-full col-span-2'>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className={clsx(
|
||||
'p-2.5 rounded-xl border transition-colors flex flex-col md:flex-row gap-3 md:items-center md:justify-between',
|
||||
hasBackground
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/50 dark:bg-white/5 border-white/20'
|
||||
)}>
|
||||
<div className='flex items-center gap-3 w-full md:w-auto'>
|
||||
<div className="flex-shrink-0">
|
||||
<WSStatus state={readyState} />
|
||||
</div>
|
||||
<div className='flex-1 md:w-56 overflow-hidden'>
|
||||
{FilterMessagesType}
|
||||
</div>
|
||||
<div className='flex gap-2 justify-end col-span-2 md:col-span-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
color='danger'
|
||||
variant='flat'
|
||||
onPress={clearMessages}
|
||||
>
|
||||
清空日志
|
||||
</Button>
|
||||
<OneBotSendModal sendMessage={sendMessage} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2 justify-end w-full md:w-auto pt-1 md:pt-0 border-t border-white/5 md:border-t-0'>
|
||||
<Button
|
||||
size='sm'
|
||||
color='danger'
|
||||
variant='flat'
|
||||
radius='full'
|
||||
className='font-medium'
|
||||
onPress={clearMessages}
|
||||
>
|
||||
清空日志
|
||||
</Button>
|
||||
<OneBotSendModal sendMessage={sendMessage} />
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className='flex-1 overflow-hidden'>
|
||||
{/* Message List */}
|
||||
<div className={clsx(
|
||||
'flex-1 overflow-hidden rounded-2xl border backdrop-blur-xl',
|
||||
hasBackground
|
||||
? 'bg-white/10 dark:bg-black/10 border-white/40 dark:border-white/10'
|
||||
: 'bg-white/60 dark:bg-black/40 border-white/40 dark:border-white/10'
|
||||
)}>
|
||||
<OneBotMessageList messages={filteredMessages} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @stylistic/indent */
|
||||
import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
@@ -320,9 +321,9 @@ export default function FileManagerPage () {
|
||||
}
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
|
||||
onDrop,
|
||||
noClick: true,
|
||||
noClick: true, // 禁用自动点击,使用 open() 手动触发
|
||||
onDragOver: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -336,13 +337,15 @@ export default function FileManagerPage () {
|
||||
return (
|
||||
<div className='h-full flex flex-col relative gap-4 w-full p-2 md:p-4'>
|
||||
<div className={clsx(
|
||||
'mb-4 flex flex-col md:flex-row items-stretch md:items-center gap-4 sticky top-14 z-10 backdrop-blur-sm shadow-sm py-2 px-4 rounded-xl transition-colors',
|
||||
'mb-4 flex flex-col md:flex-row items-stretch md:items-center gap-4 sticky top-14 z-10 backdrop-blur-sm shadow-sm py-2 px-4 rounded-sm transition-colors',
|
||||
hasBackground
|
||||
? 'bg-white/20 dark:bg-black/10 border border-white/40 dark:border-white/10'
|
||||
: 'bg-white/60 dark:bg-black/40 border border-white/40 dark:border-white/10'
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-x-auto hide-scrollbar pb-1 md:pb-0'>
|
||||
<Button
|
||||
radius='sm'
|
||||
color='primary'
|
||||
size='sm'
|
||||
isIconOnly
|
||||
@@ -354,6 +357,7 @@ export default function FileManagerPage () {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
radius='sm'
|
||||
color='primary'
|
||||
size='sm'
|
||||
isIconOnly
|
||||
@@ -365,6 +369,7 @@ export default function FileManagerPage () {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
radius='sm'
|
||||
color='primary'
|
||||
isLoading={loading}
|
||||
size='sm'
|
||||
@@ -376,6 +381,7 @@ export default function FileManagerPage () {
|
||||
<MdRefresh />
|
||||
</Button>
|
||||
<Button
|
||||
radius='sm'
|
||||
color='primary'
|
||||
size='sm'
|
||||
isIconOnly
|
||||
@@ -390,6 +396,7 @@ export default function FileManagerPage () {
|
||||
selectedFiles === 'all') && (
|
||||
<>
|
||||
<Button
|
||||
radius='sm'
|
||||
color='primary'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
@@ -402,6 +409,7 @@ export default function FileManagerPage () {
|
||||
)
|
||||
</Button>
|
||||
<Button
|
||||
radius='sm'
|
||||
color='primary'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
@@ -417,6 +425,7 @@ export default function FileManagerPage () {
|
||||
)
|
||||
</Button>
|
||||
<Button
|
||||
radius='sm'
|
||||
color='primary'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
@@ -433,7 +442,10 @@ export default function FileManagerPage () {
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col md:flex-row flex-1 gap-2 overflow-hidden items-stretch md:items-center'>
|
||||
<Breadcrumbs className='flex-1 bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border border-white/20 px-2 py-2 rounded-lg overflow-x-auto hide-scrollbar whitespace-nowrap'>
|
||||
<Breadcrumbs
|
||||
radius='sm'
|
||||
className='flex-1 bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border border-white/20 px-2 py-2 rounded-sm overflow-x-auto hide-scrollbar whitespace-nowrap'
|
||||
>
|
||||
{currentPath.split('/').map((part, index, parts) => (
|
||||
<BreadcrumbItem
|
||||
key={part}
|
||||
@@ -448,6 +460,7 @@ export default function FileManagerPage () {
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
<Input
|
||||
radius='sm'
|
||||
type='text'
|
||||
placeholder='输入跳转路径'
|
||||
value={jumpPath}
|
||||
@@ -470,7 +483,7 @@ export default function FileManagerPage () {
|
||||
animate={{ height: showUpload ? 'auto' : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={clsx(
|
||||
'border-dashed rounded-lg text-center',
|
||||
'border-dashed rounded-sm text-center overflow-hidden',
|
||||
isDragActive ? 'border-primary bg-primary/10' : 'border-default-300',
|
||||
showUpload ? 'mb-4 border-2' : 'border-none'
|
||||
)}
|
||||
@@ -479,9 +492,15 @@ export default function FileManagerPage () {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div {...getRootProps()} className='w-full h-full p-4'>
|
||||
<div {...getRootProps()} className='w-full h-full p-4 cursor-pointer hover:bg-default-100 transition-colors'>
|
||||
<input {...getInputProps()} multiple />
|
||||
<p>拖拽文件或文件夹到此处上传,或点击选择文件</p>
|
||||
<div className='flex flex-col items-center gap-2'>
|
||||
<FiUpload className='text-3xl text-primary' />
|
||||
<p className='text-default-600'>拖拽文件到此处上传</p>
|
||||
<Button radius='sm' color='primary' size='sm' variant='flat' onPress={open}>
|
||||
点击选择文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Card, CardBody } from '@heroui/card';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useRequest } from 'ahooks';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import key from '@/const/key';
|
||||
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -65,7 +65,7 @@ export interface SystemStatusCardProps {
|
||||
setArchInfo: (arch: string | undefined) => void;
|
||||
}
|
||||
const SystemStatusCard: React.FC<SystemStatusCardProps> = ({ setArchInfo }) => {
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus>();
|
||||
const [systemStatus, setSystemStatus] = useLocalStorage<SystemStatus | undefined>('napcat_system_status_cache', undefined);
|
||||
const isSetted = useRef(false);
|
||||
const getStatus = useCallback(() => {
|
||||
try {
|
||||
@@ -94,7 +94,7 @@ const SystemStatusCard: React.FC<SystemStatusCardProps> = ({ setArchInfo }) => {
|
||||
};
|
||||
|
||||
const DashboardIndexPage: React.FC = () => {
|
||||
const [archInfo, setArchInfo] = useState<string>();
|
||||
const [archInfo, setArchInfo] = useLocalStorage<string | undefined>('napcat_arch_info_cache', undefined);
|
||||
// @ts-ignore
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
@@ -102,7 +102,7 @@ const DashboardIndexPage: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<title>基础信息 - NapCat WebUI</title>
|
||||
<section className='w-full p-2 md:p-4 md:max-w-[1000px] mx-auto'>
|
||||
<section className='w-full p-2 md:p-4 md:max-w-[1000px] mx-auto overflow-hidden'>
|
||||
<div className='grid grid-cols-1 lg:grid-cols-3 gap-4 items-stretch'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<QQInfo />
|
||||
@@ -112,10 +112,11 @@ const DashboardIndexPage: React.FC = () => {
|
||||
</div>
|
||||
<Networks />
|
||||
<Card className={clsx(
|
||||
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all',
|
||||
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all overflow-hidden',
|
||||
hasBackground ? 'bg-white/10 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
|
||||
)}>
|
||||
<CardBody>
|
||||
)}
|
||||
>
|
||||
<CardBody className='overflow-hidden'>
|
||||
<Hitokoto />
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function TerminalPage () {
|
||||
variant='flat'
|
||||
onPress={createNewTerminal}
|
||||
startContent={<IoAdd />}
|
||||
className='text-xl'
|
||||
className='text-xl ml-auto'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-grow overflow-hidden'>
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
@font-face {
|
||||
font-family: 'Aa偷吃可爱长大的';
|
||||
src: url('/fonts/AaCute.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/fonts/JetBrainsMono.ttf') format('truetype');
|
||||
src: url('/webui/fonts/JetBrainsMono.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/fonts/JetBrainsMono-Italic.ttf') format('truetype');
|
||||
src: url('/webui/fonts/JetBrainsMono-Italic.ttf') format('truetype');
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
@@ -5,40 +5,42 @@
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family:
|
||||
'Quicksand',
|
||||
'Nunito',
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Arial,
|
||||
'PingFang SC',
|
||||
'Microsoft YaHei',
|
||||
sans-serif !important;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-smooth: always;
|
||||
letter-spacing: 0.02em;
|
||||
font-family: var(--font-family-base, 'Quicksand', 'Nunito', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif) !important;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-smooth: always;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
:root {
|
||||
--heroui-primary: 217.2 91.2% 59.8%; /* 自然的现代蓝 */
|
||||
/* 字体变量:可被 JS 动态覆盖 */
|
||||
--font-family-fallbacks: 'Quicksand', 'Nunito', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-family-base: var(--font-family-fallbacks);
|
||||
--heroui-primary: 217.2 91.2% 59.8%;
|
||||
/* 自然的现代蓝 */
|
||||
--heroui-primary-foreground: 210 40% 98%;
|
||||
--heroui-radius: 0.75rem;
|
||||
--text-primary: 222.2 47.4% 11.2%;
|
||||
--text-secondary: 215.4 16.3% 46.9%;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: hsl(var(--text-primary));
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.dark h1, .dark h2, .dark h3, .dark h4, .dark h5, .dark h6 {
|
||||
.dark h1,
|
||||
.dark h2,
|
||||
.dark h3,
|
||||
.dark h4,
|
||||
.dark h5,
|
||||
.dark h6 {
|
||||
color: hsl(210 40% 98%);
|
||||
}
|
||||
|
||||
@@ -52,11 +54,13 @@ h1, h2, h3, h4, h5, h6 {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar-thumb {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar-track {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
@@ -80,7 +84,8 @@ h1, h2, h3, h4, h5, h6 {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 182, 193, 0.4); /* 浅粉色滚动条 */
|
||||
background-color: rgba(255, 182, 193, 0.4);
|
||||
/* 浅粉色滚动条 */
|
||||
border-radius: 3px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
@@ -123,16 +128,18 @@ h1, h2, h3, h4, h5, h6 {
|
||||
|
||||
.context-view.monaco-menu-container * {
|
||||
font-family:
|
||||
PingFang SC,
|
||||
'Aa偷吃可爱长大的',
|
||||
Helvetica Neue,
|
||||
Microsoft YaHei,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
'PingFang SC',
|
||||
'Microsoft YaHei',
|
||||
sans-serif !important;
|
||||
}
|
||||
|
||||
.ql-hidden {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.ql-editor img {
|
||||
@apply inline-block;
|
||||
}
|
||||
317
packages/napcat-webui-frontend/src/types/server.d.ts
vendored
317
packages/napcat-webui-frontend/src/types/server.d.ts
vendored
@@ -1,191 +1,192 @@
|
||||
interface ServerResponse<T> {
|
||||
code: number
|
||||
data: T
|
||||
message: string
|
||||
code: number;
|
||||
data: T;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
Credential: string
|
||||
Credential: string;
|
||||
}
|
||||
|
||||
interface LoginListItem {
|
||||
uin: string
|
||||
uid: string
|
||||
nickName: string
|
||||
faceUrl: string
|
||||
facePath: string
|
||||
loginType: 1 // 1是二维码登录?
|
||||
isQuickLogin: boolean // 是否可以快速登录
|
||||
isAutoLogin: boolean // 是否可以自动登录
|
||||
uin: string;
|
||||
uid: string;
|
||||
nickName: string;
|
||||
faceUrl: string;
|
||||
facePath: string;
|
||||
loginType: 1; // 1是二维码登录?
|
||||
isQuickLogin: boolean; // 是否可以快速登录
|
||||
isAutoLogin: boolean; // 是否可以自动登录
|
||||
}
|
||||
|
||||
interface PackageInfo {
|
||||
name: string
|
||||
version: string
|
||||
private: boolean
|
||||
type: string
|
||||
scripts: Record<string, string>
|
||||
dependencies: Record<string, string>
|
||||
devDependencies: Record<string, string>
|
||||
name: string;
|
||||
version: string;
|
||||
private: boolean;
|
||||
type: string;
|
||||
scripts: Record<string, string>;
|
||||
dependencies: Record<string, string>;
|
||||
devDependencies: Record<string, string>;
|
||||
}
|
||||
|
||||
interface SystemStatus {
|
||||
cpu: {
|
||||
core: number
|
||||
model: string
|
||||
speed: string
|
||||
core: number;
|
||||
model: string;
|
||||
speed: string;
|
||||
usage: {
|
||||
system: string
|
||||
qq: string
|
||||
}
|
||||
}
|
||||
system: string;
|
||||
qq: string;
|
||||
};
|
||||
};
|
||||
memory: {
|
||||
total: string
|
||||
total: string;
|
||||
usage: {
|
||||
system: string
|
||||
qq: string
|
||||
}
|
||||
}
|
||||
arch: string
|
||||
system: string;
|
||||
qq: string;
|
||||
};
|
||||
};
|
||||
arch: string;
|
||||
}
|
||||
|
||||
interface ThemeConfigItem {
|
||||
'--heroui-background': string
|
||||
'--heroui-foreground-50': string
|
||||
'--heroui-foreground-100': string
|
||||
'--heroui-foreground-200': string
|
||||
'--heroui-foreground-300': string
|
||||
'--heroui-foreground-400': string
|
||||
'--heroui-foreground-500': string
|
||||
'--heroui-foreground-600': string
|
||||
'--heroui-foreground-700': string
|
||||
'--heroui-foreground-800': string
|
||||
'--heroui-foreground-900': string
|
||||
'--heroui-foreground': string
|
||||
'--heroui-focus': string
|
||||
'--heroui-overlay': string
|
||||
'--heroui-divider': string
|
||||
'--heroui-divider-opacity': string
|
||||
'--heroui-content1': string
|
||||
'--heroui-content1-foreground': string
|
||||
'--heroui-content2': string
|
||||
'--heroui-content2-foreground': string
|
||||
'--heroui-content3': string
|
||||
'--heroui-content3-foreground': string
|
||||
'--heroui-content4': string
|
||||
'--heroui-content4-foreground': string
|
||||
'--heroui-default-50': string
|
||||
'--heroui-default-100': string
|
||||
'--heroui-default-200': string
|
||||
'--heroui-default-300': string
|
||||
'--heroui-default-400': string
|
||||
'--heroui-default-500': string
|
||||
'--heroui-default-600': string
|
||||
'--heroui-default-700': string
|
||||
'--heroui-default-800': string
|
||||
'--heroui-default-900': string
|
||||
'--heroui-default-foreground': string
|
||||
'--heroui-default': string
|
||||
'--heroui-background': string;
|
||||
'--heroui-foreground-50': string;
|
||||
'--heroui-foreground-100': string;
|
||||
'--heroui-foreground-200': string;
|
||||
'--heroui-foreground-300': string;
|
||||
'--heroui-foreground-400': string;
|
||||
'--heroui-foreground-500': string;
|
||||
'--heroui-foreground-600': string;
|
||||
'--heroui-foreground-700': string;
|
||||
'--heroui-foreground-800': string;
|
||||
'--heroui-foreground-900': string;
|
||||
'--heroui-foreground': string;
|
||||
'--heroui-focus': string;
|
||||
'--heroui-overlay': string;
|
||||
'--heroui-divider': string;
|
||||
'--heroui-divider-opacity': string;
|
||||
'--heroui-content1': string;
|
||||
'--heroui-content1-foreground': string;
|
||||
'--heroui-content2': string;
|
||||
'--heroui-content2-foreground': string;
|
||||
'--heroui-content3': string;
|
||||
'--heroui-content3-foreground': string;
|
||||
'--heroui-content4': string;
|
||||
'--heroui-content4-foreground': string;
|
||||
'--heroui-default-50': string;
|
||||
'--heroui-default-100': string;
|
||||
'--heroui-default-200': string;
|
||||
'--heroui-default-300': string;
|
||||
'--heroui-default-400': string;
|
||||
'--heroui-default-500': string;
|
||||
'--heroui-default-600': string;
|
||||
'--heroui-default-700': string;
|
||||
'--heroui-default-800': string;
|
||||
'--heroui-default-900': string;
|
||||
'--heroui-default-foreground': string;
|
||||
'--heroui-default': string;
|
||||
// 新增 danger
|
||||
'--heroui-danger-50': string
|
||||
'--heroui-danger-100': string
|
||||
'--heroui-danger-200': string
|
||||
'--heroui-danger-300': string
|
||||
'--heroui-danger-400': string
|
||||
'--heroui-danger-500': string
|
||||
'--heroui-danger-600': string
|
||||
'--heroui-danger-700': string
|
||||
'--heroui-danger-800': string
|
||||
'--heroui-danger-900': string
|
||||
'--heroui-danger-foreground': string
|
||||
'--heroui-danger': string
|
||||
'--heroui-danger-50': string;
|
||||
'--heroui-danger-100': string;
|
||||
'--heroui-danger-200': string;
|
||||
'--heroui-danger-300': string;
|
||||
'--heroui-danger-400': string;
|
||||
'--heroui-danger-500': string;
|
||||
'--heroui-danger-600': string;
|
||||
'--heroui-danger-700': string;
|
||||
'--heroui-danger-800': string;
|
||||
'--heroui-danger-900': string;
|
||||
'--heroui-danger-foreground': string;
|
||||
'--heroui-danger': string;
|
||||
// 新增 primary
|
||||
'--heroui-primary-50': string
|
||||
'--heroui-primary-100': string
|
||||
'--heroui-primary-200': string
|
||||
'--heroui-primary-300': string
|
||||
'--heroui-primary-400': string
|
||||
'--heroui-primary-500': string
|
||||
'--heroui-primary-600': string
|
||||
'--heroui-primary-700': string
|
||||
'--heroui-primary-800': string
|
||||
'--heroui-primary-900': string
|
||||
'--heroui-primary-foreground': string
|
||||
'--heroui-primary': string
|
||||
'--heroui-primary-50': string;
|
||||
'--heroui-primary-100': string;
|
||||
'--heroui-primary-200': string;
|
||||
'--heroui-primary-300': string;
|
||||
'--heroui-primary-400': string;
|
||||
'--heroui-primary-500': string;
|
||||
'--heroui-primary-600': string;
|
||||
'--heroui-primary-700': string;
|
||||
'--heroui-primary-800': string;
|
||||
'--heroui-primary-900': string;
|
||||
'--heroui-primary-foreground': string;
|
||||
'--heroui-primary': string;
|
||||
// 新增 secondary
|
||||
'--heroui-secondary-50': string
|
||||
'--heroui-secondary-100': string
|
||||
'--heroui-secondary-200': string
|
||||
'--heroui-secondary-300': string
|
||||
'--heroui-secondary-400': string
|
||||
'--heroui-secondary-500': string
|
||||
'--heroui-secondary-600': string
|
||||
'--heroui-secondary-700': string
|
||||
'--heroui-secondary-800': string
|
||||
'--heroui-secondary-900': string
|
||||
'--heroui-secondary-foreground': string
|
||||
'--heroui-secondary': string
|
||||
'--heroui-secondary-50': string;
|
||||
'--heroui-secondary-100': string;
|
||||
'--heroui-secondary-200': string;
|
||||
'--heroui-secondary-300': string;
|
||||
'--heroui-secondary-400': string;
|
||||
'--heroui-secondary-500': string;
|
||||
'--heroui-secondary-600': string;
|
||||
'--heroui-secondary-700': string;
|
||||
'--heroui-secondary-800': string;
|
||||
'--heroui-secondary-900': string;
|
||||
'--heroui-secondary-foreground': string;
|
||||
'--heroui-secondary': string;
|
||||
// 新增 success
|
||||
'--heroui-success-50': string
|
||||
'--heroui-success-100': string
|
||||
'--heroui-success-200': string
|
||||
'--heroui-success-300': string
|
||||
'--heroui-success-400': string
|
||||
'--heroui-success-500': string
|
||||
'--heroui-success-600': string
|
||||
'--heroui-success-700': string
|
||||
'--heroui-success-800': string
|
||||
'--heroui-success-900': string
|
||||
'--heroui-success-foreground': string
|
||||
'--heroui-success': string
|
||||
'--heroui-success-50': string;
|
||||
'--heroui-success-100': string;
|
||||
'--heroui-success-200': string;
|
||||
'--heroui-success-300': string;
|
||||
'--heroui-success-400': string;
|
||||
'--heroui-success-500': string;
|
||||
'--heroui-success-600': string;
|
||||
'--heroui-success-700': string;
|
||||
'--heroui-success-800': string;
|
||||
'--heroui-success-900': string;
|
||||
'--heroui-success-foreground': string;
|
||||
'--heroui-success': string;
|
||||
// 新增 warning
|
||||
'--heroui-warning-50': string
|
||||
'--heroui-warning-100': string
|
||||
'--heroui-warning-200': string
|
||||
'--heroui-warning-300': string
|
||||
'--heroui-warning-400': string
|
||||
'--heroui-warning-500': string
|
||||
'--heroui-warning-600': string
|
||||
'--heroui-warning-700': string
|
||||
'--heroui-warning-800': string
|
||||
'--heroui-warning-900': string
|
||||
'--heroui-warning-foreground': string
|
||||
'--heroui-warning': string
|
||||
'--heroui-warning-50': string;
|
||||
'--heroui-warning-100': string;
|
||||
'--heroui-warning-200': string;
|
||||
'--heroui-warning-300': string;
|
||||
'--heroui-warning-400': string;
|
||||
'--heroui-warning-500': string;
|
||||
'--heroui-warning-600': string;
|
||||
'--heroui-warning-700': string;
|
||||
'--heroui-warning-800': string;
|
||||
'--heroui-warning-900': string;
|
||||
'--heroui-warning-foreground': string;
|
||||
'--heroui-warning': string;
|
||||
// 其它配置
|
||||
'--heroui-code-background': string
|
||||
'--heroui-strong': string
|
||||
'--heroui-code-mdx': string
|
||||
'--heroui-divider-weight': string
|
||||
'--heroui-disabled-opacity': string
|
||||
'--heroui-font-size-tiny': string
|
||||
'--heroui-font-size-small': string
|
||||
'--heroui-font-size-medium': string
|
||||
'--heroui-font-size-large': string
|
||||
'--heroui-line-height-tiny': string
|
||||
'--heroui-line-height-small': string
|
||||
'--heroui-line-height-medium': string
|
||||
'--heroui-line-height-large': string
|
||||
'--heroui-radius-small': string
|
||||
'--heroui-radius-medium': string
|
||||
'--heroui-radius-large': string
|
||||
'--heroui-border-width-small': string
|
||||
'--heroui-border-width-medium': string
|
||||
'--heroui-border-width-large': string
|
||||
'--heroui-box-shadow-small': string
|
||||
'--heroui-box-shadow-medium': string
|
||||
'--heroui-box-shadow-large': string
|
||||
'--heroui-hover-opacity': string
|
||||
'--heroui-code-background': string;
|
||||
'--heroui-strong': string;
|
||||
'--heroui-code-mdx': string;
|
||||
'--heroui-divider-weight': string;
|
||||
'--heroui-disabled-opacity': string;
|
||||
'--heroui-font-size-tiny': string;
|
||||
'--heroui-font-size-small': string;
|
||||
'--heroui-font-size-medium': string;
|
||||
'--heroui-font-size-large': string;
|
||||
'--heroui-line-height-tiny': string;
|
||||
'--heroui-line-height-small': string;
|
||||
'--heroui-line-height-medium': string;
|
||||
'--heroui-line-height-large': string;
|
||||
'--heroui-radius-small': string;
|
||||
'--heroui-radius-medium': string;
|
||||
'--heroui-radius-large': string;
|
||||
'--heroui-border-width-small': string;
|
||||
'--heroui-border-width-medium': string;
|
||||
'--heroui-border-width-large': string;
|
||||
'--heroui-box-shadow-small': string;
|
||||
'--heroui-box-shadow-medium': string;
|
||||
'--heroui-box-shadow-large': string;
|
||||
'--heroui-hover-opacity': string;
|
||||
}
|
||||
|
||||
interface ThemeConfig {
|
||||
dark: ThemeConfigItem
|
||||
light: ThemeConfigItem
|
||||
dark: ThemeConfigItem;
|
||||
light: ThemeConfigItem;
|
||||
fontMode?: string;
|
||||
}
|
||||
|
||||
interface WebUIConfig {
|
||||
host: string
|
||||
port: number
|
||||
loginRate: number
|
||||
disableWebUI: boolean
|
||||
disableNonLANAccess: boolean
|
||||
host: string;
|
||||
port: number;
|
||||
loginRate: number;
|
||||
disableWebUI: boolean;
|
||||
disableNonLANAccess: boolean;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user