mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-01-04 17:49:01 +08:00
Compare commits
No commits in common. "main" and "v4.9.71" have entirely different histories.
18
.github/prompt/default.md
vendored
18
.github/prompt/default.md
vendored
@ -1,4 +1,4 @@
|
|||||||
# {VERSION}
|
# V?.?.?
|
||||||
[使用文档](https://napneko.github.io/)
|
[使用文档](https://napneko.github.io/)
|
||||||
|
|
||||||
## Windows 一键包
|
## Windows 一键包
|
||||||
@ -25,19 +25,3 @@ NapCat.Shell.Windows.OneKey.zip (无头)
|
|||||||
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
||||||
|
|
||||||
## 更新
|
## 更新
|
||||||
|
|
||||||
### 🐛 修复
|
|
||||||
1. 修复 WebUI 主题配置在有未保存更改时卸载组件导致字体重置的问题 (ae42eed6)
|
|
||||||
|
|
||||||
### ✨ 新增
|
|
||||||
1. 文件上传相关接口(UploadGroupFile/UploadPrivateFile)新增 `upload_file` 参数支持 (91e0839e)
|
|
||||||
2. 消息发送逻辑支持 PTT(语音)元素过滤,确保语音消息正确独立发送 (47983e29)
|
|
||||||
|
|
||||||
### 🔧 优化
|
|
||||||
1. 优化合并转发消息(GetForwardMsg)的获取与解析逻辑,提高兼容性 (334c4233)
|
|
||||||
2. 改进消息发送方法中发送者 UIN 的处理逻辑 (71bb4f68)
|
|
||||||
3. 增强 WebUI 系统信息界面中对构建产物的处理与展示 (cb061890)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**完整更新日志**: [v4.10.6...v4.10.7](https://github.com/NapNeko/NapCatQQ/compare/v4.10.6...v4.10.7)
|
|
||||||
|
|||||||
101
.github/prompt/release_note_prompt.txt
vendored
101
.github/prompt/release_note_prompt.txt
vendored
@ -1,33 +1,34 @@
|
|||||||
# NapCat Release Note Generator
|
注意:输出必须严格使用 NapCat 的发布说明格式,严格保证示例格式,并用简体中文。
|
||||||
|
|
||||||
你是 NapCat 项目的发布说明生成器。请根据提供的 commit 列表生成标准格式的发布说明。
|
格式规则:
|
||||||
|
1. 第一行:# V{TAG}
|
||||||
|
2. 第二行:[使用文档](https://napneko.github.io/)
|
||||||
|
3. 空行后,按下面的节顺序输出(存在则输出,不存在则省略该节):
|
||||||
|
|
||||||
## 核心规则
|
## Windows 一键包
|
||||||
|
- 简短一句话介绍一键包用途
|
||||||
|
- 列出可下载的文件名(只列文件名,不写下载链接)
|
||||||
|
|
||||||
1. **版本号**:第一行必须是 `# {VERSION}`,使用用户提供的版本号,如果版本号是小写 v 开头(如 v4.10.2),必须转换为大写 V(如 V4.10.2)
|
## 警告
|
||||||
2. **语言**:全部使用简体中文
|
- 如果有需要特别提醒的兼容/运行库/版本要求,写成加粗警告句
|
||||||
3. **格式**:严格按照下方模板输出,不要添加额外的 markdown 格式
|
|
||||||
|
|
||||||
## Commit 分析规则
|
## 如果WinX64缺少运行库或者xxx.dll?
|
||||||
|
- 常见运行库建议
|
||||||
|
|
||||||
将 commit 分类为以下类型:
|
## 更新
|
||||||
- 🐛 **修复**:bug fix、修复、fix 相关
|
按数字序列列出主要变更项,每条尽量一句话
|
||||||
- ✨ **新增**:新功能、feat、add 相关
|
- 前缀短 commit id,例如:1. 修复 get_essence_msg_list 崩溃 (a1b2c3d)
|
||||||
- 🔧 **优化**:优化、重构、refactor、improve、perf 相关
|
- 保持 4-18 条要点
|
||||||
- 📦 **依赖**:deps、依赖更新(通常可以忽略或合并)
|
|
||||||
- 🔨 **构建**:ci、build、workflow 相关(通常可以忽略)
|
|
||||||
|
|
||||||
## 合并和筛选
|
## 开发者注意
|
||||||
|
- 列出迁移/接口断裂/配置变更;若无则省略
|
||||||
|
|
||||||
- **合并相似项**:同一功能的多个 commit 合并为一条
|
额外约束:
|
||||||
- **忽略琐碎项**:合并冲突、格式化、typo 等可忽略
|
- 语言简体中文,面向最终用户
|
||||||
- **控制数量**:最终保持 5-15 条更新要点
|
|
||||||
- **保留 commit hash**:每条末尾附上短 hash,格式 `(a1b2c3d)`
|
|
||||||
|
|
||||||
## 输出模板 - 必须严格遵守以下格式
|
下面为真实示例,请完全参考(第一行版本号必须使用用户提供的版本号,例如 v4.9.5)
|
||||||
|
|
||||||
```
|
# V4.9.0
|
||||||
# {VERSION}
|
|
||||||
[使用文档](https://napneko.github.io/)
|
[使用文档](https://napneko.github.io/)
|
||||||
|
|
||||||
## Windows 一键包
|
## Windows 一键包
|
||||||
@ -54,58 +55,6 @@ NapCat.Shell.Windows.OneKey.zip (无头)
|
|||||||
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
||||||
|
|
||||||
## 更新
|
## 更新
|
||||||
|
1. 修改了XXXXX
|
||||||
### 🐛 修复
|
2. 新增了XXXX
|
||||||
1. 修复 xxx 问题 (a1b2c3d)
|
3. 重构了XXXX
|
||||||
2. 修复 yyy 崩溃 (b2c3d4e)
|
|
||||||
|
|
||||||
### ✨ 新增
|
|
||||||
1. 新增 xxx 功能 (c3d4e5f)
|
|
||||||
2. 支持 yyy 特性 (d4e5f6g)
|
|
||||||
|
|
||||||
### 🔧 优化
|
|
||||||
1. 优化 xxx 性能 (e5f6g7h)
|
|
||||||
2. 重构 yyy 模块 (f6g7h8i)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**完整更新日志**: [{PREV_VERSION}...{VERSION}](https://github.com/NapNeko/NapCatQQ/compare/{PREV_VERSION}...{VERSION})
|
|
||||||
```
|
|
||||||
|
|
||||||
**格式要求 - 务必严格遵守:**
|
|
||||||
- "Windows 一键包"部分的文本必须完全一致,不要修改任何措辞
|
|
||||||
- "警告"部分必须包含所有 QQ 版本下载链接,保持原有格式
|
|
||||||
- "如果WinX64缺少运行库或者xxx.dll?"这一行必须保持原样
|
|
||||||
- QQ 版本号和下载链接保持不变(40990 版本)
|
|
||||||
- 只有"## 更新"部分下面的内容需要根据实际 commit 生成
|
|
||||||
|
|
||||||
## 重要约束
|
|
||||||
|
|
||||||
1. 如果某个分类没有内容,则完全省略该分类
|
|
||||||
2. 不要编造不存在的更新
|
|
||||||
3. 保持简洁,每条更新控制在一行内
|
|
||||||
4. 使用用户友好的语言,避免过于技术化的描述
|
|
||||||
5. 重大变更(Breaking Changes)需要在注意事项中加粗提示
|
|
||||||
|
|
||||||
## 文件变化分析
|
|
||||||
|
|
||||||
用户会提供文件变化统计和具体代码diff,帮助你理解变更内容:
|
|
||||||
|
|
||||||
### 目录含义
|
|
||||||
- `packages/napcat-core/` → 核心功能、消息处理、QQ接口
|
|
||||||
- `packages/napcat-onebot/` → OneBot 协议实现、API、事件
|
|
||||||
- `packages/napcat-webui-backend/` → WebUI 后端接口
|
|
||||||
- `packages/napcat-webui-frontend/` → WebUI 前端界面
|
|
||||||
- `packages/napcat-shell/` → Shell 启动器
|
|
||||||
|
|
||||||
### 代码diff阅读指南
|
|
||||||
- `+` 开头的行是新增代码
|
|
||||||
- `-` 开头的行是删除代码
|
|
||||||
- 关注函数名、类名的变化来理解功能变更
|
|
||||||
- 关注 `fix`、`bug`、`error` 等关键词识别修复项
|
|
||||||
- 关注 `add`、`new`、`feature` 等关键词识别新功能
|
|
||||||
- 忽略纯重构(代码移动但功能不变)和格式化变更
|
|
||||||
|
|
||||||
### 截断说明
|
|
||||||
- 如果看到 `[... 已截断 ...]`,表示内容过长被截断
|
|
||||||
- 根据已有信息推断完整变更意图即可
|
|
||||||
231
.github/scripts/lib/comment.ts
vendored
231
.github/scripts/lib/comment.ts
vendored
@ -1,231 +0,0 @@
|
|||||||
/**
|
|
||||||
* 构建状态评论模板
|
|
||||||
*/
|
|
||||||
|
|
||||||
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
189
.github/scripts/lib/github.ts
vendored
@ -1,189 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
36
.github/scripts/pr-build-building.ts
vendored
@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
206
.github/scripts/pr-build-check.ts
vendored
@ -1,206 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
90
.github/scripts/pr-build-result.ts
vendored
@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
149
.github/scripts/pr-build-run.ts
vendored
@ -1,149 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,27 +13,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Clone Main Repository
|
- name: Clone Main Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
fetch-depth: 0 # 需要完整历史来获取 tags
|
|
||||||
- name: Use Node.js 20.X
|
- name: Use Node.js 20.X
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
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
|
- name: Build NapCat.Framework
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
|
|
||||||
run: |
|
run: |
|
||||||
npm i -g pnpm
|
npm i -g pnpm
|
||||||
pnpm i
|
pnpm i
|
||||||
@ -55,27 +39,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Clone Main Repository
|
- name: Clone Main Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
fetch-depth: 0 # 需要完整历史来获取 tags
|
|
||||||
- name: Use Node.js 20.X
|
- name: Use Node.js 20.X
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
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
|
- name: Build NapCat.Shell
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
|
|
||||||
run: |
|
run: |
|
||||||
npm i -g pnpm
|
npm i -g pnpm
|
||||||
pnpm i
|
pnpm i
|
||||||
|
|||||||
303
.github/workflows/pr-build.yml
vendored
303
.github/workflows/pr-build.yml
vendored
@ -1,303 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# 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
|
|
||||||
204
.github/workflows/release.yml
vendored
204
.github/workflows/release.yml
vendored
@ -4,48 +4,17 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- '*'
|
||||||
|
|
||||||
permissions: write-all
|
permissions: write-all
|
||||||
|
|
||||||
env:
|
env:
|
||||||
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
|
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
|
||||||
OPENROUTER_MODEL: "Antigravity/gemini-3-flash-preview"
|
OPENROUTER_MODEL: "kimi-k2-0905-turbo"
|
||||||
RELEASE_NAME: "NapCat"
|
RELEASE_NAME: "NapCat"
|
||||||
|
|
||||||
jobs:
|
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:
|
Build-Framework:
|
||||||
needs: validate-version
|
|
||||||
if: needs.validate-version.outputs.valid == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Clone Main Repository
|
- name: Clone Main Repository
|
||||||
@ -55,8 +24,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
- name: Build NapCat.Framework
|
- name: Build NapCat.Framework
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
npm i -g pnpm
|
npm i -g pnpm
|
||||||
pnpm i
|
pnpm i
|
||||||
@ -73,8 +40,6 @@ jobs:
|
|||||||
path: framework-dist
|
path: framework-dist
|
||||||
|
|
||||||
Build-Shell:
|
Build-Shell:
|
||||||
needs: validate-version
|
|
||||||
if: needs.validate-version.outputs.valid == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Clone Main Repository
|
- name: Clone Main Repository
|
||||||
@ -84,8 +49,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
- name: Build NapCat.Shell
|
- name: Build NapCat.Shell
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
npm i -g pnpm
|
npm i -g pnpm
|
||||||
pnpm i
|
pnpm i
|
||||||
@ -198,10 +161,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: ./artifacts
|
path: ./artifacts
|
||||||
|
|
||||||
- name: Download NapCat.Shell.Windows.OneKey.zip
|
|
||||||
run: |
|
|
||||||
curl -L -o NapCat.Shell.Windows.OneKey.zip https://github.com/NapNeko/NapCatResource/raw/main/NapCat.Shell.Windows.OneKey.zip
|
|
||||||
|
|
||||||
- name: Zip Artifacts
|
- name: Zip Artifacts
|
||||||
run: |
|
run: |
|
||||||
cd artifacts
|
cd artifacts
|
||||||
@ -212,10 +171,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate release note via OpenRouter
|
- name: Generate release note via OpenRouter
|
||||||
env:
|
env:
|
||||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||||
OPENROUTER_API_URL: ${{ env.OPENROUTER_API_URL }}
|
OPENROUTER_API_URL: ${{ env.OPENROUTER_API_URL }}
|
||||||
OPENROUTER_MODEL: ${{ env.OPENROUTER_MODEL }}
|
OPENROUTER_MODEL: ${{ env.OPENROUTER_MODEL }}
|
||||||
GITHUB_OWNER: "NapNeko" # 替换成你的 repo owner
|
GITHUB_OWNER: "NapNeKo" # 替换成你的 repo owner
|
||||||
GITHUB_REPO: "NapCatQQ" # 替换成你的 repo 名
|
GITHUB_REPO: "NapCatQQ" # 替换成你的 repo 名
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@ -240,162 +199,41 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [ -z "$PREV_TAG" ]; then
|
if [ -z "$PREV_TAG" ]; then
|
||||||
echo "⚠️ Could not find previous tag for $CURRENT_TAG, using first commit"
|
echo "❌ Could not find previous tag for $CURRENT_TAG, aborting."
|
||||||
PREV_TAG=$(git rev-list --max-parents=0 HEAD | head -1)
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Previous tag: $PREV_TAG"
|
echo "Previous tag: $PREV_TAG"
|
||||||
|
|
||||||
# 强制拉取上一个 tag 和当前 tag
|
# 强制拉取上一个 tag 和当前 tag
|
||||||
git fetch origin "refs/tags/$PREV_TAG:refs/tags/$PREV_TAG" --force || true
|
git fetch origin "refs/tags/$PREV_TAG:refs/tags/$PREV_TAG" --force
|
||||||
git fetch origin "refs/tags/$CURRENT_TAG:refs/tags/$CURRENT_TAG" --force || true
|
git fetch origin "refs/tags/$CURRENT_TAG:refs/tags/$CURRENT_TAG" --force
|
||||||
|
|
||||||
# 获取 commit,使用更清晰的格式
|
# 获取 commit title + body + 作者,保留换行
|
||||||
# 格式: <type>: <subject> (<hash>)
|
COMMITS=$(git log --pretty=format:'%h %B (%an)' "$PREV_TAG".."$CURRENT_TAG" | sed 's/$/\\n/')
|
||||||
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 "Commit list from $PREV_TAG to $CURRENT_TAG:"
|
||||||
echo "$COMMITS"
|
echo -e "$COMMITS"
|
||||||
|
|
||||||
# 获取文件变化统计
|
|
||||||
echo "Getting file change statistics..."
|
|
||||||
FILE_STATS=$(git diff --stat "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
# 获取总体统计(最后一行)
|
|
||||||
SUMMARY_LINE=$(echo "$FILE_STATS" | tail -1)
|
|
||||||
echo "Summary: $SUMMARY_LINE"
|
|
||||||
|
|
||||||
# 获取每个文件的变化(去掉最后一行汇总)
|
|
||||||
# 截断过长的输出(最多50个文件,每行最多80字符)
|
|
||||||
FILE_CHANGES=$(echo "$FILE_STATS" | head -n -1 | head -50 | cut -c1-80)
|
|
||||||
|
|
||||||
# 如果文件变化太多,进一步精简:只保留主要目录的变化
|
|
||||||
FILE_COUNT=$(echo "$FILE_STATS" | head -n -1 | wc -l)
|
|
||||||
if [ "$FILE_COUNT" -gt 50 ]; then
|
|
||||||
echo "Too many files ($FILE_COUNT), grouping by directory..."
|
|
||||||
# 按目录分组统计
|
|
||||||
DIR_STATS=$(git diff --stat "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | head -n -1 | \
|
|
||||||
sed 's/|.*//g' | \
|
|
||||||
awk -F'/' '{if(NF>1) print $1"/"$2; else print $1}' | \
|
|
||||||
sort | uniq -c | sort -rn | head -20)
|
|
||||||
FILE_CHANGES="[按目录分组统计 - 共 $FILE_COUNT 个文件变更]
|
|
||||||
$DIR_STATS"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "File changes:"
|
|
||||||
echo "$FILE_CHANGES"
|
|
||||||
|
|
||||||
# 获取具体代码变化(关键文件的diff)
|
|
||||||
echo "Getting code diff for key files..."
|
|
||||||
|
|
||||||
# 定义关键目录(优先展示这些目录的变化)
|
|
||||||
KEY_DIRS="packages/napcat-core packages/napcat-onebot packages/napcat-webui-backend"
|
|
||||||
|
|
||||||
# 获取变更的关键文件列表(排除测试、配置等)
|
|
||||||
# 使用 || true 防止 grep 无匹配时返回非零退出码
|
|
||||||
KEY_FILES=$(git diff --name-only "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | \
|
|
||||||
grep -E "^packages/napcat-(core|onebot|webui-backend|shell)/" || true | \
|
|
||||||
grep -E "\.(ts|js)$" || true | \
|
|
||||||
grep -v -E "(test|spec|\.d\.ts|config)" || true | \
|
|
||||||
head -15) || true
|
|
||||||
|
|
||||||
CODE_DIFF=""
|
|
||||||
DIFF_CHAR_LIMIT=6000 # 总diff字符限制
|
|
||||||
CURRENT_CHARS=0
|
|
||||||
|
|
||||||
if [ -n "$KEY_FILES" ]; then
|
|
||||||
for file in $KEY_FILES; do
|
|
||||||
if [ "$CURRENT_CHARS" -ge "$DIFF_CHAR_LIMIT" ]; then
|
|
||||||
CODE_DIFF="$CODE_DIFF
|
|
||||||
[... 更多文件变化已截断 ...]"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 获取单个文件的diff,限制每个文件最多50行
|
|
||||||
FILE_DIFF=$(git diff "$PREV_TAG".."$CURRENT_TAG" -- "$file" 2>/dev/null | head -50) || true
|
|
||||||
FILE_DIFF_LEN=${#FILE_DIFF}
|
|
||||||
|
|
||||||
# 如果单个文件diff超过1500字符,截断
|
|
||||||
if [ "$FILE_DIFF_LEN" -gt 1500 ]; then
|
|
||||||
FILE_DIFF=$(echo "$FILE_DIFF" | head -c 1500)
|
|
||||||
FILE_DIFF="$FILE_DIFF
|
|
||||||
[... 文件 $file 变化已截断 ...]"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$FILE_DIFF" ]; then
|
|
||||||
CODE_DIFF="$CODE_DIFF
|
|
||||||
|
|
||||||
### $file
|
|
||||||
\`\`\`diff
|
|
||||||
$FILE_DIFF
|
|
||||||
\`\`\`"
|
|
||||||
CURRENT_CHARS=$((CURRENT_CHARS + FILE_DIFF_LEN))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 如果没有关键文件变化,获取前5个变更文件的diff
|
|
||||||
if [ -z "$CODE_DIFF" ]; then
|
|
||||||
echo "No key files changed, getting top changed files..."
|
|
||||||
TOP_FILES=$(git diff --name-only "$PREV_TAG".."$CURRENT_TAG" 2>/dev/null | \
|
|
||||||
grep -E "\.(ts|js|yml|md)$" | head -5) || true
|
|
||||||
|
|
||||||
if [ -n "$TOP_FILES" ]; then
|
|
||||||
for file in $TOP_FILES; do
|
|
||||||
FILE_DIFF=$(git diff "$PREV_TAG".."$CURRENT_TAG" -- "$file" 2>/dev/null | head -30) || true
|
|
||||||
if [ -n "$FILE_DIFF" ] && [ ${#FILE_DIFF} -lt 1000 ]; then
|
|
||||||
CODE_DIFF="$CODE_DIFF
|
|
||||||
|
|
||||||
### $file
|
|
||||||
\`\`\`diff
|
|
||||||
$FILE_DIFF
|
|
||||||
\`\`\`"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 如果仍然没有代码变化,添加说明
|
|
||||||
if [ -z "$CODE_DIFF" ]; then
|
|
||||||
CODE_DIFF="[本次更新主要涉及配置文件和文档变更,无核心代码变化]"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Code diff preview:"
|
|
||||||
echo "$CODE_DIFF" | head -50
|
|
||||||
|
|
||||||
# 读取 prompt
|
# 读取 prompt
|
||||||
PROMPT_FILE=".github/prompt/release_note_prompt.txt"
|
PROMPT_FILE=".github/prompt/release_note_prompt.txt"
|
||||||
SYSTEM_PROMPT=$(<"$PROMPT_FILE")
|
SYSTEM_PROMPT=$(<"$PROMPT_FILE")
|
||||||
|
|
||||||
# 构建用户内容,传递更多上下文(包含文件变化和代码diff)
|
# 构建用户内容
|
||||||
USER_CONTENT="当前版本: $CURRENT_TAG
|
USER_CONTENT="当前真正的版本: $CURRENT_TAG\n提交列表:\n$COMMITS"
|
||||||
上一版本: $PREV_TAG
|
|
||||||
|
|
||||||
## 提交列表
|
# 构建请求 JSON
|
||||||
$COMMITS
|
|
||||||
|
|
||||||
## 文件变化统计
|
|
||||||
$SUMMARY_LINE
|
|
||||||
|
|
||||||
## 变更文件列表
|
|
||||||
$FILE_CHANGES
|
|
||||||
|
|
||||||
## 关键代码变化
|
|
||||||
$CODE_DIFF"
|
|
||||||
|
|
||||||
# 构建请求 JSON,增加 max_tokens 以获取更完整的输出
|
|
||||||
BODY=$(jq -n \
|
BODY=$(jq -n \
|
||||||
--arg system "$SYSTEM_PROMPT" \
|
--arg system "$SYSTEM_PROMPT" \
|
||||||
--arg user "$USER_CONTENT" \
|
--arg user "$USER_CONTENT" \
|
||||||
--arg model "$OPENROUTER_MODEL" \
|
'{model: env.OPENROUTER_MODEL, messages:[{role:"system", content:$system},{role:"user", content:$user}], temperature:0.3, max_tokens:800}')
|
||||||
'{model: $model, messages:[{role:"system", content:$system},{role:"user", content:$user}], temperature:0.2, max_tokens:1500}')
|
|
||||||
|
|
||||||
echo "=== OpenRouter request body ==="
|
echo "=== OpenRouter request body ==="
|
||||||
echo "$BODY" | jq .
|
echo "$BODY" | jq .
|
||||||
|
|
||||||
# 调用 OpenRouter
|
# 调用 OpenRouter
|
||||||
if RESPONSE=$(curl -s -X POST "$OPENROUTER_API_URL" \
|
if RESPONSE=$(curl -s -X POST "$OPENROUTER_API_URL" \
|
||||||
-H "Authorization: Bearer $OPENAI_KEY" \
|
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$BODY"); then
|
-d "$BODY"); then
|
||||||
echo "=== raw response ==="
|
echo "=== raw response ==="
|
||||||
@ -412,18 +250,13 @@ jobs:
|
|||||||
|
|
||||||
if [ -z "$RELEASE_BODY" ]; then
|
if [ -z "$RELEASE_BODY" ]; then
|
||||||
echo "❌ OpenRouter failed to generate release note, using default.md"
|
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
|
else
|
||||||
# 后处理:确保版本号正确,并添加比较链接
|
|
||||||
echo -e "$RELEASE_BODY" > CHANGELOG.md
|
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
|
fi
|
||||||
else
|
else
|
||||||
echo "❌ Curl failed, using default.md"
|
echo "❌ Curl failed, using default.md"
|
||||||
sed "s/{VERSION}/$CURRENT_TAG/g" .github/prompt/default.md > CHANGELOG.md
|
cp .github/prompt/default.md CHANGELOG.md
|
||||||
fi
|
fi
|
||||||
echo "=== generated release note ==="
|
echo "=== generated release note ==="
|
||||||
cat CHANGELOG.md
|
cat CHANGELOG.md
|
||||||
@ -438,5 +271,4 @@ jobs:
|
|||||||
NapCat.Shell.Windows.Node.zip
|
NapCat.Shell.Windows.Node.zip
|
||||||
NapCat.Framework.zip
|
NapCat.Framework.zip
|
||||||
NapCat.Shell.zip
|
NapCat.Shell.zip
|
||||||
NapCat.Shell.Windows.OneKey.zip
|
|
||||||
draft: true
|
draft: true
|
||||||
|
|||||||
@ -43,7 +43,7 @@ _Modern protocol-side framework implemented based on NTQQ._
|
|||||||
|
|
||||||
**首次使用**请务必查看如下文档看使用教程
|
**首次使用**请务必查看如下文档看使用教程
|
||||||
|
|
||||||
> 项目非盈利,涉及 对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
||||||
|
|
||||||
## Link
|
## Link
|
||||||
|
|
||||||
|
|||||||
@ -2,11 +2,6 @@ import path from 'node:path';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { QQVersionConfigType, QQLevel } from './types';
|
import { QQVersionConfigType, QQLevel } from './types';
|
||||||
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> {
|
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) => {
|
return new Promise<ReturnType<T> | undefined>((resolve) => {
|
||||||
@ -216,25 +211,3 @@ export function parseAppidFromMajor (nodeMajor: string): string | undefined {
|
|||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== GitHub Tags 获取 ==============
|
|
||||||
// 使用 mirror 模块统一管理镜像
|
|
||||||
|
|
||||||
export async function getAllTags (): Promise<{ tags: string[], mirror: string; }> {
|
|
||||||
return getAllTagsFromMirror('NapNeko', 'NapCatQQ');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export async function getLatestTag (): Promise<string> {
|
|
||||||
const { tags } = await getAllTags();
|
|
||||||
|
|
||||||
// 使用 SemVer 规范排序
|
|
||||||
tags.sort((a, b) => compareSemVer(a, b));
|
|
||||||
|
|
||||||
const latest = tags.at(-1);
|
|
||||||
if (!latest) {
|
|
||||||
throw new Error('No tags found');
|
|
||||||
}
|
|
||||||
// 去掉开头的 v
|
|
||||||
return latest.replace(/^v/, '');
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -3,11 +3,11 @@ import http from 'node:http';
|
|||||||
|
|
||||||
export class RequestUtil {
|
export class RequestUtil {
|
||||||
// 适用于获取服务器下发cookies时获取,仅GET
|
// 适用于获取服务器下发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;
|
const client = url.startsWith('https') ? https : http;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = client.get(url, (res) => {
|
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('data', () => { }); // Necessary to consume the stream
|
||||||
res.on('end', () => {
|
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.statusCode === 301 || res.statusCode === 302) {
|
||||||
if (res.headers.location) {
|
if (res.headers.location) {
|
||||||
const redirectUrl = new URL(res.headers.location, url);
|
const redirectUrl = new URL(res.headers.location, url);
|
||||||
@ -39,7 +39,7 @@ export class RequestUtil {
|
|||||||
return cookies;
|
return cookies;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string; }) {
|
private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string }) {
|
||||||
setCookieHeaders.forEach((cookie) => {
|
setCookieHeaders.forEach((cookie) => {
|
||||||
const parts = cookie.split(';')[0]?.split('=');
|
const parts = cookie.split(';')[0]?.split('=');
|
||||||
if (parts) {
|
if (parts) {
|
||||||
@ -53,10 +53,9 @@ export class RequestUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 请求和回复都是JSON data传原始内容 自动编码json
|
// 请求和回复都是JSON data传原始内容 自动编码json
|
||||||
// 支持 301/302 重定向(最多 5 次)
|
static async HttpGetJson<T>(url: string, method: string = 'GET', data?: any, headers: {
|
||||||
static async HttpGetJson<T> (url: string, method: string = 'GET', data?: any, headers: {
|
[key: string]: string
|
||||||
[key: string]: string;
|
} = {}, isJsonRet: boolean = true, isArgJson: boolean = true): Promise<T> {
|
||||||
} = {}, isJsonRet: boolean = true, isArgJson: boolean = true, maxRedirects: number = 5): Promise<T> {
|
|
||||||
const option = new URL(url);
|
const option = new URL(url);
|
||||||
const protocol = url.startsWith('https://') ? https : http;
|
const protocol = url.startsWith('https://') ? https : http;
|
||||||
const options = {
|
const options = {
|
||||||
@ -72,20 +71,6 @@ export class RequestUtil {
|
|||||||
// },
|
// },
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = protocol.request(options, (res: http.IncomingMessage) => {
|
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 = '';
|
let responseBody = '';
|
||||||
res.on('data', (chunk: string | Buffer) => {
|
res.on('data', (chunk: string | Buffer) => {
|
||||||
responseBody += chunk.toString();
|
responseBody += chunk.toString();
|
||||||
@ -124,7 +109,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);
|
return this.HttpGetJson<string>(url, method, data, headers, false, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,118 +1,2 @@
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export const napCatVersion = (typeof import.meta?.env !== 'undefined' && import.meta.env.VITE_NAPCAT_VERSION) || '1.0.0-dev';
|
export const napCatVersion = (typeof import.meta?.env !== 'undefined' && import.meta.env.VITE_NAPCAT_VERSION) || 'alpha';
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ export class NodeIDependsAdapter {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMSFSsoError (_code: number, _desc: string) {
|
onMSFSsoError (_args: unknown) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -138,7 +138,7 @@ export class NTQQFileApi {
|
|||||||
})).urlResult.domainUrl;
|
})).urlResult.domainUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadFile (filePath: string, elementType: ElementType = ElementType.PIC, elementSubType: number = 0, uploadGroupFile = true) {
|
async uploadFile (filePath: string, elementType: ElementType = ElementType.PIC, elementSubType: number = 0) {
|
||||||
const fileMd5 = await calculateFileMD5(filePath);
|
const fileMd5 = await calculateFileMD5(filePath);
|
||||||
const extOrEmpty = await fileTypeFromFile(filePath).then(e => e?.ext ?? '').catch(() => '');
|
const extOrEmpty = await fileTypeFromFile(filePath).then(e => e?.ext ?? '').catch(() => '');
|
||||||
const ext = extOrEmpty ? `.${extOrEmpty}` : '';
|
const ext = extOrEmpty ? `.${extOrEmpty}` : '';
|
||||||
@ -146,8 +146,7 @@ export class NTQQFileApi {
|
|||||||
if (fileName.indexOf('.') === -1) {
|
if (fileName.indexOf('.') === -1) {
|
||||||
fileName += ext;
|
fileName += ext;
|
||||||
}
|
}
|
||||||
const fileSize = await this.getFileSize(filePath);
|
|
||||||
if (uploadGroupFile) {
|
|
||||||
const mediaPath = this.context.session.getMsgService().getRichMediaFilePathForGuild({
|
const mediaPath = this.context.session.getMsgService().getRichMediaFilePathForGuild({
|
||||||
md5HexStr: fileMd5,
|
md5HexStr: fileMd5,
|
||||||
fileName,
|
fileName,
|
||||||
@ -160,7 +159,7 @@ export class NTQQFileApi {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.copyFile(filePath, mediaPath);
|
await this.copyFile(filePath, mediaPath);
|
||||||
|
const fileSize = await this.getFileSize(filePath);
|
||||||
return {
|
return {
|
||||||
md5: fileMd5,
|
md5: fileMd5,
|
||||||
fileName,
|
fileName,
|
||||||
@ -169,14 +168,6 @@ export class NTQQFileApi {
|
|||||||
ext,
|
ext,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
md5: fileMd5,
|
|
||||||
fileName,
|
|
||||||
path: filePath,
|
|
||||||
fileSize,
|
|
||||||
ext,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async downloadFileForModelId (peer: Peer, modelId: string, unknown: string, timeout = 1000 * 60 * 2) {
|
async downloadFileForModelId (peer: Peer, modelId: string, unknown: string, timeout = 1000 * 60 * 2) {
|
||||||
const [, fileTransNotifyInfo] = await this.core.eventWrapper.callNormalEventV2(
|
const [, fileTransNotifyInfo] = await this.core.eventWrapper.callNormalEventV2(
|
||||||
|
|||||||
36
packages/napcat-core/external/appid.json
vendored
36
packages/napcat-core/external/appid.json
vendored
@ -466,41 +466,5 @@
|
|||||||
"6.9.85-42086": {
|
"6.9.85-42086": {
|
||||||
"appid": 537320237,
|
"appid": 537320237,
|
||||||
"qua": "V1_MAC_NQ_6.9.85_42086_GW_B"
|
"qua": "V1_MAC_NQ_6.9.85_42086_GW_B"
|
||||||
},
|
|
||||||
"9.9.23-42430": {
|
|
||||||
"appid": 537320212,
|
|
||||||
"qua": "V1_WIN_NQ_9.9.23_42430_GW_B"
|
|
||||||
},
|
|
||||||
"9.9.25-42744": {
|
|
||||||
"appid": 537328470,
|
|
||||||
"qua": "V1_WIN_NQ_9.9.23_42744_GW_B"
|
|
||||||
},
|
|
||||||
"6.9.86-42744": {
|
|
||||||
"appid": 537328495,
|
|
||||||
"qua": "V1_MAC_NQ_6.9.85_42744_GW_B"
|
|
||||||
},
|
|
||||||
"9.9.25-42905": {
|
|
||||||
"appid": 537328521,
|
|
||||||
"qua": "V1_WIN_NQ_9.9.25_42905_GW_B"
|
|
||||||
},
|
|
||||||
"6.9.86-42905": {
|
|
||||||
"appid": 537328546,
|
|
||||||
"qua": "V1_MAC_NQ_6.9.86_42905_GW_B"
|
|
||||||
},
|
|
||||||
"3.2.22-42941": {
|
|
||||||
"appid": 537328659,
|
|
||||||
"qua": "V1_LNX_NQ_3.2.22_42941_GW_B"
|
|
||||||
},
|
|
||||||
"9.9.25-42941": {
|
|
||||||
"appid": 537328623,
|
|
||||||
"qua": "V1_WIN_NQ_9.9.25_42941_GW_B"
|
|
||||||
},
|
|
||||||
"6.9.86-42941": {
|
|
||||||
"appid": 537328648,
|
|
||||||
"qua": "V1_MAC_NQ_6.9.86_42941_GW_B"
|
|
||||||
},
|
|
||||||
"9.9.26-44175": {
|
|
||||||
"appid": 537336450,
|
|
||||||
"qua": "V1_WIN_NQ_9.9.26_44175_GW_B"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
40
packages/napcat-core/external/napi2native.json
vendored
40
packages/napcat-core/external/napi2native.json
vendored
@ -90,45 +90,5 @@
|
|||||||
"3.2.21-42086-x64": {
|
"3.2.21-42086-x64": {
|
||||||
"send": "5B42CF0",
|
"send": "5B42CF0",
|
||||||
"recv": "2FDA6F0"
|
"recv": "2FDA6F0"
|
||||||
},
|
|
||||||
"9.9.23-42430-x64": {
|
|
||||||
"send": "0A01A34",
|
|
||||||
"recv": "1D1CFF9"
|
|
||||||
},
|
|
||||||
"9.9.25-42744-x64": {
|
|
||||||
"send": "0A0D104",
|
|
||||||
"recv": "1D3E7F9"
|
|
||||||
},
|
|
||||||
"6.9.85-42744-arm64": {
|
|
||||||
"send": "23DFEF0",
|
|
||||||
"recv": "095FD80"
|
|
||||||
},
|
|
||||||
"9.9.25-42905-x64": {
|
|
||||||
"send": "0A12E74",
|
|
||||||
"recv": "1D450FD"
|
|
||||||
},
|
|
||||||
"6.9.86-42905-arm64": {
|
|
||||||
"send": "2342408",
|
|
||||||
"recv": "09639B8"
|
|
||||||
},
|
|
||||||
"3.2.22-42941-x64": {
|
|
||||||
"send": "5BC1630",
|
|
||||||
"recv": "3011E00"
|
|
||||||
},
|
|
||||||
"3.2.22-42941-arm64": {
|
|
||||||
"send": "3DC90AC",
|
|
||||||
"recv": "1497A70"
|
|
||||||
},
|
|
||||||
"9.9.25-42941-x64": {
|
|
||||||
"send": "0A131D4",
|
|
||||||
"recv": "1D4547D"
|
|
||||||
},
|
|
||||||
"6.9.86-42941-arm64": {
|
|
||||||
"send": "2346108",
|
|
||||||
"recv": "09675F0"
|
|
||||||
},
|
|
||||||
"9.9.26-44175-x64": {
|
|
||||||
"send": "0A0F2EC",
|
|
||||||
"recv": "1D3AD4D"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
40
packages/napcat-core/external/packet.json
vendored
40
packages/napcat-core/external/packet.json
vendored
@ -602,45 +602,5 @@
|
|||||||
"3.2.21-42086-arm64": {
|
"3.2.21-42086-arm64": {
|
||||||
"send": "6B13038",
|
"send": "6B13038",
|
||||||
"recv": "6B169C8"
|
"recv": "6B169C8"
|
||||||
},
|
|
||||||
"9.9.23-42430-x64": {
|
|
||||||
"send": "2C9A4A0",
|
|
||||||
"recv": "2C9DA20"
|
|
||||||
},
|
|
||||||
"9.9.25-42744-x64": {
|
|
||||||
"send": "2CD8E40",
|
|
||||||
"recv": "2CDC3C0"
|
|
||||||
},
|
|
||||||
"6.9.86-42744-arm64": {
|
|
||||||
"send": "3DCC840",
|
|
||||||
"recv": "3DCF150"
|
|
||||||
},
|
|
||||||
"9.9.25-42905-x64": {
|
|
||||||
"send": "2CE46A0",
|
|
||||||
"recv": "2CE7C20"
|
|
||||||
},
|
|
||||||
"6.9.86-42905-arm64": {
|
|
||||||
"send": "3DD6098",
|
|
||||||
"recv": "3DD89A8"
|
|
||||||
},
|
|
||||||
"3.2.22-42941-x64": {
|
|
||||||
"send": "A8AD8A0",
|
|
||||||
"recv": "A8B1320"
|
|
||||||
},
|
|
||||||
"9.9.25-42941-x64": {
|
|
||||||
"send": "2CE4DA0",
|
|
||||||
"recv": "2CE8320"
|
|
||||||
},
|
|
||||||
"3.2.22-42941-arm64": {
|
|
||||||
"send": "6BC95E8",
|
|
||||||
"recv": "6BCCF78"
|
|
||||||
},
|
|
||||||
"6.9.86-42941-arm64": {
|
|
||||||
"send": "3DDDAD0",
|
|
||||||
"recv": "3DE03E0"
|
|
||||||
},
|
|
||||||
"9.9.26-44175-x64": {
|
|
||||||
"send": "2CD84A0",
|
|
||||||
"recv": "2CDBA20"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -17,6 +17,7 @@ import {
|
|||||||
WrapperSessionInitConfig,
|
WrapperSessionInitConfig,
|
||||||
} from '@/napcat-core/wrapper';
|
} from '@/napcat-core/wrapper';
|
||||||
import { LogLevel, LogWrapper } from '@/napcat-core/helper/log';
|
import { LogLevel, LogWrapper } from '@/napcat-core/helper/log';
|
||||||
|
import { NodeIKernelLoginService } from '@/napcat-core/services';
|
||||||
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
||||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@ -125,7 +126,7 @@ export class NapCatCore {
|
|||||||
container.bind(TypedEventEmitter).toConstantValue(this.event);
|
container.bind(TypedEventEmitter).toConstantValue(this.event);
|
||||||
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
|
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
|
||||||
container.bind(ServiceClass).toSelf();
|
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 }) => {
|
this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => {
|
||||||
const serviceInstance = container.get(ServiceClass);
|
const serviceInstance = container.get(ServiceClass);
|
||||||
return serviceInstance.handler(seq, hex_data);
|
return serviceInstance.handler(seq, hex_data);
|
||||||
@ -277,6 +278,7 @@ export interface InstanceContext {
|
|||||||
readonly wrapper: WrapperNodeApi;
|
readonly wrapper: WrapperNodeApi;
|
||||||
readonly session: NodeIQQNTWrapperSession;
|
readonly session: NodeIQQNTWrapperSession;
|
||||||
readonly logger: LogWrapper;
|
readonly logger: LogWrapper;
|
||||||
|
readonly loginService: NodeIKernelLoginService;
|
||||||
readonly basicInfoWrapper: QQBasicInfoWrapper;
|
readonly basicInfoWrapper: QQBasicInfoWrapper;
|
||||||
readonly pathWrapper: NapCatPathWrapper;
|
readonly pathWrapper: NapCatPathWrapper;
|
||||||
readonly packetHandler: NativePacketHandler;
|
readonly packetHandler: NativePacketHandler;
|
||||||
|
|||||||
@ -3,7 +3,7 @@ export class NodeIKernelStorageCleanListener {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onScanCacheProgressChanged (_current_progress: number, _total_progress: number): any {
|
onScanCacheProgressChanged (_args: unknown): any {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -11,7 +11,7 @@ export class NodeIKernelStorageCleanListener {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onFinishScan (_sizes: Array<`${number}`>): any {
|
onFinishScan (_args: unknown): any {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,12 +15,8 @@ import { NapProtoMsg } from 'napcat-protobuf';
|
|||||||
import * as proto from '@/napcat-core/packet/transformer/proto';
|
import * as proto from '@/napcat-core/packet/transformer/proto';
|
||||||
import * as trans from '@/napcat-core/packet/transformer';
|
import * as trans from '@/napcat-core/packet/transformer';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
|
||||||
import { NapCoreContext } from '@/napcat-core/packet/context/napCoreContext';
|
import { NapCoreContext } from '@/napcat-core/packet/context/napCoreContext';
|
||||||
import { PacketClientContext } from '@/napcat-core/packet/context/clientContext';
|
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;
|
export const BlockSize = 1024 * 1024;
|
||||||
|
|
||||||
@ -109,23 +105,6 @@ export class PacketHighwayContext {
|
|||||||
if (+(video.fileSize ?? 0) > 1024 * 1024 * 100) {
|
if (+(video.fileSize ?? 0) > 1024 * 1024 * 100) {
|
||||||
throw new Error(`[Highway] 视频文件过大: ${(+(video.fileSize ?? 0) / (1024 * 1024)).toFixed(2)} MB > 100 MB,请使用文件上传!`);
|
throw new Error(`[Highway] 视频文件过大: ${(+(video.fileSize ?? 0) / (1024 * 1024)).toFixed(2)} MB > 100 MB,请使用文件上传!`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果缺少视频缩略图,自动生成一个
|
|
||||||
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) {
|
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
|
||||||
await this.uploadGroupVideo(+peer.peerUid, video);
|
await this.uploadGroupVideo(+peer.peerUid, video);
|
||||||
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
|
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
|
||||||
@ -133,65 +112,6 @@ export class PacketHighwayContext {
|
|||||||
} else {
|
} else {
|
||||||
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
|
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> {
|
async uploadPtt (peer: Peer, ptt: PacketMsgPttElement): Promise<void> {
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export class PacketMsgBuilder {
|
|||||||
|
|
||||||
buildFakeMsg (selfUid: string, element: PacketMsg[]): NapProtoEncodeStructType<typeof PushMsgBody>[] {
|
buildFakeMsg (selfUid: string, element: PacketMsg[]): NapProtoEncodeStructType<typeof PushMsgBody>[] {
|
||||||
return element.map((node): NapProtoEncodeStructType<typeof PushMsgBody> => {
|
return element.map((node): NapProtoEncodeStructType<typeof PushMsgBody> => {
|
||||||
const avatar = `https://q.qlogo.cn/headimg_dl?dst_uin=${node.senderUin}&spec=0&img_type=jpg`;
|
const avatar = `https://q.qlogo.cn/headimg_dl?dst_uin=${node.senderUin}&spec=640&img_type=jpg`;
|
||||||
const msgContent = node.msg.reduceRight((acc: undefined | Uint8Array, msg: IPacketMsgElement<PacketSendMsgElement>) => {
|
const msgContent = node.msg.reduceRight((acc: undefined | Uint8Array, msg: IPacketMsgElement<PacketSendMsgElement>) => {
|
||||||
return acc ?? msg.buildContent();
|
return acc ?? msg.buildContent();
|
||||||
}, undefined);
|
}, undefined);
|
||||||
|
|||||||
@ -510,15 +510,15 @@ export class PacketMsgPttElement extends IPacketMsgElement<SendPttElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
|
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
|
||||||
//return [];
|
return [];
|
||||||
if (!this.msgInfo) return [];
|
// if (!this.msgInfo) return [];
|
||||||
return [{
|
// return [{
|
||||||
commonElem: {
|
// commonElem: {
|
||||||
serviceType: 48,
|
// serviceType: 48,
|
||||||
pbElem: new NapProtoMsg(MsgInfo).encode(this.msgInfo),
|
// pbElem: new NapProtoMsg(MsgInfo).encode(this.msgInfo),
|
||||||
businessType: 22,
|
// businessType: 22,
|
||||||
}
|
// }
|
||||||
}];
|
// }];
|
||||||
}
|
}
|
||||||
|
|
||||||
override toPreview (): string {
|
override toPreview (): string {
|
||||||
|
|||||||
@ -3,56 +3,39 @@ import { GeneralCallResult } from './common';
|
|||||||
|
|
||||||
export interface NodeIKernelStorageCleanService {
|
export interface NodeIKernelStorageCleanService {
|
||||||
|
|
||||||
addKernelStorageCleanListener (listener: NodeIKernelStorageCleanListener): number;
|
addKernelStorageCleanListener(listener: NodeIKernelStorageCleanListener): number;
|
||||||
|
|
||||||
removeKernelStorageCleanListener (listenerId: number): void;
|
removeKernelStorageCleanListener(listenerId: number): void;
|
||||||
// [
|
|
||||||
// "hotUpdate",
|
|
||||||
// [
|
|
||||||
// "C:\\Users\\nanaeo\\AppData\\Roaming\\QQ\\packages"
|
|
||||||
// ]
|
|
||||||
// ],
|
|
||||||
// [
|
|
||||||
// "tmp",
|
|
||||||
// [
|
|
||||||
// "C:\\Users\\nanaeo\\AppData\\Roaming\\QQ\\tmp"
|
|
||||||
// ]
|
|
||||||
// ],
|
|
||||||
// [
|
|
||||||
// "SilentCacheappSessionPartation9212",
|
|
||||||
// [
|
|
||||||
// "C:\\Users\\nanaeo\\AppData\\Roaming\\QQ\\Partitions\\qqnt_9212"
|
|
||||||
// ]
|
|
||||||
// ]
|
|
||||||
addCacheScanedPaths (paths: Map<`tmp` | `SilentCacheappSessionPartation9212` | `hotUpdate`, unknown>): unknown;
|
|
||||||
|
|
||||||
addFilesScanedPaths (arg: unknown): unknown;
|
addCacheScanedPaths(arg: unknown): unknown;
|
||||||
|
|
||||||
scanCache (): Promise<GeneralCallResult & {
|
addFilesScanedPaths(arg: unknown): unknown;
|
||||||
size: string[];
|
|
||||||
|
scanCache(): Promise<GeneralCallResult & {
|
||||||
|
size: string[]
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
addReportData (arg: unknown): unknown;
|
addReportData(arg: unknown): unknown;
|
||||||
|
|
||||||
reportData (): unknown;
|
reportData(): unknown;
|
||||||
|
|
||||||
getChatCacheInfo (arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown): unknown;
|
getChatCacheInfo(arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown): unknown;
|
||||||
|
|
||||||
getFileCacheInfo (arg1: unknown, arg2: unknown, arg3: unknown, arg44: unknown, args5: unknown): unknown;
|
getFileCacheInfo(arg1: unknown, arg2: unknown, arg3: unknown, arg44: unknown, args5: unknown): unknown;
|
||||||
|
|
||||||
clearChatCacheInfo (arg1: unknown, arg2: unknown): unknown;
|
clearChatCacheInfo(arg1: unknown, arg2: unknown): unknown;
|
||||||
|
|
||||||
clearCacheDataByKeys (keys: Array<string>): Promise<GeneralCallResult>;
|
clearCacheDataByKeys(arg: unknown): unknown;
|
||||||
|
|
||||||
setSilentScan (is_silent: boolean): unknown;
|
setSilentScan(arg: unknown): unknown;
|
||||||
|
|
||||||
closeCleanWindow (): unknown;
|
closeCleanWindow(): unknown;
|
||||||
|
|
||||||
clearAllChatCacheInfo (): unknown;
|
clearAllChatCacheInfo(): unknown;
|
||||||
|
|
||||||
endScan (arg: unknown): unknown;
|
endScan(arg: unknown): unknown;
|
||||||
|
|
||||||
addNewDownloadOrUploadFile (arg: unknown): unknown;
|
addNewDownloadOrUploadFile(arg: unknown): unknown;
|
||||||
|
|
||||||
isNull (): boolean;
|
isNull(): boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,8 +8,6 @@ import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
|||||||
import { InstanceContext, loadQQWrapper, NapCatCore, NapCatCoreWorkingEnv, NodeIKernelLoginListener, NodeIKernelLoginService, NodeIQQNTWrapperSession, SelfInfo, WrapperNodeApi } from '@/napcat-core';
|
import { InstanceContext, loadQQWrapper, NapCatCore, NapCatCoreWorkingEnv, NodeIKernelLoginListener, NodeIKernelLoginService, NodeIQQNTWrapperSession, SelfInfo, WrapperNodeApi } from '@/napcat-core';
|
||||||
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
||||||
import { statusHelperSubscription } from '@/napcat-core/helper/status';
|
import { statusHelperSubscription } from '@/napcat-core/helper/status';
|
||||||
import { applyPendingUpdates } from '@/napcat-webui-backend/src/api/UpdateNapCat';
|
|
||||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
|
||||||
|
|
||||||
// Framework ES入口文件
|
// Framework ES入口文件
|
||||||
export async function getWebUiUrl () {
|
export async function getWebUiUrl () {
|
||||||
@ -34,9 +32,7 @@ export async function NCoreInitFramework (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pathWrapper = new NapCatPathWrapper();
|
const pathWrapper = new NapCatPathWrapper();
|
||||||
|
|
||||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||||
await applyPendingUpdates(pathWrapper, logger);
|
|
||||||
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
||||||
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
|
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
|
||||||
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
|
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
|
||||||
@ -73,17 +69,13 @@ export async function NCoreInitFramework (
|
|||||||
// 过早进入会导致addKernelMsgListener等Listener添加失败
|
// 过早进入会导致addKernelMsgListener等Listener添加失败
|
||||||
// await sleep(2500);
|
// await sleep(2500);
|
||||||
// 初始化 NapCatFramework
|
// 初始化 NapCatFramework
|
||||||
const loaderObject = new NapCatFramework(wrapper, session, logger, selfInfo, basicInfoWrapper, pathWrapper, nativePacketHandler);
|
const loaderObject = new NapCatFramework(wrapper, session, logger, loginService, selfInfo, basicInfoWrapper, pathWrapper, nativePacketHandler);
|
||||||
await loaderObject.core.initCore();
|
await loaderObject.core.initCore();
|
||||||
|
|
||||||
// 启动WebUi
|
// 启动WebUi
|
||||||
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework);
|
|
||||||
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
||||||
// 初始化LLNC的Onebot实现
|
// 初始化LLNC的Onebot实现
|
||||||
const oneBotAdapter = new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper);
|
await new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper).InitOneBot();
|
||||||
// 注册到 WebUiDataRuntime,供调试功能使用
|
|
||||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
|
||||||
await oneBotAdapter.InitOneBot();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NapCatFramework {
|
export class NapCatFramework {
|
||||||
@ -94,6 +86,7 @@ export class NapCatFramework {
|
|||||||
wrapper: WrapperNodeApi,
|
wrapper: WrapperNodeApi,
|
||||||
session: NodeIQQNTWrapperSession,
|
session: NodeIQQNTWrapperSession,
|
||||||
logger: LogWrapper,
|
logger: LogWrapper,
|
||||||
|
loginService: NodeIKernelLoginService,
|
||||||
selfInfo: SelfInfo,
|
selfInfo: SelfInfo,
|
||||||
basicInfoWrapper: QQBasicInfoWrapper,
|
basicInfoWrapper: QQBasicInfoWrapper,
|
||||||
pathWrapper: NapCatPathWrapper,
|
pathWrapper: NapCatPathWrapper,
|
||||||
@ -105,6 +98,7 @@ export class NapCatFramework {
|
|||||||
wrapper,
|
wrapper,
|
||||||
session,
|
session,
|
||||||
logger,
|
logger,
|
||||||
|
loginService,
|
||||||
basicInfoWrapper,
|
basicInfoWrapper,
|
||||||
pathWrapper,
|
pathWrapper,
|
||||||
};
|
};
|
||||||
|
|||||||
Binary file not shown.
@ -6,23 +6,23 @@ import { Static, Type } from '@sinclair/typebox';
|
|||||||
const SchemaData = Type.Object({
|
const SchemaData = Type.Object({
|
||||||
user_id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
|
user_id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
|
||||||
group_id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
|
group_id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
|
||||||
phone_number: Type.String({ default: '' }),
|
phoneNumber: Type.String({ default: '' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
type Payload = Static<typeof SchemaData>;
|
type Payload = Static<typeof SchemaData>;
|
||||||
|
|
||||||
export class SharePeerBase extends OneBotAction<Payload, GeneralCallResult & {
|
export class SharePeer extends OneBotAction<Payload, GeneralCallResult & {
|
||||||
arkMsg?: string;
|
arkMsg?: string;
|
||||||
arkJson?: string;
|
arkJson?: string;
|
||||||
}> {
|
}> {
|
||||||
|
override actionName = ActionName.SharePeer;
|
||||||
override payloadSchema = SchemaData;
|
override payloadSchema = SchemaData;
|
||||||
|
|
||||||
async _handle (payload: Payload) {
|
async _handle (payload: Payload) {
|
||||||
if (payload.group_id) {
|
if (payload.group_id) {
|
||||||
return await this.core.apis.GroupApi.getGroupRecommendContactArkJson(payload.group_id.toString());
|
return await this.core.apis.GroupApi.getGroupRecommendContactArkJson(payload.group_id.toString());
|
||||||
} else if (payload.user_id) {
|
} else if (payload.user_id) {
|
||||||
return await this.core.apis.UserApi.getBuddyRecommendContactArkJson(payload.user_id.toString(), payload.phone_number);
|
return await this.core.apis.UserApi.getBuddyRecommendContactArkJson(payload.user_id.toString(), payload.phoneNumber);
|
||||||
}
|
}
|
||||||
throw new Error('group_id or user_id is required');
|
throw new Error('group_id or user_id is required');
|
||||||
}
|
}
|
||||||
@ -31,25 +31,14 @@ export class SharePeerBase extends OneBotAction<Payload, GeneralCallResult & {
|
|||||||
const SchemaDataGroupEx = Type.Object({
|
const SchemaDataGroupEx = Type.Object({
|
||||||
group_id: Type.Union([Type.Number(), Type.String()]),
|
group_id: Type.Union([Type.Number(), Type.String()]),
|
||||||
});
|
});
|
||||||
export class SharePeer extends SharePeerBase {
|
|
||||||
override actionName = ActionName.SharePeer;
|
|
||||||
}
|
|
||||||
type PayloadGroupEx = Static<typeof SchemaDataGroupEx>;
|
type PayloadGroupEx = Static<typeof SchemaDataGroupEx>;
|
||||||
|
|
||||||
export class ShareGroupExBase extends OneBotAction<PayloadGroupEx, string> {
|
export class ShareGroupEx extends OneBotAction<PayloadGroupEx, string> {
|
||||||
|
override actionName = ActionName.ShareGroupEx;
|
||||||
override payloadSchema = SchemaDataGroupEx;
|
override payloadSchema = SchemaDataGroupEx;
|
||||||
|
|
||||||
async _handle (payload: PayloadGroupEx) {
|
async _handle (payload: PayloadGroupEx) {
|
||||||
return await this.core.apis.GroupApi.getArkJsonGroupShare(payload.group_id.toString());
|
return await this.core.apis.GroupApi.getArkJsonGroupShare(payload.group_id.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class ShareGroupEx extends ShareGroupExBase {
|
|
||||||
override actionName = ActionName.ShareGroupEx;
|
|
||||||
}
|
|
||||||
export class SendGroupArkShare extends ShareGroupExBase {
|
|
||||||
override actionName = ActionName.SendGroupArkShare;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SendArkShare extends SharePeerBase {
|
|
||||||
override actionName = ActionName.SendArkShare;
|
|
||||||
}
|
|
||||||
@ -100,7 +100,7 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
|
|||||||
|
|
||||||
// 3. 定义协议回退逻辑函数
|
// 3. 定义协议回退逻辑函数
|
||||||
const protocolFallbackLogic = async (resId: string) => {
|
const protocolFallbackLogic = async (resId: string) => {
|
||||||
const ob = (await this.obContext.apis.MsgApi.parseMessageV2(createFakeForwardMsg(resId), true))?.arrayMsg;
|
const ob = (await this.obContext.apis.MsgApi.parseMessageV2(createFakeForwardMsg(resId)))?.arrayMsg;
|
||||||
if (ob) {
|
if (ob) {
|
||||||
return {
|
return {
|
||||||
messages: (ob?.message?.[0] as OB11MessageForward)?.data?.content,
|
messages: (ob?.message?.[0] as OB11MessageForward)?.data?.content,
|
||||||
@ -122,7 +122,7 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction<Payload, {
|
|||||||
|
|
||||||
if (rootMsg) {
|
if (rootMsg) {
|
||||||
// 5. 获取消息内容
|
// 5. 获取消息内容
|
||||||
const data = await this.core.apis.MsgApi.getMsgHistory(rootMsg.Peer, rootMsg.MsgId, 1);//getMsgsIncludeSelf
|
const data = await this.core.apis.MsgApi.getMsgsByMsgId(rootMsg.Peer, [rootMsg.MsgId]);
|
||||||
|
|
||||||
if (data && data.result === 0 && data.msgList.length > 0) {
|
if (data && data.result === 0 && data.msgList.length > 0) {
|
||||||
const singleMsg = data.msgList[0];
|
const singleMsg = data.msgList[0];
|
||||||
|
|||||||
@ -14,11 +14,10 @@ const SchemaData = Type.Object({
|
|||||||
user_id: Type.String(),
|
user_id: Type.String(),
|
||||||
message_seq: Type.Optional(Type.String()),
|
message_seq: Type.Optional(Type.String()),
|
||||||
count: Type.Number({ default: 20 }),
|
count: Type.Number({ default: 20 }),
|
||||||
reverse_order: Type.Boolean({ default: false }),
|
reverseOrder: Type.Boolean({ default: false }),
|
||||||
disable_get_url: Type.Boolean({ default: false }),
|
disable_get_url: Type.Boolean({ default: false }),
|
||||||
parse_mult_msg: Type.Boolean({ default: true }),
|
parse_mult_msg: Type.Boolean({ default: true }),
|
||||||
quick_reply: Type.Boolean({ default: false }),
|
quick_reply: Type.Boolean({ default: false }),
|
||||||
reverseOrder: Type.Boolean({ default: false }),// @deprecated 兼容旧版本
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type Payload = Static<typeof SchemaData>;
|
type Payload = Static<typeof SchemaData>;
|
||||||
@ -36,7 +35,7 @@ export default class GetFriendMsgHistory extends OneBotAction<Payload, Response>
|
|||||||
const hasMessageSeq = !payload.message_seq ? !!payload.message_seq : !(payload.message_seq?.toString() === '' || payload.message_seq?.toString() === '0');
|
const hasMessageSeq = !payload.message_seq ? !!payload.message_seq : !(payload.message_seq?.toString() === '' || payload.message_seq?.toString() === '0');
|
||||||
const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0';
|
const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0';
|
||||||
const msgList = hasMessageSeq
|
const msgList = hasMessageSeq
|
||||||
? (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverse_order || payload.reverseOrder)).msgList
|
? (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverseOrder)).msgList
|
||||||
: (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList;
|
: (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList;
|
||||||
if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`);
|
if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`);
|
||||||
// 转换序号
|
// 转换序号
|
||||||
|
|||||||
@ -14,11 +14,10 @@ const SchemaData = Type.Object({
|
|||||||
group_id: Type.String(),
|
group_id: Type.String(),
|
||||||
message_seq: Type.Optional(Type.String()),
|
message_seq: Type.Optional(Type.String()),
|
||||||
count: Type.Number({ default: 20 }),
|
count: Type.Number({ default: 20 }),
|
||||||
reverse_order: Type.Boolean({ default: false }),
|
reverseOrder: Type.Boolean({ default: false }),
|
||||||
disable_get_url: Type.Boolean({ default: false }),
|
disable_get_url: Type.Boolean({ default: false }),
|
||||||
parse_mult_msg: Type.Boolean({ default: true }),
|
parse_mult_msg: Type.Boolean({ default: true }),
|
||||||
quick_reply: Type.Boolean({ default: false }),
|
quick_reply: Type.Boolean({ default: false }),
|
||||||
reverseOrder: Type.Boolean({ default: false }),// @deprecated 兼容旧版本
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type Payload = Static<typeof SchemaData>;
|
type Payload = Static<typeof SchemaData>;
|
||||||
@ -33,7 +32,7 @@ export default class GoCQHTTPGetGroupMsgHistory extends OneBotAction<Payload, Re
|
|||||||
// 拉取消息
|
// 拉取消息
|
||||||
const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0';
|
const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0';
|
||||||
const msgList = hasMessageSeq
|
const msgList = hasMessageSeq
|
||||||
? (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverse_order || payload.reverseOrder)).msgList
|
? (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverseOrder)).msgList
|
||||||
: (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList;
|
: (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList;
|
||||||
if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`);
|
if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`);
|
||||||
// 转换序号
|
// 转换序号
|
||||||
|
|||||||
@ -12,7 +12,6 @@ const SchemaData = Type.Object({
|
|||||||
name: Type.String(),
|
name: Type.String(),
|
||||||
folder: Type.Optional(Type.String()),
|
folder: Type.Optional(Type.String()),
|
||||||
folder_id: Type.Optional(Type.String()), // 临时扩展
|
folder_id: Type.Optional(Type.String()), // 临时扩展
|
||||||
upload_file: Type.Boolean({ default: true }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type Payload = Static<typeof SchemaData>;
|
type Payload = Static<typeof SchemaData>;
|
||||||
@ -42,7 +41,7 @@ export default class GoCQHTTPUploadGroupFile extends OneBotAction<Payload, Uploa
|
|||||||
peer,
|
peer,
|
||||||
deleteAfterSentFiles: [],
|
deleteAfterSentFiles: [],
|
||||||
};
|
};
|
||||||
const sendFileEle = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id, payload.upload_file);
|
const sendFileEle = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id);
|
||||||
msgContext.deleteAfterSentFiles.push(downloadResult.path);
|
msgContext.deleteAfterSentFiles.push(downloadResult.path);
|
||||||
const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles);
|
const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles);
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,6 @@ const SchemaData = Type.Object({
|
|||||||
user_id: Type.Union([Type.Number(), Type.String()]),
|
user_id: Type.Union([Type.Number(), Type.String()]),
|
||||||
file: Type.String(),
|
file: Type.String(),
|
||||||
name: Type.String(),
|
name: Type.String(),
|
||||||
upload_file: Type.Boolean({ default: true }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type Payload = Static<typeof SchemaData>;
|
type Payload = Static<typeof SchemaData>;
|
||||||
@ -52,7 +51,7 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, Upl
|
|||||||
}, ContextMode.Private),
|
}, ContextMode.Private),
|
||||||
deleteAfterSentFiles: [],
|
deleteAfterSentFiles: [],
|
||||||
};
|
};
|
||||||
const sendFileEle: SendFileElement = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, '', payload.upload_file);
|
const sendFileEle: SendFileElement = await this.obContext.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name);
|
||||||
msgContext.deleteAfterSentFiles.push(downloadResult.path);
|
msgContext.deleteAfterSentFiles.push(downloadResult.path);
|
||||||
const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(await this.getPeer(payload), [sendFileEle], msgContext.deleteAfterSentFiles);
|
const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(await this.getPeer(payload), [sendFileEle], msgContext.deleteAfterSentFiles);
|
||||||
|
|
||||||
|
|||||||
@ -54,7 +54,7 @@ import { GetOnlineClient } from './go-cqhttp/GetOnlineClient';
|
|||||||
import { IOCRImage, OCRImage } from './extends/OCRImage';
|
import { IOCRImage, OCRImage } from './extends/OCRImage';
|
||||||
import { TranslateEnWordToZn } from './extends/TranslateEnWordToZn';
|
import { TranslateEnWordToZn } from './extends/TranslateEnWordToZn';
|
||||||
import { SetQQProfile } from './go-cqhttp/SetQQProfile';
|
import { SetQQProfile } from './go-cqhttp/SetQQProfile';
|
||||||
import { SendArkShare, SendGroupArkShare, ShareGroupEx, SharePeer } from './extends/ShareContact';
|
import { ShareGroupEx, SharePeer } from './extends/ShareContact';
|
||||||
import { CreateCollection } from './extends/CreateCollection';
|
import { CreateCollection } from './extends/CreateCollection';
|
||||||
import { SetLongNick } from './extends/SetLongNick';
|
import { SetLongNick } from './extends/SetLongNick';
|
||||||
import DelEssenceMsg from './group/DelEssenceMsg';
|
import DelEssenceMsg from './group/DelEssenceMsg';
|
||||||
@ -170,8 +170,6 @@ export function createActionMap (obContext: NapCatOneBot11Adapter, core: NapCatC
|
|||||||
new SetQQProfile(obContext, core),
|
new SetQQProfile(obContext, core),
|
||||||
new ShareGroupEx(obContext, core),
|
new ShareGroupEx(obContext, core),
|
||||||
new SharePeer(obContext, core),
|
new SharePeer(obContext, core),
|
||||||
new SendGroupArkShare(obContext, core),
|
|
||||||
new SendArkShare(obContext, core),
|
|
||||||
new CreateCollection(obContext, core),
|
new CreateCollection(obContext, core),
|
||||||
new SetLongNick(obContext, core),
|
new SetLongNick(obContext, core),
|
||||||
new ForwardFriendSingleMsg(obContext, core),
|
new ForwardFriendSingleMsg(obContext, core),
|
||||||
|
|||||||
@ -317,11 +317,11 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
|
|||||||
|
|
||||||
const MixElement = sendElements.filter(
|
const MixElement = sendElements.filter(
|
||||||
element =>
|
element =>
|
||||||
element.elementType !== ElementType.FILE && element.elementType !== ElementType.VIDEO && element.elementType !== ElementType.ARK && element.elementType !== ElementType.PTT
|
element.elementType !== ElementType.FILE && element.elementType !== ElementType.VIDEO && element.elementType !== ElementType.ARK
|
||||||
);
|
);
|
||||||
const SingleElement = sendElements.filter(
|
const SingleElement = sendElements.filter(
|
||||||
element =>
|
element =>
|
||||||
element.elementType === ElementType.FILE || element.elementType === ElementType.VIDEO || element.elementType === ElementType.ARK || element.elementType === ElementType.PTT
|
element.elementType === ElementType.FILE || element.elementType === ElementType.VIDEO || element.elementType === ElementType.ARK
|
||||||
).map(e => [e]);
|
).map(e => [e]);
|
||||||
|
|
||||||
const AllElement: SendMessageElement[][] = [MixElement, ...SingleElement].filter(e => e !== undefined && e.length !== 0);
|
const AllElement: SendMessageElement[][] = [MixElement, ...SingleElement].filter(e => e !== undefined && e.length !== 0);
|
||||||
|
|||||||
@ -125,11 +125,8 @@ export const ActionName = {
|
|||||||
// 以下为扩展napcat扩展
|
// 以下为扩展napcat扩展
|
||||||
Unknown: 'unknown',
|
Unknown: 'unknown',
|
||||||
SetDiyOnlineStatus: 'set_diy_online_status',
|
SetDiyOnlineStatus: 'set_diy_online_status',
|
||||||
SharePeer: 'ArkSharePeer',// @deprecated
|
SharePeer: 'ArkSharePeer',
|
||||||
ShareGroupEx: 'ArkShareGroup',// @deprecated
|
ShareGroupEx: 'ArkShareGroup',
|
||||||
// 标准化接口
|
|
||||||
SendGroupArkShare: 'send_group_ark_share',
|
|
||||||
SendArkShare: 'send_ark_share',
|
|
||||||
// RebootNormal : 'reboot_normal', //无快速登录重新启动
|
// RebootNormal : 'reboot_normal', //无快速登录重新启动
|
||||||
GetRobotUinRange: 'get_robot_uin_range',
|
GetRobotUinRange: 'get_robot_uin_range',
|
||||||
SetOnlineStatus: 'set_online_status',
|
SetOnlineStatus: 'set_online_status',
|
||||||
|
|||||||
@ -19,18 +19,16 @@ export class OneBotFileApi {
|
|||||||
this.core = core;
|
this.core = core;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createValidSendFileElement (context: SendMessageContext, filePath: string, fileName: string = '', folderId: string = '', uploadGroupFile: boolean = false): Promise<SendFileElement> {
|
async createValidSendFileElement (context: SendMessageContext, filePath: string, fileName: string = '', folderId: string = ''): Promise<SendFileElement> {
|
||||||
const {
|
const {
|
||||||
fileName: _fileName,
|
fileName: _fileName,
|
||||||
path,
|
path,
|
||||||
fileSize,
|
fileSize,
|
||||||
} = await this.core.apis.FileApi.uploadFile(filePath, ElementType.FILE, 0, uploadGroupFile);
|
} = await this.core.apis.FileApi.uploadFile(filePath, ElementType.FILE);
|
||||||
if (fileSize === 0) {
|
if (fileSize === 0) {
|
||||||
throw new Error('文件异常,大小为0');
|
throw new Error('文件异常,大小为0');
|
||||||
}
|
}
|
||||||
if (uploadGroupFile) {
|
|
||||||
context.deleteAfterSentFiles.push(path);
|
context.deleteAfterSentFiles.push(path);
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
elementType: ElementType.FILE,
|
elementType: ElementType.FILE,
|
||||||
elementId: '',
|
elementId: '',
|
||||||
|
|||||||
@ -19,7 +19,6 @@ import { OB11GroupCardEvent } from '@/napcat-onebot/event/notice/OB11GroupCardEv
|
|||||||
import { OB11GroupPokeEvent } from '@/napcat-onebot/event/notice/OB11PokeEvent';
|
import { OB11GroupPokeEvent } from '@/napcat-onebot/event/notice/OB11PokeEvent';
|
||||||
import { OB11GroupEssenceEvent } from '@/napcat-onebot/event/notice/OB11GroupEssenceEvent';
|
import { OB11GroupEssenceEvent } from '@/napcat-onebot/event/notice/OB11GroupEssenceEvent';
|
||||||
import { OB11GroupTitleEvent } from '@/napcat-onebot/event/notice/OB11GroupTitleEvent';
|
import { OB11GroupTitleEvent } from '@/napcat-onebot/event/notice/OB11GroupTitleEvent';
|
||||||
import { OB11GroupGrayTipEvent } from '@/napcat-onebot/event/notice/OB11GroupGrayTipEvent';
|
|
||||||
import { OB11GroupUploadNoticeEvent } from '../event/notice/OB11GroupUploadNoticeEvent';
|
import { OB11GroupUploadNoticeEvent } from '../event/notice/OB11GroupUploadNoticeEvent';
|
||||||
import { OB11GroupNameEvent } from '../event/notice/OB11GroupNameEvent';
|
import { OB11GroupNameEvent } from '../event/notice/OB11GroupNameEvent';
|
||||||
import { FileNapCatOneBotUUID } from 'napcat-common/src/file-uuid';
|
import { FileNapCatOneBotUUID } from 'napcat-common/src/file-uuid';
|
||||||
@ -175,6 +174,7 @@ export class OneBotGroupApi {
|
|||||||
|
|
||||||
async registerParseGroupReactEventByCore () {
|
async registerParseGroupReactEventByCore () {
|
||||||
this.core.event.on('event:emoji_like', async (data) => {
|
this.core.event.on('event:emoji_like', async (data) => {
|
||||||
|
console.log('Received emoji_like event from core:', data);
|
||||||
const event = await this.createGroupEmojiLikeEvent(
|
const event = await this.createGroupEmojiLikeEvent(
|
||||||
data.groupId,
|
data.groupId,
|
||||||
data.senderUin,
|
data.senderUin,
|
||||||
@ -207,24 +207,15 @@ export class OneBotGroupApi {
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
async parseOtherJsonEvent (msg: RawMessage, jsonGrayTipElement: GrayTipElement['jsonGrayTipElement'], context: InstanceContext) {
|
|
||||||
let json: { items?: { txt?: string; param?: string[] }[] };
|
async parseOtherJsonEvent (msg: RawMessage, jsonStr: string, context: InstanceContext) {
|
||||||
try {
|
const json = JSON.parse(jsonStr);
|
||||||
json = JSON.parse(jsonGrayTipElement.jsonStr);
|
const type = json.items[json.items.length - 1]?.txt;
|
||||||
} 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);
|
await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid);
|
||||||
if (type === '头衔') {
|
if (type === '头衔') {
|
||||||
const memberUin = json.items?.[1]?.param?.[0];
|
const memberUin = json.items[1].param[0];
|
||||||
const title = json.items?.[3]?.txt;
|
const title = json.items[3].txt;
|
||||||
context.logger.logDebug('收到群成员新头衔消息', json);
|
context.logger.logDebug('收到群成员新头衔消息', json);
|
||||||
if (memberUin == null || title == null) {
|
|
||||||
context.logger.logWarn('收到格式异常的群成员新头衔灰条消息', json);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return new OB11GroupTitleEvent(
|
return new OB11GroupTitleEvent(
|
||||||
this.core,
|
this.core,
|
||||||
+msg.peerUid,
|
+msg.peerUid,
|
||||||
@ -235,27 +226,6 @@ export class OneBotGroupApi {
|
|||||||
context.logger.logDebug('收到机器人被踢消息', json);
|
context.logger.logDebug('收到机器人被踢消息', json);
|
||||||
} else {
|
} else {
|
||||||
context.logger.logWarn('收到未知的灰条消息', json);
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -407,7 +377,7 @@ export class OneBotGroupApi {
|
|||||||
return await this.parse51TypeEvent(msg, grayTipElement);
|
return await this.parse51TypeEvent(msg, grayTipElement);
|
||||||
} else {
|
} else {
|
||||||
console.log('Unknown JSON event:', grayTipElement.jsonGrayTipElement, JSON.stringify(grayTipElement));
|
console.log('Unknown JSON event:', grayTipElement.jsonGrayTipElement, JSON.stringify(grayTipElement));
|
||||||
return await this.parseOtherJsonEvent(msg, grayTipElement.jsonGrayTipElement, this.core.context);
|
return await this.parseOtherJsonEvent(msg, grayTipElement.jsonGrayTipElement.jsonStr, this.core.context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@ -749,31 +749,26 @@ export class OneBotMsgApi {
|
|||||||
|
|
||||||
[OB11MessageDataType.music]: async ({ data }, context) => {
|
[OB11MessageDataType.music]: async ({ data }, context) => {
|
||||||
// 保留, 直到...找到更好的解决方案
|
// 保留, 直到...找到更好的解决方案
|
||||||
const supportedPlatforms = ['qq', '163', 'kugou', 'kuwo', 'migu'];
|
|
||||||
const supportedPlatformsWithCustom = [...supportedPlatforms, 'custom'];
|
|
||||||
|
|
||||||
// 验证音乐类型
|
|
||||||
if (data.id !== undefined) {
|
if (data.id !== undefined) {
|
||||||
if (!supportedPlatforms.includes(data.type)) {
|
if (!['qq', '163', 'kugou', 'kuwo', 'migu'].includes(data.type)) {
|
||||||
this.core.context.logger.logError(`[音乐卡片] type参数错误: "${data.type}",仅支持: ${supportedPlatforms.join('、')}`);
|
this.core.context.logger.logError('音乐卡片type错误, 只支持qq、163、kugou、kuwo、migu,当前type:', data.type);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!supportedPlatformsWithCustom.includes(data.type)) {
|
if (!['qq', '163', 'kugou', 'kuwo', 'migu', 'custom'].includes(data.type)) {
|
||||||
this.core.context.logger.logError(`[音乐卡片] type参数错误: "${data.type}",仅支持: ${supportedPlatformsWithCustom.join('、')}`);
|
this.core.context.logger.logError('音乐卡片type错误, 只支持qq、163、kugou、kuwo、migu、custom,当前type:', data.type);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (!data.url) {
|
if (!data.url) {
|
||||||
this.core.context.logger.logError('[音乐卡片] 自定义音乐卡片缺少必需参数: url');
|
this.core.context.logger.logError('自定义音卡缺少参数url');
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (!data.image) {
|
if (!data.image) {
|
||||||
this.core.context.logger.logError('[音乐卡片] 自定义音乐卡片缺少必需参数: image');
|
this.core.context.logger.logError('自定义音卡缺少参数image');
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建请求数据
|
|
||||||
let postData: IdMusicSignPostData | CustomMusicSignPostData;
|
let postData: IdMusicSignPostData | CustomMusicSignPostData;
|
||||||
if (data.id === undefined && data.content) {
|
if (data.id === undefined && data.content) {
|
||||||
const { content, ...others } = data;
|
const { content, ...others } = data;
|
||||||
@ -781,14 +776,11 @@ export class OneBotMsgApi {
|
|||||||
} else {
|
} else {
|
||||||
postData = data;
|
postData = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取签名服务地址
|
|
||||||
let signUrl = this.obContext.configLoader.configData.musicSignUrl;
|
let signUrl = this.obContext.configLoader.configData.musicSignUrl;
|
||||||
if (!signUrl) {
|
if (!signUrl) {
|
||||||
signUrl = 'https://ss.xingzhige.com/music_card/card';// 感谢思思!已获思思许可 其余地方使用请自行询问
|
signUrl = 'https://ss.xingzhige.com/music_card/card';// 感谢思思!已获思思许可 其余地方使用请自行询问
|
||||||
|
// throw Error('音乐消息签名地址未配置');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求签名服务
|
|
||||||
try {
|
try {
|
||||||
const musicJson = await RequestUtil.HttpGetJson<string>(signUrl, 'POST', postData);
|
const musicJson = await RequestUtil.HttpGetJson<string>(signUrl, 'POST', postData);
|
||||||
return this.ob11ToRawConverters.json({
|
return this.ob11ToRawConverters.json({
|
||||||
@ -796,16 +788,9 @@ export class OneBotMsgApi {
|
|||||||
type: OB11MessageDataType.json,
|
type: OB11MessageDataType.json,
|
||||||
}, context);
|
}, context);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
this.core.context.logger.logError('生成音乐消息失败', 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,
|
[OB11MessageDataType.node]: async () => undefined,
|
||||||
@ -984,20 +969,8 @@ export class OneBotMsgApi {
|
|||||||
disableGetUrl: boolean = false,
|
disableGetUrl: boolean = false,
|
||||||
quick_reply: boolean = false
|
quick_reply: boolean = false
|
||||||
) {
|
) {
|
||||||
if ((msg.senderUin === '0' || msg.senderUin === '')) {
|
if (msg.senderUin === '0' || msg.senderUin === '') return;
|
||||||
if (msg.senderUid && msg.senderUid !== '' && msg.senderUid !== '0') {
|
if (msg.peerUin === '0' || msg.peerUin === '') return;
|
||||||
msg.senderUin = await this.core.apis.UserApi.getUinByUidV2(msg.senderUid);
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (msg.peerUin === '0' || msg.peerUin === '') {
|
|
||||||
if (msg.peerUid && msg.peerUid !== '' && msg.peerUid !== '0') {
|
|
||||||
msg.peerUin = await this.core.apis.UserApi.getUinByUidV2(msg.peerUid);
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resMsg = this.initializeMessage(msg);
|
const resMsg = this.initializeMessage(msg);
|
||||||
|
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
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,22 +1,12 @@
|
|||||||
import type { createActionMap } from 'napcat-onebot/action';
|
|
||||||
import { EventType } from 'napcat-onebot/event/OneBotEvent';
|
import { EventType } from 'napcat-onebot/event/OneBotEvent';
|
||||||
import type { PluginModule } from 'napcat-onebot/network/plugin';
|
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) => {
|
const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => {
|
||||||
console.log('[Plugin: example] 插件已初始化');
|
console.log('[Plugin: example] 插件已初始化');
|
||||||
actionMap = _actions;
|
|
||||||
};
|
};
|
||||||
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, actions, instance) => {
|
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, actions, instance) => {
|
||||||
if (event.post_type === EventType.MESSAGE && event.raw_message.includes('ping')) {
|
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);
|
await actions.get('send_group_msg')?.handle({ group_id: String(event.group_id), message: 'pong' }, adapter, instance.config);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
export { plugin_init, plugin_onmessage, actionMap };
|
export { plugin_init, plugin_onmessage };
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
conditions: ['node', 'default'],
|
conditions: ['node', 'default'],
|
||||||
alias: {
|
alias: {
|
||||||
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
'@/napcat-core': resolve(__dirname, '../core'),
|
||||||
'@': resolve(__dirname, '../'),
|
'@': resolve(__dirname, '../'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -12,17 +12,7 @@ import { ArgvOrCommandLine } from '@homebridge/node-pty-prebuilt-multiarch/src/t
|
|||||||
import { assign } from '@homebridge/node-pty-prebuilt-multiarch/src/utils';
|
import { assign } from '@homebridge/node-pty-prebuilt-multiarch/src/utils';
|
||||||
import { pty_loader } from './prebuild-loader';
|
import { pty_loader } from './prebuild-loader';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
export const pty = pty_loader();
|
||||||
// 懒加载pty,避免在模块导入时立即执行pty_loader()
|
|
||||||
let _pty: any;
|
|
||||||
export const pty: any = new Proxy({}, {
|
|
||||||
get (_target, prop) {
|
|
||||||
if (!_pty) {
|
|
||||||
_pty = pty_loader();
|
|
||||||
}
|
|
||||||
return _pty[prop];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let helperPath: string;
|
let helperPath: string;
|
||||||
helperPath = '../build/Release/spawn-helper';
|
helperPath = '../build/Release/spawn-helper';
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
@echo off
|
@echo off
|
||||||
chcp 65001 >nul
|
chcp 65001
|
||||||
set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json
|
set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json
|
||||||
set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js
|
set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js
|
||||||
set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll
|
set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll
|
||||||
@ -27,6 +27,6 @@ if not exist "%QQpath%" (
|
|||||||
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
|
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
|
||||||
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
|
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
|
||||||
|
|
||||||
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
|
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %1
|
||||||
|
|
||||||
pause
|
pause
|
||||||
@ -1,5 +1,5 @@
|
|||||||
@echo off
|
@echo off
|
||||||
chcp 65001 >nul
|
chcp 65001
|
||||||
set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json
|
set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json
|
||||||
set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js
|
set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js
|
||||||
set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll
|
set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll
|
||||||
@ -26,9 +26,8 @@ if not exist "%QQpath%" (
|
|||||||
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
|
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
|
||||||
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
|
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
|
||||||
|
|
||||||
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
|
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %1
|
||||||
|
|
||||||
REM Optional: -q <QQ_NUMBER> for quick login, omit for QR code login
|
REM "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" 123456
|
||||||
REM Example: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456
|
|
||||||
|
|
||||||
pause
|
pause
|
||||||
@ -1,11 +1,11 @@
|
|||||||
@echo off
|
@echo off
|
||||||
chcp 65001 >nul
|
chcp 65001
|
||||||
net session >nul 2>&1
|
net session >nul 2>&1
|
||||||
if %ERRORLEVEL% == 0 (
|
if %errorLevel% == 0 (
|
||||||
echo Administrator mode detected.
|
echo Administrator mode detected.
|
||||||
) else (
|
) else (
|
||||||
echo Please run this script in administrator mode.
|
echo Please run this script in administrator mode.
|
||||||
powershell -Command "Start-Process 'cmd.exe' -ArgumentList '/c cd /d \"%cd%\" && \"%~f0\" %*' -Verb runAs"
|
powershell -Command "Start-Process 'cmd.exe' -ArgumentList '/c cd /d \"%cd%\" && \"%~f0\" %1' -Verb runAs"
|
||||||
exit
|
exit
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -35,7 +35,6 @@ if not exist "%QQPath%" (
|
|||||||
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
|
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
|
||||||
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
|
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
|
||||||
|
|
||||||
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
|
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %1
|
||||||
|
|
||||||
REM Optional: -q <QQ_NUMBER> for quick login, omit for QR code login
|
REM "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" 123456
|
||||||
REM Example: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456
|
|
||||||
@ -1,11 +1,11 @@
|
|||||||
@echo off
|
@echo off
|
||||||
chcp 65001 >nul
|
chcp 65001
|
||||||
net session >nul 2>&1
|
net session >nul 2>&1
|
||||||
if %ERRORLEVEL% == 0 (
|
if %errorLevel% == 0 (
|
||||||
echo Administrator mode detected.
|
echo Administrator mode detected.
|
||||||
) else (
|
) else (
|
||||||
echo Please run this script in administrator mode.
|
echo Please run this script in administrator mode.
|
||||||
powershell -Command "Start-Process 'wt.exe' -ArgumentList 'cmd /c cd /d \"%cd%\" && \"%~f0\" %*' -Verb runAs"
|
powershell -Command "Start-Process 'wt.exe' -ArgumentList 'cmd /c cd /d \"%cd%\" && \"%~f0\" %1' -Verb runAs"
|
||||||
exit
|
exit
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -36,4 +36,4 @@ if not exist "%QQPath%" (
|
|||||||
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
|
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
|
||||||
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
|
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
|
||||||
|
|
||||||
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
|
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %1
|
||||||
@ -1,7 +1,4 @@
|
|||||||
@echo off
|
@echo off
|
||||||
REM 快速登录示例脚本
|
REM ./launcher.bat 123456
|
||||||
REM -q 参数是可选的,不传则使用二维码登录
|
REM ./launcher-win10.bat 123456
|
||||||
REM
|
REM 带有REM的为注释 删掉你需要的系统的那行REM这三个单词 修改QQ本脚本启动即可
|
||||||
REM 使用方法(删掉对应系统那行的 REM):
|
|
||||||
REM ./launcher.bat -q 123456
|
|
||||||
REM ./launcher-win10.bat -q 123456
|
|
||||||
@ -35,7 +35,6 @@ import { logSubscription, LogWrapper } from '@/napcat-core/helper/log';
|
|||||||
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
||||||
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
||||||
import { statusHelperSubscription } from '@/napcat-core/helper/status';
|
import { statusHelperSubscription } from '@/napcat-core/helper/status';
|
||||||
import { applyPendingUpdates } from '@/napcat-webui-backend/src/api/UpdateNapCat';
|
|
||||||
// NapCat Shell App ES 入口文件
|
// NapCat Shell App ES 入口文件
|
||||||
async function handleUncaughtExceptions (logger: LogWrapper) {
|
async function handleUncaughtExceptions (logger: LogWrapper) {
|
||||||
process.on('uncaughtException', (err) => {
|
process.on('uncaughtException', (err) => {
|
||||||
@ -319,7 +318,6 @@ export async function NCoreInitShell () {
|
|||||||
const pathWrapper = new NapCatPathWrapper();
|
const pathWrapper = new NapCatPathWrapper();
|
||||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||||
handleUncaughtExceptions(logger);
|
handleUncaughtExceptions(logger);
|
||||||
await applyPendingUpdates(pathWrapper, logger);
|
|
||||||
|
|
||||||
// 初始化 FFmpeg 服务
|
// 初始化 FFmpeg 服务
|
||||||
await FFmpegService.init(pathWrapper.binaryPath, logger);
|
await FFmpegService.init(pathWrapper.binaryPath, logger);
|
||||||
@ -340,8 +338,8 @@ export async function NCoreInitShell () {
|
|||||||
o3Service.addO3MiscListener(new NodeIO3MiscListener());
|
o3Service.addO3MiscListener(new NodeIO3MiscListener());
|
||||||
|
|
||||||
logger.log('[NapCat] [Core] NapCat.Core Version: ' + napCatVersion);
|
logger.log('[NapCat] [Core] NapCat.Core Version: ' + napCatVersion);
|
||||||
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Shell);
|
|
||||||
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
||||||
|
|
||||||
const engine = wrapper.NodeIQQNTWrapperEngine.get();
|
const engine = wrapper.NodeIQQNTWrapperEngine.get();
|
||||||
const loginService = wrapper.NodeIKernelLoginService.get();
|
const loginService = wrapper.NodeIKernelLoginService.get();
|
||||||
let session: NodeIQQNTWrapperSession;
|
let session: NodeIQQNTWrapperSession;
|
||||||
@ -418,6 +416,7 @@ export async function NCoreInitShell () {
|
|||||||
wrapper,
|
wrapper,
|
||||||
session,
|
session,
|
||||||
logger,
|
logger,
|
||||||
|
loginService,
|
||||||
selfInfo,
|
selfInfo,
|
||||||
basicInfoWrapper,
|
basicInfoWrapper,
|
||||||
pathWrapper,
|
pathWrapper,
|
||||||
@ -433,6 +432,7 @@ export class NapCatShell {
|
|||||||
wrapper: WrapperNodeApi,
|
wrapper: WrapperNodeApi,
|
||||||
session: NodeIQQNTWrapperSession,
|
session: NodeIQQNTWrapperSession,
|
||||||
logger: LogWrapper,
|
logger: LogWrapper,
|
||||||
|
loginService: NodeIKernelLoginService,
|
||||||
selfInfo: SelfInfo,
|
selfInfo: SelfInfo,
|
||||||
basicInfoWrapper: QQBasicInfoWrapper,
|
basicInfoWrapper: QQBasicInfoWrapper,
|
||||||
pathWrapper: NapCatPathWrapper,
|
pathWrapper: NapCatPathWrapper,
|
||||||
@ -444,6 +444,7 @@ export class NapCatShell {
|
|||||||
wrapper,
|
wrapper,
|
||||||
session,
|
session,
|
||||||
logger,
|
logger,
|
||||||
|
loginService,
|
||||||
basicInfoWrapper,
|
basicInfoWrapper,
|
||||||
pathWrapper,
|
pathWrapper,
|
||||||
};
|
};
|
||||||
@ -452,11 +453,7 @@ export class NapCatShell {
|
|||||||
|
|
||||||
async InitNapCat () {
|
async InitNapCat () {
|
||||||
await this.core.initCore();
|
await this.core.initCore();
|
||||||
const oneBotAdapter = new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper);
|
new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper).InitOneBot()
|
||||||
// 注册到 WebUiDataRuntime,供调试功能使用
|
|
||||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
|
||||||
oneBotAdapter.InitOneBot()
|
|
||||||
.catch(e => this.context.logger.logError('初始化OneBot失败', e));
|
.catch(e => this.context.logger.logError('初始化OneBot失败', e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,7 +27,6 @@ const ShellBaseConfigPlugin: PluginOption[] = [
|
|||||||
targets: [
|
targets: [
|
||||||
{ src: '../napcat-native/', dest: 'dist/native', flatten: false },
|
{ src: '../napcat-native/', dest: 'dist/native', flatten: false },
|
||||||
{ src: '../napcat-webui-frontend/dist/', dest: 'dist/static/', 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: '../napcat-core/external/napcat.json', dest: 'dist/config/' },
|
||||||
{ src: '../../package.json', dest: 'dist' },
|
{ src: '../../package.json', dest: 'dist' },
|
||||||
{ src: '../napcat-shell-loader', dest: 'dist' },
|
{ src: '../napcat-shell-loader', dest: 'dist' },
|
||||||
|
|||||||
@ -6,49 +6,8 @@ import { fileURLToPath } from 'url';
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
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
|
* 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 () {
|
export default function vitePluginNapcatVersion () {
|
||||||
const pluginDir = path.resolve(__dirname, 'dist');
|
const pluginDir = path.resolve(__dirname, 'dist');
|
||||||
@ -57,9 +16,6 @@ export default function vitePluginNapcatVersion () {
|
|||||||
const repo = 'NapCatQQ';
|
const repo = 'NapCatQQ';
|
||||||
const maxAgeMs = 24 * 60 * 60 * 1000; // cache 1 day
|
const maxAgeMs = 24 * 60 * 60 * 1000; // cache 1 day
|
||||||
const githubToken = process.env.GITHUB_TOKEN;
|
const githubToken = process.env.GITHUB_TOKEN;
|
||||||
// CI 构建时可通过环境变量直接指定版本号
|
|
||||||
const envVersion = process.env.NAPCAT_VERSION;
|
|
||||||
const fallbackVersion = '1.0.0-dev';
|
|
||||||
|
|
||||||
fs.mkdirSync(pluginDir, { recursive: true });
|
fs.mkdirSync(pluginDir, { recursive: true });
|
||||||
|
|
||||||
@ -70,7 +26,7 @@ export default function vitePluginNapcatVersion () {
|
|||||||
const data = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
const data = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
||||||
if (data?.tag) return data.tag;
|
if (data?.tag) return data.tag;
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,7 +36,7 @@ export default function vitePluginNapcatVersion () {
|
|||||||
cacheFile,
|
cacheFile,
|
||||||
JSON.stringify({ tag, time: new Date().toISOString() }, null, 2)
|
JSON.stringify({ tag, time: new Date().toISOString() }, null, 2)
|
||||||
);
|
);
|
||||||
} catch { }
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLatestTag () {
|
async function fetchLatestTag () {
|
||||||
@ -102,14 +58,7 @@ export default function vitePluginNapcatVersion () {
|
|||||||
try {
|
try {
|
||||||
const json = JSON.parse(data);
|
const json = JSON.parse(data);
|
||||||
if (Array.isArray(json) && json[0]?.name) {
|
if (Array.isArray(json) && json[0]?.name) {
|
||||||
const tagName = json[0].name;
|
resolve(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'));
|
} else reject(new Error('Invalid GitHub tag response'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(e);
|
reject(e);
|
||||||
@ -122,17 +71,6 @@ export default function vitePluginNapcatVersion () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getVersion () {
|
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();
|
const cached = readCache();
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
try {
|
try {
|
||||||
@ -141,7 +79,7 @@ export default function vitePluginNapcatVersion () {
|
|||||||
return tag;
|
return tag;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[vite-plugin-napcat-version] Failed to fetch tag:', e.message);
|
console.warn('[vite-plugin-napcat-version] Failed to fetch tag:', e.message);
|
||||||
return cached ?? fallbackVersion;
|
return cached ?? 'v0.0.0';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,11 +110,8 @@ export default function vitePluginNapcatVersion () {
|
|||||||
lastTag = tag;
|
lastTag = tag;
|
||||||
ctx.server?.ws.send({ type: 'full-reload' });
|
ctx.server?.ws.send({ type: 'full-reload' });
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export validateVersion for external use
|
|
||||||
export { validateVersion };
|
|
||||||
|
|||||||
@ -22,14 +22,6 @@ import { existsSync, readFileSync } from 'node:fs'; // 引入multer用于错误
|
|||||||
import { ILogWrapper } from 'napcat-common/src/log-interface';
|
import { ILogWrapper } from 'napcat-common/src/log-interface';
|
||||||
import { ISubscription } from 'napcat-common/src/subscription-interface';
|
import { ISubscription } from 'napcat-common/src/subscription-interface';
|
||||||
import { IStatusHelperSubscription } from '@/napcat-common/src/status-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
|
// 实例化Express
|
||||||
const app = express();
|
const app = express();
|
||||||
/**
|
/**
|
||||||
@ -42,7 +34,6 @@ export let WebUiConfig: WebUiConfigWrapper;
|
|||||||
export let webUiPathWrapper: NapCatPathWrapper;
|
export let webUiPathWrapper: NapCatPathWrapper;
|
||||||
export let logSubscription: ISubscription;
|
export let logSubscription: ISubscription;
|
||||||
export let statusHelperSubscription: IStatusHelperSubscription;
|
export let statusHelperSubscription: IStatusHelperSubscription;
|
||||||
export let webUiLogger: ILogWrapper | null = null;
|
|
||||||
const MAX_PORT_TRY = 100;
|
const MAX_PORT_TRY = 100;
|
||||||
|
|
||||||
export let webUiRuntimePort = 6099;
|
export let webUiRuntimePort = 6099;
|
||||||
@ -101,17 +92,10 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
|||||||
webUiPathWrapper = pathWrapper;
|
webUiPathWrapper = pathWrapper;
|
||||||
logSubscription = Subscription;
|
logSubscription = Subscription;
|
||||||
statusHelperSubscription = statusSubscription;
|
statusHelperSubscription = statusSubscription;
|
||||||
webUiLogger = logger;
|
|
||||||
WebUiConfig = new WebUiConfigWrapper();
|
WebUiConfig = new WebUiConfigWrapper();
|
||||||
let config = await WebUiConfig.GetWebUIConfig();
|
let config = await WebUiConfig.GetWebUIConfig();
|
||||||
|
|
||||||
// 检查是否禁用WebUI(若禁用则不进行密码检测)
|
// 检查并更新默认密码 - 最高优先级
|
||||||
if (config.disableWebUI) {
|
|
||||||
logger.log('[NapCat] [WebUi] WebUI is disabled by configuration.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查并更新默认密码(仅在启用WebUI时)
|
|
||||||
if (config.token === 'napcat' || !config.token) {
|
if (config.token === 'napcat' || !config.token) {
|
||||||
const randomToken = process.env['NAPCAT_WEBUI_SECRET_KEY'] || getRandomToken(8);
|
const randomToken = process.env['NAPCAT_WEBUI_SECRET_KEY'] || getRandomToken(8);
|
||||||
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
|
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
|
||||||
@ -128,6 +112,12 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
|||||||
// 存储启动时的初始token用于鉴权
|
// 存储启动时的初始token用于鉴权
|
||||||
setInitialWebUiToken(config.token);
|
setInitialWebUiToken(config.token);
|
||||||
|
|
||||||
|
// 检查是否禁用WebUI
|
||||||
|
if (config.disableWebUI) {
|
||||||
|
logger.log('[NapCat] [WebUi] WebUI is disabled by configuration.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const [host, port, token] = await InitPort(config);
|
const [host, port, token] = await InitPort(config);
|
||||||
webUiRuntimePort = port;
|
webUiRuntimePort = port;
|
||||||
if (port === 0) {
|
if (port === 0) {
|
||||||
@ -152,31 +142,18 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
|||||||
// ------------注册中间件------------
|
// ------------注册中间件------------
|
||||||
// 使用express的json中间件
|
// 使用express的json中间件
|
||||||
app.use(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中间件
|
// CORS中间件
|
||||||
// TODO:
|
// TODO:
|
||||||
app.use(cors);
|
app.use(cors);
|
||||||
|
|
||||||
// 自定义字体文件路由 - 返回用户上传的字体文件
|
// 如果是webui字体文件,挂载字体文件
|
||||||
app.use('/webui/fonts/CustomFont.woff', async (_req, res) => {
|
app.use('/webui/fonts/AaCute.woff', async (_req, res, next) => {
|
||||||
const fontPath = await WebUiConfig.GetWebUIFontPath();
|
const isFontExist = await WebUiConfig.CheckWebUIFontExist();
|
||||||
if (fontPath) {
|
if (isFontExist) {
|
||||||
res.sendFile(fontPath);
|
res.sendFile(WebUiConfig.GetWebUIFontPath());
|
||||||
} else {
|
} else {
|
||||||
res.status(404).send('Custom font not found');
|
next();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -198,32 +175,6 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
|||||||
res.send(css);
|
res.send(css);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 动态生成 sw.js
|
|
||||||
app.get('/webui/sw.js', async (_req, res) => {
|
|
||||||
try {
|
|
||||||
// 读取模板文件
|
|
||||||
let templatePath = resolve(__dirname, 'static', 'sw_template.js');
|
|
||||||
if (!existsSync(templatePath)) {
|
|
||||||
templatePath = resolve(__dirname, 'src', 'assets', 'sw_template.js');
|
|
||||||
}
|
|
||||||
|
|
||||||
let swContent = readFileSync(templatePath, 'utf-8');
|
|
||||||
|
|
||||||
// 替换版本号
|
|
||||||
// 使用 napCatVersion,如果为 alpha 则尝试加上时间戳或其他标识以避免缓存冲突,或者直接使用
|
|
||||||
// 用户要求控制 sw.js 版本,napCatVersion 是核心控制点
|
|
||||||
swContent = swContent.replace('{{VERSION}}', napCatVersion);
|
|
||||||
|
|
||||||
res.header('Content-Type', 'application/javascript');
|
|
||||||
res.header('Service-Worker-Allowed', '/webui/');
|
|
||||||
res.header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
||||||
res.send(swContent);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[NapCat] [WebUi] Error generating sw.js', error);
|
|
||||||
res.status(500).send('Error generating service worker');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ------------中间件结束------------
|
// ------------中间件结束------------
|
||||||
|
|
||||||
// ------------挂载路由------------
|
// ------------挂载路由------------
|
||||||
@ -236,15 +187,7 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
|||||||
const isHttps = !!sslCerts;
|
const isHttps = !!sslCerts;
|
||||||
const server = isHttps && sslCerts ? createHttpsServer(sslCerts, app) : createServer(app);
|
const server = isHttps && sslCerts ? createHttpsServer(sslCerts, app) : createServer(app);
|
||||||
server.on('upgrade', (request, socket, head) => {
|
server.on('upgrade', (request, socket, head) => {
|
||||||
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);
|
terminalManager.initialize(request, socket, head, logger);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
// 挂载API接口
|
// 挂载API接口
|
||||||
app.use('/api', ALLRouter);
|
app.use('/api', ALLRouter);
|
||||||
|
|||||||
@ -16,11 +16,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@simplewebauthn/server": "^13.2.2",
|
|
||||||
"@sinclair/typebox": "^0.34.38",
|
"@sinclair/typebox": "^0.34.38",
|
||||||
"ajv": "^8.13.0",
|
"ajv": "^8.13.0",
|
||||||
"compressing": "^1.10.3",
|
"compressing": "^1.10.3",
|
||||||
"compression": "^1.8.1",
|
|
||||||
"express": "^5.0.0",
|
"express": "^5.0.0",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
@ -30,7 +28,6 @@
|
|||||||
"ws": "^8.18.3"
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/compression": "^1.8.1",
|
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.0.1",
|
"@types/node": "^22.0.1",
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { RequestHandler } from 'express';
|
import { RequestHandler } from 'express';
|
||||||
import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
|
import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
|
||||||
import { PasskeyHelper } from '@/napcat-webui-backend/src/helper/PasskeyHelper';
|
|
||||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||||
import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response';
|
import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response';
|
||||||
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
||||||
@ -149,115 +148,3 @@ export const UpdateTokenHandler: RequestHandler = async (req, res) => {
|
|||||||
return sendError(res, `Failed to update token: ${e.message}`);
|
return sendError(res, `Failed to update token: ${e.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 生成Passkey注册选项
|
|
||||||
export const GeneratePasskeyRegistrationOptionsHandler: RequestHandler = async (_req, res) => {
|
|
||||||
try {
|
|
||||||
// 使用固定用户ID,因为WebUI只有一个用户
|
|
||||||
const userId = 'napcat-user';
|
|
||||||
const userName = 'NapCat User';
|
|
||||||
|
|
||||||
// 从请求头获取host来确定RP_ID
|
|
||||||
const host = _req.get('host') || 'localhost';
|
|
||||||
const hostname = host.split(':')[0] || 'localhost'; // 移除端口
|
|
||||||
// 对于本地开发,使用localhost而不是IP地址
|
|
||||||
const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname;
|
|
||||||
|
|
||||||
const options = await PasskeyHelper.generateRegistrationOptions(userId, userName, rpId);
|
|
||||||
return sendSuccess(res, options);
|
|
||||||
} catch (error) {
|
|
||||||
return sendError(res, `Failed to generate registration options: ${(error as Error).message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 验证Passkey注册
|
|
||||||
export const VerifyPasskeyRegistrationHandler: RequestHandler = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { response } = req.body;
|
|
||||||
if (!response) {
|
|
||||||
return sendError(res, 'Response is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const origin = req.get('origin') || req.protocol + '://' + req.get('host');
|
|
||||||
const host = req.get('host') || 'localhost';
|
|
||||||
const hostname = host.split(':')[0] || 'localhost'; // 移除端口
|
|
||||||
// 对于本地开发,使用localhost而不是IP地址
|
|
||||||
const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname;
|
|
||||||
const userId = 'napcat-user';
|
|
||||||
const verification = await PasskeyHelper.verifyRegistration(userId, response, origin, rpId);
|
|
||||||
|
|
||||||
if (verification.verified) {
|
|
||||||
return sendSuccess(res, { verified: true });
|
|
||||||
} else {
|
|
||||||
return sendError(res, 'Registration failed');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return sendError(res, `Registration verification failed: ${(error as Error).message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 生成Passkey认证选项
|
|
||||||
export const GeneratePasskeyAuthenticationOptionsHandler: RequestHandler = async (_req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = 'napcat-user';
|
|
||||||
|
|
||||||
if (!(await PasskeyHelper.hasPasskeys(userId))) {
|
|
||||||
return sendError(res, 'No passkeys registered');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从请求头获取host来确定RP_ID
|
|
||||||
const host = _req.get('host') || 'localhost';
|
|
||||||
const hostname = host.split(':')[0] || 'localhost'; // 移除端口
|
|
||||||
// 对于本地开发,使用localhost而不是IP地址
|
|
||||||
const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname;
|
|
||||||
|
|
||||||
const options = await PasskeyHelper.generateAuthenticationOptions(userId, rpId);
|
|
||||||
return sendSuccess(res, options);
|
|
||||||
} catch (error) {
|
|
||||||
return sendError(res, `Failed to generate authentication options: ${(error as Error).message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 验证Passkey认证
|
|
||||||
export const VerifyPasskeyAuthenticationHandler: RequestHandler = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { response } = req.body;
|
|
||||||
if (!response) {
|
|
||||||
return sendError(res, 'Response is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取WebUI配置用于限速检查
|
|
||||||
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
|
|
||||||
// 获取客户端IP
|
|
||||||
const clientIP = req.ip || req.socket.remoteAddress || '';
|
|
||||||
|
|
||||||
// 检查登录频率
|
|
||||||
if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
|
|
||||||
return sendError(res, 'login rate limit');
|
|
||||||
}
|
|
||||||
|
|
||||||
const origin = req.get('origin') || req.protocol + '://' + req.get('host');
|
|
||||||
const host = req.get('host') || 'localhost';
|
|
||||||
const hostname = host.split(':')[0] || 'localhost'; // 移除端口
|
|
||||||
// 对于本地开发,使用localhost而不是IP地址
|
|
||||||
const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname;
|
|
||||||
const userId = 'napcat-user';
|
|
||||||
const verification = await PasskeyHelper.verifyAuthentication(userId, response, origin, rpId);
|
|
||||||
|
|
||||||
if (verification.verified) {
|
|
||||||
// 使用与普通登录相同的凭证签发
|
|
||||||
const initialToken = getInitialWebUiToken();
|
|
||||||
if (!initialToken) {
|
|
||||||
return sendError(res, 'Server token not initialized');
|
|
||||||
}
|
|
||||||
const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(AuthHelper.generatePasswordHash(initialToken)))).toString('base64');
|
|
||||||
return sendSuccess(res, {
|
|
||||||
Credential: signCredential,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return sendError(res, 'Authentication failed');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return sendError(res, `Authentication verification failed: ${(error as Error).message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@ -3,143 +3,12 @@ import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
|||||||
|
|
||||||
import { sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
import { sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||||
import { getLatestTag, getAllTags, compareSemVer } from 'napcat-common/src/helper';
|
|
||||||
import { getLatestActionArtifacts } from '@/napcat-common/src/mirror';
|
|
||||||
import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types';
|
|
||||||
|
|
||||||
export const GetNapCatVersion: RequestHandler = (_, res) => {
|
export const GetNapCatVersion: RequestHandler = (_, res) => {
|
||||||
const data = WebUiDataRuntime.GetNapCatVersion();
|
const data = WebUiDataRuntime.GetNapCatVersion();
|
||||||
sendSuccess(res, { version: data });
|
sendSuccess(res, { version: data });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLatestTagHandler: RequestHandler = async (_, res) => {
|
|
||||||
try {
|
|
||||||
const latestTag = await getLatestTag();
|
|
||||||
sendSuccess(res, latestTag);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ error: 'Failed to fetch latest tag', details: (error as Error).message });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 版本信息接口
|
|
||||||
*/
|
|
||||||
export interface VersionInfo {
|
|
||||||
tag: string;
|
|
||||||
type: 'release' | 'prerelease' | 'action';
|
|
||||||
/** Action artifact 专用字段 */
|
|
||||||
artifactId?: number;
|
|
||||||
artifactName?: string;
|
|
||||||
createdAt?: string;
|
|
||||||
expiresAt?: string;
|
|
||||||
size?: number;
|
|
||||||
workflowRunId?: number;
|
|
||||||
headSha?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有可用的版本(release + action artifacts)
|
|
||||||
* 支持分页,懒加载:根据 type 参数只获取需要的版本类型
|
|
||||||
*/
|
|
||||||
export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const page = parseInt(req.query['page'] as string) || 1;
|
|
||||||
const pageSize = parseInt(req.query['pageSize'] as string) || 20;
|
|
||||||
const typeFilter = req.query['type'] as string | undefined; // 'release' | 'action' | 'all'
|
|
||||||
const searchQuery = (req.query['search'] as string || '').toLowerCase().trim();
|
|
||||||
|
|
||||||
let versions: VersionInfo[] = [];
|
|
||||||
let actionVersions: VersionInfo[] = [];
|
|
||||||
let usedMirror = '';
|
|
||||||
|
|
||||||
// 懒加载:只获取需要的版本类型
|
|
||||||
const needReleases = !typeFilter || typeFilter === 'all' || typeFilter === 'release';
|
|
||||||
const needActions = typeFilter === 'action' || typeFilter === 'all';
|
|
||||||
|
|
||||||
// 获取正式版本(仅当需要时)
|
|
||||||
if (needReleases) {
|
|
||||||
try {
|
|
||||||
const result = await getAllTags();
|
|
||||||
usedMirror = result.mirror;
|
|
||||||
|
|
||||||
versions = result.tags.map(tag => {
|
|
||||||
const isPrerelease = /-(alpha|beta|rc|dev|pre|snapshot)/i.test(tag);
|
|
||||||
return {
|
|
||||||
tag,
|
|
||||||
type: isPrerelease ? 'prerelease' : 'release',
|
|
||||||
} as VersionInfo;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 使用语义化版本排序(最新的在前)
|
|
||||||
versions.sort((a, b) => -compareSemVer(a.tag, b.tag));
|
|
||||||
} catch {
|
|
||||||
// 如果获取 tags 失败,返回空列表而不是抛出错误
|
|
||||||
versions = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取 Action Artifacts(仅当需要时)
|
|
||||||
if (needActions) {
|
|
||||||
try {
|
|
||||||
const artifacts = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main');
|
|
||||||
|
|
||||||
// 根据当前工作环境自动过滤对应的 artifact 类型
|
|
||||||
const isFramework = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework;
|
|
||||||
const targetArtifactName = isFramework ? 'NapCat.Framework' : 'NapCat.Shell';
|
|
||||||
|
|
||||||
actionVersions = artifacts
|
|
||||||
.filter(a => a.name === targetArtifactName)
|
|
||||||
.map(a => ({
|
|
||||||
tag: `action-${a.id}`,
|
|
||||||
type: 'action' as const,
|
|
||||||
artifactId: a.id,
|
|
||||||
artifactName: a.name,
|
|
||||||
createdAt: a.created_at,
|
|
||||||
expiresAt: a.expires_at,
|
|
||||||
size: a.size_in_bytes,
|
|
||||||
workflowRunId: a.workflow_run_id,
|
|
||||||
headSha: a.head_sha,
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
// 获取失败时返回空列表
|
|
||||||
actionVersions = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并版本列表(action 在最前面)
|
|
||||||
let allVersions = [...actionVersions, ...versions];
|
|
||||||
|
|
||||||
// 搜索过滤
|
|
||||||
if (searchQuery) {
|
|
||||||
allVersions = allVersions.filter(v => {
|
|
||||||
const tagMatch = v.tag.toLowerCase().includes(searchQuery);
|
|
||||||
const nameMatch = v.artifactName?.toLowerCase().includes(searchQuery);
|
|
||||||
return tagMatch || nameMatch;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页
|
|
||||||
const total = allVersions.length;
|
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
|
||||||
const start = (page - 1) * pageSize;
|
|
||||||
const end = start + pageSize;
|
|
||||||
const paginatedVersions = allVersions.slice(start, end);
|
|
||||||
|
|
||||||
sendSuccess(res, {
|
|
||||||
versions: paginatedVersions,
|
|
||||||
pagination: {
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
total,
|
|
||||||
totalPages,
|
|
||||||
},
|
|
||||||
mirror: usedMirror
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ error: 'Failed to fetch releases' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const QQVersionHandler: RequestHandler = (_, res) => {
|
export const QQVersionHandler: RequestHandler = (_, res) => {
|
||||||
const data = WebUiDataRuntime.getQQVersion();
|
const data = WebUiDataRuntime.getQQVersion();
|
||||||
sendSuccess(res, data);
|
sendSuccess(res, data);
|
||||||
|
|||||||
@ -1,406 +0,0 @@
|
|||||||
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字体文件处理方法
|
// 删除WebUI字体文件处理方法
|
||||||
export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => {
|
export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const fontPath = await WebUiConfig.GetWebUIFontPath();
|
const fontPath = WebUiConfig.GetWebUIFontPath();
|
||||||
const exists = await WebUiConfig.CheckWebUIFontExist();
|
const exists = await WebUiConfig.CheckWebUIFontExist();
|
||||||
|
|
||||||
if (!exists || !fontPath) {
|
if (!exists) {
|
||||||
return sendSuccess(res, true);
|
return sendSuccess(res, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,395 +0,0 @@
|
|||||||
import { RequestHandler } from 'express';
|
|
||||||
import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import * as https from 'https';
|
|
||||||
import compressing from 'compressing';
|
|
||||||
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 UpdateRequestBody {
|
|
||||||
/** 要更新到的版本 tag,如 "v4.9.9",不传则更新到最新版本 */
|
|
||||||
targetVersion?: string;
|
|
||||||
/** 是否强制更新(即使是降级也更新) */
|
|
||||||
force?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新配置文件接口
|
|
||||||
interface UpdateConfig {
|
|
||||||
version: string;
|
|
||||||
updateTime: string;
|
|
||||||
files: Array<{
|
|
||||||
sourcePath: string;
|
|
||||||
targetPath: string;
|
|
||||||
backupPath?: string;
|
|
||||||
}>;
|
|
||||||
changelog?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 需要跳过更新的文件
|
|
||||||
const SKIP_UPDATE_FILES = [
|
|
||||||
'NapCatWinBootMain.exe',
|
|
||||||
'NapCatWinBootHook.dll'
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 递归扫描目录中的所有文件
|
|
||||||
*/
|
|
||||||
function scanFilesRecursively (dirPath: string, basePath: string = dirPath): Array<{
|
|
||||||
sourcePath: string;
|
|
||||||
relativePath: string;
|
|
||||||
}> {
|
|
||||||
const files: Array<{
|
|
||||||
sourcePath: string;
|
|
||||||
relativePath: string;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
const items = fs.readdirSync(dirPath);
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const fullPath = path.join(dirPath, item);
|
|
||||||
const relativePath = path.relative(basePath, fullPath);
|
|
||||||
const stat = fs.statSync(fullPath);
|
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
// 递归扫描子目录
|
|
||||||
files.push(...scanFilesRecursively(fullPath, basePath));
|
|
||||||
} else if (stat.isFile()) {
|
|
||||||
files.push({
|
|
||||||
sourcePath: fullPath,
|
|
||||||
relativePath: relativePath
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注:镜像配置已迁移到 @/napcat-common/src/mirror 模块统一管理
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 下载文件(带进度和重试)
|
|
||||||
*/
|
|
||||||
async function downloadFile (url: string, dest: string): Promise<void> {
|
|
||||||
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) => {
|
|
||||||
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) {
|
|
||||||
webUiLogger?.log('[NapCat Update] Following redirect to:', res.headers.location);
|
|
||||||
file.close();
|
|
||||||
fs.unlinkSync(dest);
|
|
||||||
downloadFile(res.headers.location!, dest).then(resolve).catch(reject);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.statusCode !== 200) {
|
|
||||||
file.close();
|
|
||||||
fs.unlinkSync(dest);
|
|
||||||
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.pipe(file);
|
|
||||||
file.on('finish', () => {
|
|
||||||
file.close();
|
|
||||||
webUiLogger?.log('[NapCat Update] Download completed');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
request.on('error', (err) => {
|
|
||||||
webUiLogger?.logError('[NapCat Update] Download error:', err);
|
|
||||||
file.close();
|
|
||||||
fs.unlink(dest, () => { });
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
|
|
||||||
try {
|
|
||||||
// 从请求体获取目标版本(可选)
|
|
||||||
const { targetVersion, force } = req.body as UpdateRequestBody;
|
|
||||||
|
|
||||||
// 确定要下载的文件名
|
|
||||||
const ReleaseName = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip';
|
|
||||||
|
|
||||||
// 确定目标版本 tag
|
|
||||||
// 如果指定了版本,使用指定版本;否则使用 'latest'
|
|
||||||
const targetTag = targetVersion || 'latest';
|
|
||||||
webUiLogger?.log(`[NapCat Update] Target version: ${targetTag}`);
|
|
||||||
|
|
||||||
// 检查是否是 action 临时版本
|
|
||||||
const isActionVersion = targetTag.startsWith('action-');
|
|
||||||
let downloadUrl: string;
|
|
||||||
let actualVersion: string;
|
|
||||||
|
|
||||||
if (isActionVersion) {
|
|
||||||
// 处理 action 临时版本
|
|
||||||
const runId = parseInt(targetTag.replace('action-', ''));
|
|
||||||
if (isNaN(runId)) {
|
|
||||||
throw new Error(`Invalid action version format: ${targetTag}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
webUiLogger?.log(`[NapCat Update] Downloading action artifact from run: ${runId}`);
|
|
||||||
|
|
||||||
// 根据当前工作环境确定 artifact 名称
|
|
||||||
const artifactName = ReleaseName.replace('.zip', ''); // NapCat.Framework 或 NapCat.Shell
|
|
||||||
|
|
||||||
// Action artifacts 通过 nightly.link 下载
|
|
||||||
// 格式:https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
|
|
||||||
const baseUrl = `https://nightly.link/NapNeko/NapCatQQ/actions/runs/${runId}/${artifactName}.zip`;
|
|
||||||
actualVersion = targetTag;
|
|
||||||
|
|
||||||
webUiLogger?.log(`[NapCat Update] Action artifact URL: ${baseUrl}`);
|
|
||||||
|
|
||||||
// 使用 mirror 模块查找可用的 nightly.link 镜像
|
|
||||||
try {
|
|
||||||
downloadUrl = await findAvailableDownloadUrl(baseUrl, {
|
|
||||||
validateContent: true,
|
|
||||||
minFileSize: 1024 * 1024,
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`);
|
|
||||||
} catch (error) {
|
|
||||||
// 如果镜像都不可用,直接使用原始 URL
|
|
||||||
webUiLogger?.logWarn(`[NapCat Update] All nightly.link mirrors failed, using original URL`);
|
|
||||||
downloadUrl = baseUrl;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 处理标准 release 版本
|
|
||||||
// 使用 mirror 模块获取 release 信息(不依赖 API)
|
|
||||||
// 通过 assetNames 参数直接构建下载 URL,避免调用 GitHub API
|
|
||||||
const release = await getGitHubRelease('NapNeko', 'NapCatQQ', targetTag, {
|
|
||||||
assetNames: [ReleaseName, 'NapCat.Framework.zip', 'NapCat.Shell.zip'],
|
|
||||||
fetchChangelog: false, // 不需要 changelog,避免 API 调用
|
|
||||||
});
|
|
||||||
|
|
||||||
const shellZipAsset = release.assets.find(asset => asset.name === ReleaseName);
|
|
||||||
if (!shellZipAsset) {
|
|
||||||
throw new Error(`未找到${ReleaseName}文件`);
|
|
||||||
}
|
|
||||||
|
|
||||||
actualVersion = release.tag_name;
|
|
||||||
|
|
||||||
// 使用 mirror 模块查找可用的下载 URL
|
|
||||||
// 启用内容验证,确保返回的是有效文件而非错误页面
|
|
||||||
downloadUrl = await findAvailableDownloadUrl(shellZipAsset.browser_download_url, {
|
|
||||||
validateContent: true, // 验证 Content-Type 和状态码
|
|
||||||
minFileSize: 1024 * 1024, // 最小 1MB,确保不是错误页面
|
|
||||||
timeout: 10000, // 10秒超时
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否需要强制更新(降级警告)
|
|
||||||
const currentVersion = WebUiDataRuntime.GetNapCatVersion();
|
|
||||||
webUiLogger?.log(`[NapCat Update] Current version: ${currentVersion}, Target version: ${actualVersion}`);
|
|
||||||
|
|
||||||
if (!force && currentVersion && !isActionVersion) {
|
|
||||||
// 简单的版本比较(可选的降级保护)
|
|
||||||
const parseVersion = (v: string): [number, number, number] => {
|
|
||||||
const match = v.match(/^v?(\d+)\.(\d+)\.(\d+)/);
|
|
||||||
if (!match) return [0, 0, 0];
|
|
||||||
return [parseInt(match[1] || '0'), parseInt(match[2] || '0'), parseInt(match[3] || '0')];
|
|
||||||
};
|
|
||||||
const [currMajor, currMinor, currPatch] = parseVersion(currentVersion);
|
|
||||||
const [targetMajor, targetMinor, targetPatch] = parseVersion(actualVersion);
|
|
||||||
|
|
||||||
const isDowngrade =
|
|
||||||
targetMajor < currMajor ||
|
|
||||||
(targetMajor === currMajor && targetMinor < currMinor) ||
|
|
||||||
(targetMajor === currMajor && targetMinor === currMinor && targetPatch < currPatch);
|
|
||||||
|
|
||||||
if (isDowngrade) {
|
|
||||||
webUiLogger?.log(`[NapCat Update] Downgrade from ${currentVersion} to ${actualVersion}, force=${force}`);
|
|
||||||
// 不阻止降级,只是记录日志
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
webUiLogger?.log(`[NapCat Update] Updating to version: ${actualVersion}`);
|
|
||||||
|
|
||||||
// 创建临时目录
|
|
||||||
const tempDir = path.join(webUiPathWrapper.binaryPath, './temp');
|
|
||||||
if (!fs.existsSync(tempDir)) {
|
|
||||||
fs.mkdirSync(tempDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`);
|
|
||||||
|
|
||||||
// 下载zip
|
|
||||||
const zipPath = path.join(tempDir, 'napcat-latest.zip');
|
|
||||||
webUiLogger?.log('[NapCat Update] Saving to:', zipPath);
|
|
||||||
await downloadFile(downloadUrl, zipPath);
|
|
||||||
|
|
||||||
// 检查文件大小
|
|
||||||
const stats = fs.statSync(zipPath);
|
|
||||||
webUiLogger?.log('[NapCat Update] Downloaded file size:', stats.size, 'bytes');
|
|
||||||
|
|
||||||
// 解压到临时目录
|
|
||||||
const extractPath = path.join(tempDir, 'napcat-extract');
|
|
||||||
webUiLogger?.log('[NapCat Update] Extracting to:', extractPath);
|
|
||||||
await compressing.zip.uncompress(zipPath, extractPath);
|
|
||||||
|
|
||||||
// 获取解压后的实际内容目录(NapCat.Shell.zip直接包含文件,无额外根目录)
|
|
||||||
const sourcePath = extractPath;
|
|
||||||
|
|
||||||
// 执行更新操作
|
|
||||||
try {
|
|
||||||
// 扫描需要更新的文件
|
|
||||||
const allFiles = scanFilesRecursively(sourcePath);
|
|
||||||
const failedFiles: Array<{
|
|
||||||
sourcePath: string;
|
|
||||||
targetPath: string;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
// 先尝试直接替换文件
|
|
||||||
for (const fileInfo of allFiles) {
|
|
||||||
const targetFilePath = path.join(webUiPathWrapper.binaryPath, fileInfo.relativePath);
|
|
||||||
|
|
||||||
// 跳过指定的文件
|
|
||||||
if (SKIP_UPDATE_FILES.includes(path.basename(fileInfo.relativePath))) {
|
|
||||||
webUiLogger?.log(`[NapCat Update] Skipping update for ${fileInfo.relativePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 确保目标目录存在
|
|
||||||
const targetDir = path.dirname(targetFilePath);
|
|
||||||
if (!fs.existsSync(targetDir)) {
|
|
||||||
fs.mkdirSync(targetDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试直接替换文件
|
|
||||||
if (fs.existsSync(targetFilePath)) {
|
|
||||||
fs.unlinkSync(targetFilePath); // 删除旧文件
|
|
||||||
}
|
|
||||||
fs.copyFileSync(fileInfo.sourcePath, targetFilePath);
|
|
||||||
} catch (error) {
|
|
||||||
// 如果替换失败,添加到失败列表
|
|
||||||
webUiLogger?.logError(`[NapCat Update] Failed to update ${targetFilePath}, will retry on next startup:`, error);
|
|
||||||
failedFiles.push({
|
|
||||||
sourcePath: fileInfo.sourcePath,
|
|
||||||
targetPath: targetFilePath
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果有替换失败的文件,创建更新配置文件
|
|
||||||
if (failedFiles.length > 0) {
|
|
||||||
const updateConfig: UpdateConfig = {
|
|
||||||
version: actualVersion,
|
|
||||||
updateTime: new Date().toISOString(),
|
|
||||||
files: failedFiles,
|
|
||||||
changelog: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
// 保存更新配置文件
|
|
||||||
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
|
|
||||||
fs.writeFileSync(configPath, JSON.stringify(updateConfig, null, 2));
|
|
||||||
webUiLogger?.log(`[NapCat Update] Update config saved for ${failedFiles.length} failed files: ${configPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送成功响应
|
|
||||||
const message = failedFiles.length > 0
|
|
||||||
? `更新完成,重启应用以应用剩余${failedFiles.length}个文件的更新`
|
|
||||||
: '更新完成';
|
|
||||||
sendSuccess(res, {
|
|
||||||
status: 'completed',
|
|
||||||
message,
|
|
||||||
newVersion: actualVersion,
|
|
||||||
failedFilesCount: failedFiles.length
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
webUiLogger?.logError('[NapCat Update] 更新失败:', error);
|
|
||||||
sendError(res, '更新失败: ' + (error instanceof Error ? error.message : '未知错误'));
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
webUiLogger?.logError('[NapCat Update] 更新失败:', error);
|
|
||||||
sendError(res, '更新失败: ' + error.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 注:getLatestRelease 已移除,现在使用 mirror 模块的 getGitHubRelease
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用待处理的更新(在应用启动时调用)
|
|
||||||
*/
|
|
||||||
export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper, logger: ILogWrapper): Promise<void> {
|
|
||||||
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
|
|
||||||
|
|
||||||
if (!fs.existsSync(configPath)) {
|
|
||||||
logger.log('[NapCat Update] No pending updates found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
logger.log('[NapCat Update] Applying pending updates...');
|
|
||||||
const updateConfig: UpdateConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
||||||
|
|
||||||
const remainingFiles: Array<{
|
|
||||||
sourcePath: string;
|
|
||||||
targetPath: string;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (const file of updateConfig.files) {
|
|
||||||
try {
|
|
||||||
// 检查源文件是否存在
|
|
||||||
if (!fs.existsSync(file.sourcePath)) {
|
|
||||||
logger.logWarn(`[NapCat Update] Source file not found: ${file.sourcePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保目标目录存在
|
|
||||||
const targetDir = path.dirname(file.targetPath);
|
|
||||||
if (!fs.existsSync(targetDir)) {
|
|
||||||
fs.mkdirSync(targetDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试替换文件
|
|
||||||
if (fs.existsSync(file.targetPath)) {
|
|
||||||
fs.unlinkSync(file.targetPath); // 删除旧文件
|
|
||||||
}
|
|
||||||
fs.copyFileSync(file.sourcePath, file.targetPath);
|
|
||||||
logger.log(`[NapCat Update] Updated ${path.basename(file.targetPath)} on startup`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.logError(`[NapCat Update] Failed to update ${file.targetPath} on startup:`, error);
|
|
||||||
// 如果仍然失败,保留在列表中
|
|
||||||
remainingFiles.push(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果还有失败的文件,更新配置文件
|
|
||||||
if (remainingFiles.length > 0) {
|
|
||||||
const updatedConfig: UpdateConfig = {
|
|
||||||
...updateConfig,
|
|
||||||
files: remainingFiles
|
|
||||||
};
|
|
||||||
fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2));
|
|
||||||
logger.log(`[NapCat Update] ${remainingFiles.length} files still pending update`);
|
|
||||||
} else {
|
|
||||||
// 所有文件都成功更新,删除配置文件
|
|
||||||
fs.unlinkSync(configPath);
|
|
||||||
logger.log('[NapCat Update] All pending updates applied successfully');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.logError('[NapCat Update] Failed to apply pending updates:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,132 +0,0 @@
|
|||||||
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))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@ -1,9 +1,8 @@
|
|||||||
import store from 'napcat-common/src/store';
|
import store from 'napcat-common/src/store';
|
||||||
import { napCatVersion } from 'napcat-common/src/version';
|
import { napCatVersion } from 'napcat-common/src/version';
|
||||||
import { NapCatCoreWorkingEnv, type LoginRuntimeType } from '../types';
|
import type { LoginRuntimeType } from '../types';
|
||||||
|
|
||||||
const LoginRuntime: LoginRuntimeType = {
|
const LoginRuntime: LoginRuntimeType = {
|
||||||
workingEnv: NapCatCoreWorkingEnv.Unknown,
|
|
||||||
LoginCurrentTime: Date.now(),
|
LoginCurrentTime: Date.now(),
|
||||||
LoginCurrentRate: 0,
|
LoginCurrentRate: 0,
|
||||||
QQLoginStatus: false, // 已实现 但太傻了 得去那边注册个回调刷新
|
QQLoginStatus: false, // 已实现 但太傻了 得去那边注册个回调刷新
|
||||||
@ -15,7 +14,6 @@ const LoginRuntime: LoginRuntimeType = {
|
|||||||
nick: '',
|
nick: '',
|
||||||
},
|
},
|
||||||
QQVersion: 'unknown',
|
QQVersion: 'unknown',
|
||||||
OneBotContext: null,
|
|
||||||
onQQLoginStatusChange: async (status: boolean) => {
|
onQQLoginStatusChange: async (status: boolean) => {
|
||||||
LoginRuntime.QQLoginStatus = status;
|
LoginRuntime.QQLoginStatus = status;
|
||||||
},
|
},
|
||||||
@ -38,12 +36,6 @@ const LoginRuntime: LoginRuntimeType = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
export const WebUiDataRuntime = {
|
export const WebUiDataRuntime = {
|
||||||
setWorkingEnv (env: NapCatCoreWorkingEnv): void {
|
|
||||||
LoginRuntime.workingEnv = env;
|
|
||||||
},
|
|
||||||
getWorkingEnv (): NapCatCoreWorkingEnv {
|
|
||||||
return LoginRuntime.workingEnv;
|
|
||||||
},
|
|
||||||
setWebUiTokenChangeCallback (func: (token: string) => Promise<void>): void {
|
setWebUiTokenChangeCallback (func: (token: string) => Promise<void>): void {
|
||||||
LoginRuntime.onWebUiTokenChange = func;
|
LoginRuntime.onWebUiTokenChange = func;
|
||||||
},
|
},
|
||||||
@ -155,12 +147,4 @@ export const WebUiDataRuntime = {
|
|||||||
runWebUiConfigQuickFunction: async function () {
|
runWebUiConfigQuickFunction: async function () {
|
||||||
await LoginRuntime.WebUiConfigQuickFunction();
|
await LoginRuntime.WebUiConfigQuickFunction();
|
||||||
},
|
},
|
||||||
|
|
||||||
setOneBotContext (context: any): void {
|
|
||||||
LoginRuntime.OneBotContext = context;
|
|
||||||
},
|
|
||||||
|
|
||||||
getOneBotContext (): any | null {
|
|
||||||
return LoginRuntime.OneBotContext;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,207 +0,0 @@
|
|||||||
import {
|
|
||||||
generateRegistrationOptions,
|
|
||||||
verifyRegistrationResponse,
|
|
||||||
generateAuthenticationOptions,
|
|
||||||
verifyAuthenticationResponse,
|
|
||||||
type AuthenticatorTransportFuture,
|
|
||||||
} from '@simplewebauthn/server';
|
|
||||||
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { webUiPathWrapper } from '../../index';
|
|
||||||
|
|
||||||
interface PasskeyCredential {
|
|
||||||
id: string;
|
|
||||||
publicKey: string;
|
|
||||||
counter: number;
|
|
||||||
transports?: AuthenticatorTransportFuture[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const RP_NAME = 'NapCat WebUI';
|
|
||||||
|
|
||||||
export class PasskeyHelper {
|
|
||||||
private static getPasskeyFilePath (): string {
|
|
||||||
return path.join(webUiPathWrapper.configPath, 'passkey.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内存中存储临时挑战数据
|
|
||||||
private static challenges: Map<string, string> = new Map();
|
|
||||||
private static async ensurePasskeyFile (): Promise<void> {
|
|
||||||
try {
|
|
||||||
// 确保配置文件目录存在
|
|
||||||
const passkeyFile = this.getPasskeyFilePath();
|
|
||||||
await fs.mkdir(path.dirname(passkeyFile), { recursive: true });
|
|
||||||
// 检查文件是否存在,如果不存在创建空文件
|
|
||||||
try {
|
|
||||||
await fs.access(passkeyFile);
|
|
||||||
} catch {
|
|
||||||
await fs.writeFile(passkeyFile, JSON.stringify({}, null, 2));
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
// Directory or file already exists or other error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async getAllPasskeys (): Promise<Record<string, PasskeyCredential[]>> {
|
|
||||||
await this.ensurePasskeyFile();
|
|
||||||
try {
|
|
||||||
const passkeyFile = this.getPasskeyFilePath();
|
|
||||||
const data = await fs.readFile(passkeyFile, 'utf-8');
|
|
||||||
const passkeys = JSON.parse(data);
|
|
||||||
return typeof passkeys === 'object' && passkeys !== null ? passkeys : {};
|
|
||||||
} catch (_error) {
|
|
||||||
console.error('Failed to read passkey file:', _error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async saveAllPasskeys (allPasskeys: Record<string, PasskeyCredential[]>): Promise<void> {
|
|
||||||
await this.ensurePasskeyFile();
|
|
||||||
const passkeyFile = this.getPasskeyFilePath();
|
|
||||||
await fs.writeFile(passkeyFile, JSON.stringify(allPasskeys, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async getUserPasskeys (userId: string): Promise<PasskeyCredential[]> {
|
|
||||||
const allPasskeys = await this.getAllPasskeys();
|
|
||||||
return allPasskeys[userId] || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 持久性存储用户的passkey到统一配置文件
|
|
||||||
private static async setUserPasskeys (userId: string, passkeys: PasskeyCredential[]): Promise<void> {
|
|
||||||
const allPasskeys = await this.getAllPasskeys();
|
|
||||||
if (passkeys.length > 0) {
|
|
||||||
allPasskeys[userId] = passkeys;
|
|
||||||
} else {
|
|
||||||
delete allPasskeys[userId];
|
|
||||||
}
|
|
||||||
await this.saveAllPasskeys(allPasskeys);
|
|
||||||
}
|
|
||||||
|
|
||||||
static async generateRegistrationOptions (userId: string, userName: string, rpId: string) {
|
|
||||||
const userPasskeys = await this.getUserPasskeys(userId);
|
|
||||||
|
|
||||||
const options = await generateRegistrationOptions({
|
|
||||||
rpName: RP_NAME,
|
|
||||||
rpID: rpId,
|
|
||||||
userID: new TextEncoder().encode(userId) as Uint8Array<ArrayBuffer>,
|
|
||||||
userName,
|
|
||||||
attestationType: 'none',
|
|
||||||
excludeCredentials: userPasskeys.map(passkey => ({
|
|
||||||
id: passkey.id,
|
|
||||||
type: 'public-key' as const,
|
|
||||||
transports: passkey.transports,
|
|
||||||
})),
|
|
||||||
// Temporarily simplify authenticatorSelection - remove residentKey to avoid conflicts
|
|
||||||
authenticatorSelection: {
|
|
||||||
userVerification: 'preferred',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store challenge temporarily in memory
|
|
||||||
this.challenges.set(`reg_${userId}`, options.challenge);
|
|
||||||
// Auto cleanup after 5 minutes
|
|
||||||
setTimeout(() => {
|
|
||||||
this.challenges.delete(`reg_${userId}`);
|
|
||||||
}, 300000);
|
|
||||||
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async verifyRegistration (userId: string, response: any, origin: string, rpId: string) {
|
|
||||||
const expectedChallenge = this.challenges.get(`reg_${userId}`);
|
|
||||||
if (!expectedChallenge) {
|
|
||||||
throw new Error('Challenge not found or expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
const verification = await verifyRegistrationResponse({
|
|
||||||
response,
|
|
||||||
expectedChallenge,
|
|
||||||
expectedOrigin: origin,
|
|
||||||
expectedRPID: rpId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (verification.verified && verification.registrationInfo) {
|
|
||||||
const { registrationInfo } = verification;
|
|
||||||
|
|
||||||
const newPasskey: PasskeyCredential = {
|
|
||||||
id: registrationInfo.credential.id,
|
|
||||||
publicKey: isoBase64URL.fromBuffer(registrationInfo.credential.publicKey),
|
|
||||||
counter: registrationInfo.credential.counter || 0,
|
|
||||||
transports: response.response.transports,
|
|
||||||
};
|
|
||||||
|
|
||||||
const userPasskeys = await this.getUserPasskeys(userId);
|
|
||||||
userPasskeys.push(newPasskey);
|
|
||||||
await this.setUserPasskeys(userId, userPasskeys);
|
|
||||||
|
|
||||||
// Clean up challenge
|
|
||||||
this.challenges.delete(`reg_${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return verification;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async generateAuthenticationOptions (userId: string, rpId: string) {
|
|
||||||
const userPasskeys = await this.getUserPasskeys(userId);
|
|
||||||
|
|
||||||
const options = await generateAuthenticationOptions({
|
|
||||||
rpID: rpId,
|
|
||||||
allowCredentials: userPasskeys.map(passkey => ({
|
|
||||||
id: passkey.id,
|
|
||||||
type: 'public-key' as const,
|
|
||||||
transports: passkey.transports,
|
|
||||||
})),
|
|
||||||
userVerification: 'preferred',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store challenge temporarily in memory
|
|
||||||
this.challenges.set(`auth_${userId}`, options.challenge);
|
|
||||||
// Auto cleanup after 5 minutes
|
|
||||||
setTimeout(() => {
|
|
||||||
this.challenges.delete(`auth_${userId}`);
|
|
||||||
}, 300000);
|
|
||||||
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async verifyAuthentication (userId: string, response: any, origin: string, rpId: string) {
|
|
||||||
const expectedChallenge = this.challenges.get(`auth_${userId}`);
|
|
||||||
if (!expectedChallenge) {
|
|
||||||
throw new Error('Challenge not found or expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
const userPasskeys = await this.getUserPasskeys(userId);
|
|
||||||
const passkey = userPasskeys.find(p => p.id === response.id);
|
|
||||||
if (!passkey) {
|
|
||||||
throw new Error('Passkey not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const verification = await verifyAuthenticationResponse({
|
|
||||||
response,
|
|
||||||
expectedChallenge,
|
|
||||||
expectedOrigin: origin,
|
|
||||||
expectedRPID: rpId,
|
|
||||||
credential: {
|
|
||||||
id: passkey.id,
|
|
||||||
publicKey: isoBase64URL.toBuffer(passkey.publicKey),
|
|
||||||
counter: passkey.counter,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (verification.verified && verification.authenticationInfo) {
|
|
||||||
// Update counter
|
|
||||||
passkey.counter = verification.authenticationInfo.newCounter;
|
|
||||||
await this.setUserPasskeys(userId, userPasskeys);
|
|
||||||
|
|
||||||
// Clean up challenge
|
|
||||||
this.challenges.delete(`auth_${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return verification;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async hasPasskeys (userId: string): Promise<boolean> {
|
|
||||||
const userPasskeys = await this.getUserPasskeys(userId);
|
|
||||||
return userPasskeys.length > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -176,35 +176,17 @@ export class WebUiConfigWrapper {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断字体是否存在(支持多种格式)
|
// 判断字体是否存在(webui.woff)
|
||||||
async CheckWebUIFontExist (): Promise<boolean> {
|
async CheckWebUIFontExist (): Promise<boolean> {
|
||||||
const fontPath = await this.GetWebUIFontPath();
|
|
||||||
if (!fontPath) return false;
|
|
||||||
return await fs
|
|
||||||
.access(fontPath, constants.F_OK)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取webui字体文件路径(支持多种格式)
|
|
||||||
async GetWebUIFontPath (): Promise<string | null> {
|
|
||||||
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
|
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
|
||||||
const extensions = ['.woff', '.woff2', '.ttf', '.otf'];
|
return await fs
|
||||||
for (const ext of extensions) {
|
.access(resolve(fontsPath, './webui.woff'), constants.F_OK)
|
||||||
const fontPath = resolve(fontsPath, `webui${ext}`);
|
|
||||||
const exists = await fs
|
|
||||||
.access(fontPath, constants.F_OK)
|
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
if (exists) {
|
|
||||||
return fontPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同步版本,用于 multer 配置
|
// 获取webui字体文件路径
|
||||||
GetWebUIFontPathSync (): string {
|
GetWebUIFontPath (): string {
|
||||||
return resolve(webUiPathWrapper.configPath, './fonts/webui.woff');
|
return resolve(webUiPathWrapper.configPath, './fonts/webui.woff');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,12 +12,6 @@ export async function auth (req: Request, res: Response, next: NextFunction) {
|
|||||||
if (req.url === '/auth/login') {
|
if (req.url === '/auth/login') {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
if (req.url === '/auth/passkey/generate-authentication-options' ||
|
|
||||||
req.url === '/auth/passkey/verify-authentication') {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 判断是否有Authorization头
|
// 判断是否有Authorization头
|
||||||
if (req.headers?.authorization) {
|
if (req.headers?.authorization) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler, getAllReleasesHandler } from '../api/BaseInfo';
|
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler } from '../api/BaseInfo';
|
||||||
import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status';
|
import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status';
|
||||||
import { GetProxyHandler } from '../api/Proxy';
|
import { GetProxyHandler } from '../api/Proxy';
|
||||||
|
|
||||||
@ -7,8 +7,6 @@ const router = Router();
|
|||||||
// router: 获取nc的package.json信息
|
// router: 获取nc的package.json信息
|
||||||
router.get('/QQVersion', QQVersionHandler);
|
router.get('/QQVersion', QQVersionHandler);
|
||||||
router.get('/GetNapCatVersion', GetNapCatVersion);
|
router.get('/GetNapCatVersion', GetNapCatVersion);
|
||||||
router.get('/getLatestTag', getLatestTagHandler);
|
|
||||||
router.get('/getAllReleases', getAllReleasesHandler);
|
|
||||||
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
|
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
|
||||||
router.get('/proxy', GetProxyHandler);
|
router.get('/proxy', GetProxyHandler);
|
||||||
router.get('/Theme', GetThemeConfigHandler);
|
router.get('/Theme', GetThemeConfigHandler);
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file UpdateNapCat路由
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Router } from 'express';
|
|
||||||
import { UpdateNapCatHandler } from '@/napcat-webui-backend/src/api/UpdateNapCat';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// POST /api/UpdateNapCat/update - 更新NapCat
|
|
||||||
router.post('/update', UpdateNapCatHandler);
|
|
||||||
|
|
||||||
export { router as UpdateNapCatRouter };
|
|
||||||
@ -5,10 +5,6 @@ import {
|
|||||||
LoginHandler,
|
LoginHandler,
|
||||||
LogoutHandler,
|
LogoutHandler,
|
||||||
UpdateTokenHandler,
|
UpdateTokenHandler,
|
||||||
GeneratePasskeyRegistrationOptionsHandler,
|
|
||||||
VerifyPasskeyRegistrationHandler,
|
|
||||||
GeneratePasskeyAuthenticationOptionsHandler,
|
|
||||||
VerifyPasskeyAuthenticationHandler,
|
|
||||||
} from '@/napcat-webui-backend/src/api/Auth';
|
} from '@/napcat-webui-backend/src/api/Auth';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@ -20,13 +16,5 @@ router.post('/check', checkHandler);
|
|||||||
router.post('/logout', LogoutHandler);
|
router.post('/logout', LogoutHandler);
|
||||||
// router:更新token
|
// router:更新token
|
||||||
router.post('/update_token', UpdateTokenHandler);
|
router.post('/update_token', UpdateTokenHandler);
|
||||||
// router:生成Passkey注册选项
|
|
||||||
router.post('/passkey/generate-registration-options', GeneratePasskeyRegistrationOptionsHandler);
|
|
||||||
// router:验证Passkey注册
|
|
||||||
router.post('/passkey/verify-registration', VerifyPasskeyRegistrationHandler);
|
|
||||||
// router:生成Passkey认证选项
|
|
||||||
router.post('/passkey/generate-authentication-options', GeneratePasskeyAuthenticationOptionsHandler);
|
|
||||||
// router:验证Passkey认证
|
|
||||||
router.post('/passkey/verify-authentication', VerifyPasskeyAuthenticationHandler);
|
|
||||||
|
|
||||||
export { router as AuthRouter };
|
export { router as AuthRouter };
|
||||||
|
|||||||
@ -14,8 +14,6 @@ import { LogRouter } from '@/napcat-webui-backend/src/router/Log';
|
|||||||
import { BaseRouter } from '@/napcat-webui-backend/src/router/Base';
|
import { BaseRouter } from '@/napcat-webui-backend/src/router/Base';
|
||||||
import { FileRouter } from './File';
|
import { FileRouter } from './File';
|
||||||
import { WebUIConfigRouter } from './WebUIConfig';
|
import { WebUIConfigRouter } from './WebUIConfig';
|
||||||
import { UpdateNapCatRouter } from './UpdateNapCat';
|
|
||||||
import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -40,9 +38,5 @@ router.use('/Log', LogRouter);
|
|||||||
router.use('/File', FileRouter);
|
router.use('/File', FileRouter);
|
||||||
// router:WebUI配置相关路由
|
// router:WebUI配置相关路由
|
||||||
router.use('/WebUIConfig', WebUIConfigRouter);
|
router.use('/WebUIConfig', WebUIConfigRouter);
|
||||||
// router:更新NapCat相关路由
|
|
||||||
router.use('/UpdateNapCat', UpdateNapCatRouter);
|
|
||||||
// router:调试相关路由
|
|
||||||
router.use('/Debug', DebugRouter);
|
|
||||||
|
|
||||||
export { router as ALLRouter };
|
export { router as ALLRouter };
|
||||||
|
|||||||
@ -30,13 +30,8 @@ export interface WebUiCredentialJson {
|
|||||||
Data: WebUiCredentialInnerJson;
|
Data: WebUiCredentialInnerJson;
|
||||||
Hmac: string;
|
Hmac: string;
|
||||||
}
|
}
|
||||||
export enum NapCatCoreWorkingEnv {
|
|
||||||
Unknown = 0,
|
|
||||||
Shell = 1,
|
|
||||||
Framework = 2,
|
|
||||||
}
|
|
||||||
export interface LoginRuntimeType {
|
export interface LoginRuntimeType {
|
||||||
workingEnv: NapCatCoreWorkingEnv;
|
|
||||||
LoginCurrentTime: number;
|
LoginCurrentTime: number;
|
||||||
LoginCurrentRate: number;
|
LoginCurrentRate: number;
|
||||||
QQLoginStatus: boolean;
|
QQLoginStatus: boolean;
|
||||||
@ -47,7 +42,6 @@ export interface LoginRuntimeType {
|
|||||||
onQQLoginStatusChange: (status: boolean) => Promise<void>;
|
onQQLoginStatusChange: (status: boolean) => Promise<void>;
|
||||||
onWebUiTokenChange: (token: string) => Promise<void>;
|
onWebUiTokenChange: (token: string) => Promise<void>;
|
||||||
WebUiConfigQuickFunction: () => Promise<void>;
|
WebUiConfigQuickFunction: () => Promise<void>;
|
||||||
OneBotContext: any | null; // OneBot 上下文,用于调试功能
|
|
||||||
NapCatHelper: {
|
NapCatHelper: {
|
||||||
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
|
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
|
||||||
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
||||||
|
|||||||
@ -4,11 +4,9 @@ export const themeType = Type.Object(
|
|||||||
{
|
{
|
||||||
dark: Type.Record(Type.String(), Type.String()),
|
dark: Type.Record(Type.String(), Type.String()),
|
||||||
light: Type.Record(Type.String(), Type.String()),
|
light: Type.Record(Type.String(), Type.String()),
|
||||||
fontMode: Type.String({ default: 'system' }),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: {
|
default: {
|
||||||
fontMode: 'system',
|
|
||||||
dark: {
|
dark: {
|
||||||
'--heroui-background': '0 0% 0%',
|
'--heroui-background': '0 0% 0%',
|
||||||
'--heroui-foreground-50': '240 5.88% 10%',
|
'--heroui-foreground-50': '240 5.88% 10%',
|
||||||
|
|||||||
@ -4,51 +4,30 @@ import fs from 'fs';
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
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({
|
export const webUIFontStorage = multer.diskStorage({
|
||||||
destination: (_, __, cb) => {
|
destination: (_, __, cb) => {
|
||||||
try {
|
try {
|
||||||
const fontsPath = path.dirname(WebUiConfig.GetWebUIFontPathSync());
|
const fontsPath = path.dirname(WebUiConfig.GetWebUIFontPath());
|
||||||
// 确保字体目录存在
|
// 确保字体目录存在
|
||||||
fs.mkdirSync(fontsPath, { recursive: true });
|
fs.mkdirSync(fontsPath, { recursive: true });
|
||||||
// 清理旧的字体文件
|
|
||||||
cleanOldFontFiles(fontsPath);
|
|
||||||
cb(null, fontsPath);
|
cb(null, fontsPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 确保错误信息被正确传递
|
// 确保错误信息被正确传递
|
||||||
cb(new Error(`创建字体目录失败:${(error as Error).message}`), '');
|
cb(new Error(`创建字体目录失败:${(error as Error).message}`), '');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
filename: (_, file, cb) => {
|
filename: (_, __, cb) => {
|
||||||
// 保留原始扩展名,统一文件名为 webui
|
// 统一保存为webui.woff
|
||||||
const ext = path.extname(file.originalname).toLowerCase();
|
cb(null, 'webui.woff');
|
||||||
cb(null, `webui${ext}`);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const webUIFontUpload = multer({
|
export const webUIFontUpload = multer({
|
||||||
storage: webUIFontStorage,
|
storage: webUIFontStorage,
|
||||||
fileFilter: (_, file, cb) => {
|
fileFilter: (_, file, cb) => {
|
||||||
// 验证文件类型
|
// 再次验证文件类型
|
||||||
const ext = path.extname(file.originalname).toLowerCase();
|
if (!file.originalname.toLowerCase().endsWith('.woff')) {
|
||||||
if (!SUPPORTED_FONT_EXTENSIONS.includes(ext)) {
|
cb(new Error('只支持WOFF格式的字体文件'));
|
||||||
cb(new Error('只支持 WOFF/WOFF2/TTF/OTF 格式的字体文件'));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
@ -62,6 +41,8 @@ const webUIFontUploader = (req: Request, res: Response) => {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
webUIFontUpload(req, res, (error) => {
|
webUIFontUpload(req, res, (error) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
// 错误处理
|
||||||
|
// sendError(res, error.message, true);
|
||||||
return reject(error);
|
return reject(error);
|
||||||
}
|
}
|
||||||
return resolve(true);
|
return resolve(true);
|
||||||
|
|||||||
2
packages/napcat-webui-frontend/.gitignore
vendored
2
packages/napcat-webui-frontend/.gitignore
vendored
@ -26,5 +26,7 @@ dist-ssr
|
|||||||
# NPM LOCK files
|
# NPM LOCK files
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
|
||||||
dist.zip
|
dist.zip
|
||||||
@ -5,19 +5,12 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host=0.0.0.0",
|
"dev": "vite --host=0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "tsc && vite build",
|
||||||
"build:full": "tsc && vite build",
|
|
||||||
"fontmin": "node scripts/fontmin.cjs",
|
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
|
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@ -29,7 +22,6 @@
|
|||||||
"@heroui/checkbox": "2.3.9",
|
"@heroui/checkbox": "2.3.9",
|
||||||
"@heroui/chip": "2.2.7",
|
"@heroui/chip": "2.2.7",
|
||||||
"@heroui/code": "2.2.7",
|
"@heroui/code": "2.2.7",
|
||||||
"@heroui/divider": "^2.2.21",
|
|
||||||
"@heroui/dropdown": "2.3.10",
|
"@heroui/dropdown": "2.3.10",
|
||||||
"@heroui/form": "2.1.9",
|
"@heroui/form": "2.1.9",
|
||||||
"@heroui/image": "2.2.6",
|
"@heroui/image": "2.2.6",
|
||||||
@ -53,10 +45,10 @@
|
|||||||
"@heroui/theme": "2.4.6",
|
"@heroui/theme": "2.4.6",
|
||||||
"@heroui/tooltip": "2.2.8",
|
"@heroui/tooltip": "2.2.8",
|
||||||
"@monaco-editor/loader": "^1.4.0",
|
"@monaco-editor/loader": "^1.4.0",
|
||||||
|
"@monaco-editor/react": "4.7.0-rc.0",
|
||||||
"@react-aria/visually-hidden": "^3.8.19",
|
"@react-aria/visually-hidden": "^3.8.19",
|
||||||
"@reduxjs/toolkit": "^2.5.1",
|
"@reduxjs/toolkit": "^2.5.1",
|
||||||
"@uidotdev/usehooks": "^2.4.1",
|
"@uidotdev/usehooks": "^2.4.1",
|
||||||
"@uiw/react-codemirror": "^4.25.4",
|
|
||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
@ -65,7 +57,10 @@
|
|||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
|
"echarts": "^5.5.1",
|
||||||
"event-source-polyfill": "^1.0.31",
|
"event-source-polyfill": "^1.0.31",
|
||||||
|
"framer-motion": "^12.0.6",
|
||||||
|
"monaco-editor": "^0.52.2",
|
||||||
"motion": "^12.0.6",
|
"motion": "^12.0.6",
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"qface": "^1.4.1",
|
"qface": "^1.4.1",
|
||||||
@ -82,6 +77,7 @@
|
|||||||
"react-markdown": "^9.0.3",
|
"react-markdown": "^9.0.3",
|
||||||
"react-photo-view": "^1.2.7",
|
"react-photo-view": "^1.2.7",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
|
"react-responsive": "^10.0.0",
|
||||||
"react-router-dom": "^7.1.4",
|
"react-router-dom": "^7.1.4",
|
||||||
"react-use-websocket": "^4.11.1",
|
"react-use-websocket": "^4.11.1",
|
||||||
"react-window": "^1.8.11",
|
"react-window": "^1.8.11",
|
||||||
@ -109,15 +105,10 @@
|
|||||||
"eslint-plugin-node": "^11.1.0",
|
"eslint-plugin-node": "^11.1.0",
|
||||||
"eslint-plugin-prettier": "5.2.3",
|
"eslint-plugin-prettier": "5.2.3",
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"fontmin": "^0.9.9",
|
|
||||||
"glob": "^10.3.10",
|
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"sharp": "^0.34.5",
|
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.0.5",
|
"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-plugin-static-copy": "^2.2.0",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
@ -1,137 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
@ -7,6 +7,7 @@ import PageLoading from '@/components/page_loading';
|
|||||||
import Toaster from '@/components/toaster';
|
import Toaster from '@/components/toaster';
|
||||||
|
|
||||||
import DialogProvider from '@/contexts/dialog';
|
import DialogProvider from '@/contexts/dialog';
|
||||||
|
import AudioProvider from '@/contexts/songs';
|
||||||
|
|
||||||
import useAuth from '@/hooks/auth';
|
import useAuth from '@/hooks/auth';
|
||||||
|
|
||||||
@ -32,11 +33,13 @@ function App () {
|
|||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<PageBackground />
|
<PageBackground />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
<AudioProvider>
|
||||||
<Suspense fallback={<PageLoading />}>
|
<Suspense fallback={<PageLoading />}>
|
||||||
<AuthChecker>
|
<AuthChecker>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</AuthChecker>
|
</AuthChecker>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</AudioProvider>
|
||||||
</Provider>
|
</Provider>
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
425
packages/napcat-webui-frontend/src/components/audio_player.tsx
Normal file
425
packages/napcat-webui-frontend/src/components/audio_player.tsx
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
import { Button } from '@heroui/button';
|
||||||
|
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||||
|
import { Image } from '@heroui/image';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
|
||||||
|
import { Slider } from '@heroui/slider';
|
||||||
|
import { Tooltip } from '@heroui/tooltip';
|
||||||
|
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
BiSolidSkipNextCircle,
|
||||||
|
BiSolidSkipPreviousCircle,
|
||||||
|
} from 'react-icons/bi';
|
||||||
|
import {
|
||||||
|
FaPause,
|
||||||
|
FaPlay,
|
||||||
|
FaRegHandPointRight,
|
||||||
|
FaRepeat,
|
||||||
|
FaShuffle,
|
||||||
|
} from 'react-icons/fa6';
|
||||||
|
import { TbRepeatOnce } from 'react-icons/tb';
|
||||||
|
import { useMediaQuery } from 'react-responsive';
|
||||||
|
|
||||||
|
import { PlayMode } from '@/const/enum';
|
||||||
|
import key from '@/const/key';
|
||||||
|
|
||||||
|
import { VolumeHighIcon, VolumeLowIcon } from './icons';
|
||||||
|
|
||||||
|
export interface AudioPlayerProps
|
||||||
|
extends React.AudioHTMLAttributes<HTMLAudioElement> {
|
||||||
|
src: string
|
||||||
|
title?: string
|
||||||
|
artist?: string
|
||||||
|
cover?: string
|
||||||
|
pressNext?: () => void
|
||||||
|
pressPrevious?: () => void
|
||||||
|
onPlayEnd?: () => void
|
||||||
|
onChangeMode?: (mode: PlayMode) => void
|
||||||
|
mode?: PlayMode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AudioPlayer (props: AudioPlayerProps) {
|
||||||
|
const {
|
||||||
|
src,
|
||||||
|
pressNext,
|
||||||
|
pressPrevious,
|
||||||
|
cover = 'https://nextui.org/images/album-cover.png',
|
||||||
|
title = '未知',
|
||||||
|
artist = '未知',
|
||||||
|
onTimeUpdate,
|
||||||
|
onLoadedData,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onPlayEnd,
|
||||||
|
onChangeMode,
|
||||||
|
autoPlay,
|
||||||
|
mode = PlayMode.Loop,
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [volume, setVolume] = useState(100);
|
||||||
|
const [isCollapsed, setIsCollapsed] = useLocalStorage(
|
||||||
|
key.isCollapsedMusicPlayer,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const startY = useRef(0);
|
||||||
|
const startX = useRef(0);
|
||||||
|
const [translateY, setTranslateY] = useState(0);
|
||||||
|
const [translateX, setTranslateX] = useState(0);
|
||||||
|
const isSmallScreen = useMediaQuery({ maxWidth: 767 });
|
||||||
|
const isMediumUp = useMediaQuery({ minWidth: 768 });
|
||||||
|
const shouldAdd = useRef(false);
|
||||||
|
const currentProgress = (currentTime / duration) * 100;
|
||||||
|
const [storageAutoPlay, setStorageAutoPlay] = useLocalStorage(
|
||||||
|
key.autoPlay,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTimeUpdate = (event: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||||
|
const audio = event.target as HTMLAudioElement;
|
||||||
|
setCurrentTime(audio.currentTime);
|
||||||
|
onTimeUpdate?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedData = (event: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||||
|
const audio = event.target as HTMLAudioElement;
|
||||||
|
setDuration(audio.duration);
|
||||||
|
onLoadedData?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = (e: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
setStorageAutoPlay(true);
|
||||||
|
onPlay?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = (e: React.SyntheticEvent<HTMLAudioElement>) => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
onPause?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeMode = () => {
|
||||||
|
const modes = [PlayMode.Loop, PlayMode.Random, PlayMode.Single];
|
||||||
|
const currentIndex = modes.findIndex((_mode) => _mode === mode);
|
||||||
|
const nextIndex = currentIndex + 1;
|
||||||
|
const nextMode = modes[nextIndex] || modes[0];
|
||||||
|
onChangeMode?.(nextMode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const volumeChange = (value: number) => {
|
||||||
|
setVolume(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (audio) {
|
||||||
|
audio.volume = volume / 100;
|
||||||
|
}
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
startY.current = e.touches[0].clientY;
|
||||||
|
startX.current = e.touches[0].clientX;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
|
const deltaY = e.touches[0].clientY - startY.current;
|
||||||
|
const deltaX = e.touches[0].clientX - startX.current;
|
||||||
|
const container = cardRef.current;
|
||||||
|
const header = cardRef.current?.querySelector('[data-header]');
|
||||||
|
const headerHeight = header?.clientHeight || 20;
|
||||||
|
const addHeight = (container?.clientHeight || headerHeight) - headerHeight;
|
||||||
|
const _shouldAdd = isCollapsed && deltaY < 0;
|
||||||
|
if (isSmallScreen) {
|
||||||
|
shouldAdd.current = _shouldAdd;
|
||||||
|
setTranslateY(_shouldAdd ? deltaY + addHeight : deltaY);
|
||||||
|
} else {
|
||||||
|
setTranslateX(deltaX);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (isSmallScreen) {
|
||||||
|
const container = cardRef.current;
|
||||||
|
const header = cardRef.current?.querySelector('[data-header]');
|
||||||
|
const headerHeight = header?.clientHeight || 20;
|
||||||
|
const addHeight = (container?.clientHeight || headerHeight) - headerHeight;
|
||||||
|
const _translateY = translateY - (shouldAdd.current ? addHeight : 0);
|
||||||
|
if (_translateY > 100) {
|
||||||
|
setIsCollapsed(true);
|
||||||
|
} else if (_translateY < -100) {
|
||||||
|
setIsCollapsed(false);
|
||||||
|
}
|
||||||
|
setTranslateY(0);
|
||||||
|
} else {
|
||||||
|
if (translateX > 100) {
|
||||||
|
setIsCollapsed(true);
|
||||||
|
} else if (translateX < -100) {
|
||||||
|
setIsCollapsed(false);
|
||||||
|
}
|
||||||
|
setTranslateX(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dragTranslate = isSmallScreen
|
||||||
|
? translateY
|
||||||
|
? `translateY(${translateY}px)`
|
||||||
|
: ''
|
||||||
|
: translateX
|
||||||
|
? `translateX(${translateX}px)`
|
||||||
|
: '';
|
||||||
|
const collapsedTranslate = isCollapsed
|
||||||
|
? isSmallScreen
|
||||||
|
? 'translateY(90%)'
|
||||||
|
: 'translateX(96%)'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const translateStyle = dragTranslate || collapsedTranslate;
|
||||||
|
|
||||||
|
if (!src) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'fixed right-0 bottom-0 z-[52] w-full md:w-96',
|
||||||
|
!translateX && !translateY && 'transition-transform',
|
||||||
|
isCollapsed && 'md:hover:!translate-x-80'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: translateStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<audio
|
||||||
|
src={src}
|
||||||
|
onLoadedData={handleLoadedData}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onPlay={handlePlay}
|
||||||
|
onPause={handlePause}
|
||||||
|
onEnded={onPlayEnd}
|
||||||
|
autoPlay={autoPlay ?? storageAutoPlay}
|
||||||
|
{...rest}
|
||||||
|
controls={false}
|
||||||
|
hidden
|
||||||
|
ref={audioRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
ref={cardRef}
|
||||||
|
className={clsx(
|
||||||
|
'border-none bg-background/60 dark:bg-default-300/50 w-full max-w-full transform transition-transform backdrop-blur-md duration-300 overflow-visible',
|
||||||
|
isSmallScreen ? 'rounded-t-3xl' : 'md:rounded-l-xl'
|
||||||
|
)}
|
||||||
|
classNames={{
|
||||||
|
body: 'p-0',
|
||||||
|
}}
|
||||||
|
shadow='sm'
|
||||||
|
radius='none'
|
||||||
|
>
|
||||||
|
{isMediumUp && (
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className={clsx(
|
||||||
|
'absolute data-[hover]:bg-foreground/10 text-lg z-50',
|
||||||
|
isCollapsed
|
||||||
|
? 'top-0 left-0 w-full h-full rounded-xl bg-opacity-0 hover:bg-opacity-30'
|
||||||
|
: 'top-3 -left-8 rounded-l-full bg-opacity-50 backdrop-blur-md'
|
||||||
|
)}
|
||||||
|
variant='solid'
|
||||||
|
color='primary'
|
||||||
|
size='sm'
|
||||||
|
onPress={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
>
|
||||||
|
<FaRegHandPointRight />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isSmallScreen && (
|
||||||
|
<CardHeader
|
||||||
|
data-header
|
||||||
|
className='flex-row justify-center pt-4'
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
>
|
||||||
|
<div className='w-24 h-2 rounded-full bg-content2-foreground shadow-sm' />
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardBody>
|
||||||
|
<div className='grid grid-cols-6 md:grid-cols-12 gap-6 md:gap-4 items-center justify-center overflow-hidden p-6 md:p-2 m-0'>
|
||||||
|
<div className='relative col-span-6 md:col-span-4 flex justify-center'>
|
||||||
|
<Image
|
||||||
|
alt='Album cover'
|
||||||
|
className='object-cover'
|
||||||
|
classNames={{
|
||||||
|
wrapper: 'w-36 aspect-square md:w-24 flex',
|
||||||
|
img: 'block w-full h-full',
|
||||||
|
}}
|
||||||
|
shadow='md'
|
||||||
|
src={cover}
|
||||||
|
width='100%'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col col-span-6 md:col-span-8'>
|
||||||
|
<div className='flex flex-col gap-0'>
|
||||||
|
<h1 className='font-medium truncate'>{title}</h1>
|
||||||
|
<p className='text-xs text-foreground/80 truncate'>{artist}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
<Slider
|
||||||
|
aria-label='Music progress'
|
||||||
|
classNames={{
|
||||||
|
track: 'bg-default-500/30 border-none',
|
||||||
|
thumb: 'w-2 h-2 after:w-1.5 after:h-1.5',
|
||||||
|
filler: 'rounded-full',
|
||||||
|
}}
|
||||||
|
color='foreground'
|
||||||
|
value={currentProgress || 0}
|
||||||
|
defaultValue={0}
|
||||||
|
size='sm'
|
||||||
|
onChange={(value) => {
|
||||||
|
value = Array.isArray(value) ? value[0] : value;
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (audio) {
|
||||||
|
audio.currentTime = (value / 100) * duration;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='flex justify-between h-3'>
|
||||||
|
<p className='text-xs'>
|
||||||
|
{Math.floor(currentTime / 60)}:
|
||||||
|
{Math.floor(currentTime % 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}
|
||||||
|
</p>
|
||||||
|
<p className='text-xs text-foreground/50'>
|
||||||
|
{Math.floor(duration / 60)}:
|
||||||
|
{Math.floor(duration % 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex w-full items-center justify-center'>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
mode === PlayMode.Loop
|
||||||
|
? '列表循环'
|
||||||
|
: mode === PlayMode.Random
|
||||||
|
? '随机播放'
|
||||||
|
: '单曲循环'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className='data-[hover]:bg-foreground/10 text-lg md:text-medium'
|
||||||
|
radius='full'
|
||||||
|
variant='light'
|
||||||
|
size='md'
|
||||||
|
onPress={changeMode}
|
||||||
|
>
|
||||||
|
{mode === PlayMode.Loop && (
|
||||||
|
<FaRepeat className='text-foreground/80' />
|
||||||
|
)}
|
||||||
|
{mode === PlayMode.Random && (
|
||||||
|
<FaShuffle className='text-foreground/80' />
|
||||||
|
)}
|
||||||
|
{mode === PlayMode.Single && (
|
||||||
|
<TbRepeatOnce className='text-foreground/80 text-xl' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content='上一首'>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
|
||||||
|
radius='full'
|
||||||
|
variant='light'
|
||||||
|
size='md'
|
||||||
|
onPress={pressPrevious}
|
||||||
|
>
|
||||||
|
<BiSolidSkipPreviousCircle />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content={isPlaying ? '暂停' : '播放'}>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className='data-[hover]:bg-foreground/10 text-3xl md:text-3xl'
|
||||||
|
radius='full'
|
||||||
|
variant='light'
|
||||||
|
size='lg'
|
||||||
|
onPress={() => {
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current?.pause();
|
||||||
|
setStorageAutoPlay(false);
|
||||||
|
} else {
|
||||||
|
audioRef.current?.play();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPlaying ? <FaPause /> : <FaPlay className='ml-1' />}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content='下一首'>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
|
||||||
|
radius='full'
|
||||||
|
variant='light'
|
||||||
|
size='md'
|
||||||
|
onPress={pressNext}
|
||||||
|
>
|
||||||
|
<BiSolidSkipNextCircle />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Popover
|
||||||
|
placement='top'
|
||||||
|
classNames={{
|
||||||
|
content: 'bg-opacity-30 backdrop-blur-md',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
className='data-[hover]:bg-foreground/10 text-xl md:text-xl'
|
||||||
|
radius='full'
|
||||||
|
variant='light'
|
||||||
|
size='md'
|
||||||
|
>
|
||||||
|
<VolumeHighIcon />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent>
|
||||||
|
<Slider
|
||||||
|
orientation='vertical'
|
||||||
|
showTooltip
|
||||||
|
aria-label='Volume'
|
||||||
|
className='h-40'
|
||||||
|
color='primary'
|
||||||
|
defaultValue={volume}
|
||||||
|
onChange={(value) => {
|
||||||
|
value = Array.isArray(value) ? value[0] : value;
|
||||||
|
volumeChange(value);
|
||||||
|
}}
|
||||||
|
startContent={<VolumeHighIcon className='text-2xl' />}
|
||||||
|
size='sm'
|
||||||
|
endContent={<VolumeLowIcon className='text-2xl' />}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -18,7 +18,7 @@ import {
|
|||||||
} from '../icons';
|
} from '../icons';
|
||||||
|
|
||||||
export interface AddButtonProps {
|
export interface AddButtonProps {
|
||||||
onOpen: (key: keyof OneBotConfig['network']) => void;
|
onOpen: (key: keyof OneBotConfig['network']) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddButton: React.FC<AddButtonProps> = (props) => {
|
const AddButton: React.FC<AddButtonProps> = (props) => {
|
||||||
@ -33,7 +33,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<DropdownTrigger>
|
<DropdownTrigger>
|
||||||
<Button
|
<Button
|
||||||
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
color='primary'
|
||||||
startContent={<IoAddCircleOutline className='text-2xl' />}
|
startContent={<IoAddCircleOutline className='text-2xl' />}
|
||||||
>
|
>
|
||||||
新建
|
新建
|
||||||
@ -41,7 +41,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
|
|||||||
</DropdownTrigger>
|
</DropdownTrigger>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
aria-label='Create Network Config'
|
aria-label='Create Network Config'
|
||||||
color='default'
|
color='primary'
|
||||||
variant='flat'
|
variant='flat'
|
||||||
onAction={(key) => {
|
onAction={(key) => {
|
||||||
onOpen(key as keyof OneBotConfig['network']);
|
onOpen(key as keyof OneBotConfig['network']);
|
||||||
|
|||||||
@ -4,11 +4,11 @@ import toast from 'react-hot-toast';
|
|||||||
import { IoMdRefresh } from 'react-icons/io';
|
import { IoMdRefresh } from 'react-icons/io';
|
||||||
|
|
||||||
export interface SaveButtonsProps {
|
export interface SaveButtonsProps {
|
||||||
onSubmit: () => void;
|
onSubmit: () => void
|
||||||
reset: () => void;
|
reset: () => void
|
||||||
refresh?: () => void;
|
refresh?: () => void
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean
|
||||||
className?: string;
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SaveButtons: React.FC<SaveButtonsProps> = ({
|
const SaveButtons: React.FC<SaveButtonsProps> = ({
|
||||||
@ -20,15 +20,13 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'w-full flex flex-col justify-center gap-3',
|
'max-w-full mx-3 w-96 flex flex-col justify-center gap-3',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className='flex items-center justify-center gap-2 mt-5'>
|
<div className='flex items-center justify-center gap-2 mt-5'>
|
||||||
<Button
|
<Button
|
||||||
radius="full"
|
color='default'
|
||||||
variant="flat"
|
|
||||||
className="font-medium bg-default-100 text-default-600 dark:bg-default-50/50"
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
reset();
|
reset();
|
||||||
toast.success('重置成功');
|
toast.success('重置成功');
|
||||||
@ -38,8 +36,6 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color='primary'
|
color='primary'
|
||||||
radius="full"
|
|
||||||
className="font-medium shadow-md shadow-primary/20"
|
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
onPress={() => onSubmit()}
|
onPress={() => onSubmit()}
|
||||||
>
|
>
|
||||||
@ -48,12 +44,12 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
|
|||||||
{refresh && (
|
{refresh && (
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
|
color='secondary'
|
||||||
radius='full'
|
radius='full'
|
||||||
variant='flat'
|
variant='flat'
|
||||||
className="text-default-500 bg-default-100 dark:bg-default-50/50"
|
|
||||||
onPress={() => refresh()}
|
onPress={() => refresh()}
|
||||||
>
|
>
|
||||||
<IoMdRefresh size={20} />
|
<IoMdRefresh size={24} />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,27 +10,14 @@ import {
|
|||||||
|
|
||||||
import ChatInput from '.';
|
import ChatInput from '.';
|
||||||
|
|
||||||
interface ChatInputModalProps {
|
export default function ChatInputModal () {
|
||||||
children?: (onOpen: () => void) => React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChatInputModal ({ children }: ChatInputModalProps) {
|
|
||||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children ? children(onOpen) : (
|
<Button onPress={onOpen} color='primary' radius='full' variant='flat'>
|
||||||
<Button
|
构造聊天消息
|
||||||
onPress={onOpen}
|
|
||||||
color='primary'
|
|
||||||
radius='full'
|
|
||||||
variant='flat'
|
|
||||||
size='sm'
|
|
||||||
className="bg-primary/10 text-primary"
|
|
||||||
>
|
|
||||||
构造消息
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
<Modal
|
<Modal
|
||||||
size='4xl'
|
size='4xl'
|
||||||
scrollBehavior='inside'
|
scrollBehavior='inside'
|
||||||
|
|||||||
@ -1,126 +1,55 @@
|
|||||||
import React, { useImperativeHandle, useEffect, useState } from 'react';
|
import Editor, { OnMount, loader } from '@monaco-editor/react';
|
||||||
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
|
||||||
import { json } from '@codemirror/lang-json';
|
import React from 'react';
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
|
||||||
import { useTheme } from '@/hooks/use-theme';
|
import { useTheme } from '@/hooks/use-theme';
|
||||||
import { EditorView } from '@codemirror/view';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
const getLanguageExtension = (lang?: string) => {
|
import monaco from '@/monaco';
|
||||||
switch (lang) {
|
|
||||||
case 'json': return json();
|
|
||||||
default: return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CodeEditorProps {
|
loader.config({
|
||||||
value?: string;
|
monaco,
|
||||||
defaultValue?: string;
|
paths: {
|
||||||
language?: string;
|
vs: '/webui/monaco-editor/min/vs',
|
||||||
defaultLanguage?: string;
|
|
||||||
onChange?: (value: string | undefined) => void;
|
|
||||||
height?: string;
|
|
||||||
options?: any;
|
|
||||||
onMount?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodeEditorRef {
|
|
||||||
getValue: () => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (props.value !== undefined) {
|
|
||||||
setVal(props.value);
|
|
||||||
}
|
|
||||||
}, [props.value]);
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
loader.config({
|
||||||
|
'vs/nls': {
|
||||||
|
availableLanguages: { '*': 'zh-cn' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
|
||||||
|
test?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodeEditorRef = monaco.editor.IStandaloneCodeEditor;
|
||||||
|
|
||||||
|
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Editor
|
||||||
|
{...props}
|
||||||
|
onMount={handleEditorDidMount}
|
||||||
|
theme={isDark ? 'vs-dark' : 'light'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default CodeEditor;
|
export default CodeEditor;
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Button } from '@heroui/button';
|
import { Button, ButtonGroup } from '@heroui/button';
|
||||||
import { Switch } from '@heroui/switch';
|
import { Switch } from '@heroui/switch';
|
||||||
import clsx from 'clsx';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { CgDebug } from 'react-icons/cg';
|
import { CgDebug } from 'react-icons/cg';
|
||||||
import { FiEdit3 } from 'react-icons/fi';
|
import { FiEdit3 } from 'react-icons/fi';
|
||||||
@ -11,33 +10,33 @@ import DisplayCardContainer from './container';
|
|||||||
type NetworkType = OneBotConfig['network'];
|
type NetworkType = OneBotConfig['network'];
|
||||||
|
|
||||||
export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{
|
export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{
|
||||||
label: string;
|
label: string
|
||||||
value: NetworkType[T][0][keyof NetworkType[T][0]];
|
value: NetworkType[T][0][keyof NetworkType[T][0]]
|
||||||
render?: (
|
render?: (
|
||||||
value: NetworkType[T][0][keyof NetworkType[T][0]]
|
value: NetworkType[T][0][keyof NetworkType[T][0]]
|
||||||
) => React.ReactNode;
|
) => React.ReactNode
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export interface NetworkDisplayCardProps<T extends keyof NetworkType> {
|
export interface NetworkDisplayCardProps<T extends keyof NetworkType> {
|
||||||
data: NetworkType[T][0];
|
data: NetworkType[T][0]
|
||||||
typeLabel: string;
|
showType?: boolean
|
||||||
fields: NetworkDisplayCardFields<T>;
|
typeLabel: string
|
||||||
onEdit: () => void;
|
fields: NetworkDisplayCardFields<T>
|
||||||
onEnable: () => Promise<void>;
|
onEdit: () => void
|
||||||
onDelete: () => Promise<void>;
|
onEnable: () => Promise<void>
|
||||||
onEnableDebug: () => Promise<void>;
|
onDelete: () => Promise<void>
|
||||||
showType?: boolean;
|
onEnableDebug: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
const NetworkDisplayCard = <T extends keyof NetworkType>({
|
||||||
data,
|
data,
|
||||||
|
showType,
|
||||||
typeLabel,
|
typeLabel,
|
||||||
fields,
|
fields,
|
||||||
onEdit,
|
onEdit,
|
||||||
onEnable,
|
onEnable,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEnableDebug,
|
onEnableDebug,
|
||||||
showType,
|
|
||||||
}: NetworkDisplayCardProps<T>) => {
|
}: NetworkDisplayCardProps<T>) => {
|
||||||
const { name, enable, debug } = data;
|
const { name, enable, debug } = data;
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
@ -57,149 +56,79 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
|||||||
onEnableDebug().finally(() => setEditing(false));
|
onEnableDebug().finally(() => setEditing(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isFullWidthField = (label: string) => ['URL', 'Token', 'AccessToken'].includes(label);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisplayCardContainer
|
<DisplayCardContainer
|
||||||
className='w-full max-w-[420px]'
|
|
||||||
tag={showType ? typeLabel : undefined}
|
|
||||||
action={
|
action={
|
||||||
<div className='flex gap-2 w-full'>
|
<ButtonGroup
|
||||||
<Button
|
|
||||||
fullWidth
|
fullWidth
|
||||||
radius='full'
|
isDisabled={editing}
|
||||||
|
radius='sm'
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='flat'
|
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'
|
>
|
||||||
|
<Button
|
||||||
|
color='warning'
|
||||||
startContent={<FiEdit3 size={16} />}
|
startContent={<FiEdit3 size={16} />}
|
||||||
onPress={onEdit}
|
onPress={onEdit}
|
||||||
isDisabled={editing}
|
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
color={debug ? 'secondary' : 'success'}
|
||||||
radius='full'
|
|
||||||
size='sm'
|
|
||||||
variant='flat'
|
variant='flat'
|
||||||
className={clsx(
|
startContent={
|
||||||
'flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors',
|
<CgDebug
|
||||||
debug
|
style={{
|
||||||
? 'hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary'
|
width: '16px',
|
||||||
: 'hover:bg-success/20 hover:text-success data-[hover=true]:text-success'
|
height: '16px',
|
||||||
)}
|
minWidth: '16px',
|
||||||
startContent={<CgDebug size={16} />}
|
minHeight: '16px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
onPress={handleEnableDebug}
|
onPress={handleEnableDebug}
|
||||||
isDisabled={editing}
|
|
||||||
>
|
>
|
||||||
{debug ? '关闭调试' : '开启调试'}
|
{debug ? '关闭调试' : '开启调试'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
className='bg-danger/20 text-danger hover:bg-danger/30 transition-colors'
|
||||||
radius='full'
|
|
||||||
size='sm'
|
|
||||||
variant='flat'
|
variant='flat'
|
||||||
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-danger/20 hover:text-danger transition-colors'
|
|
||||||
startContent={<MdDeleteForever size={16} />}
|
startContent={<MdDeleteForever size={16} />}
|
||||||
onPress={handleDelete}
|
onPress={handleDelete}
|
||||||
isDisabled={editing}
|
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</ButtonGroup>
|
||||||
}
|
}
|
||||||
enableSwitch={
|
enableSwitch={
|
||||||
<Switch
|
<Switch
|
||||||
isDisabled={editing}
|
isDisabled={editing}
|
||||||
isSelected={enable}
|
isSelected={enable}
|
||||||
onChange={handleEnable}
|
onChange={handleEnable}
|
||||||
classNames={{
|
|
||||||
wrapper: 'group-data-[selected=true]:bg-primary-400',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
tag={showType && typeLabel}
|
||||||
title={name}
|
title={name}
|
||||||
>
|
>
|
||||||
<div className='grid grid-cols-2 gap-3'>
|
<div className='grid grid-cols-2 gap-1'>
|
||||||
{(() => {
|
{fields.map((field, index) => (
|
||||||
const targetFullField = fields.find(f => isFullWidthField(f.label));
|
|
||||||
|
|
||||||
if (targetFullField) {
|
|
||||||
// 模式1:存在全宽字段(如URL),布局为:
|
|
||||||
// Row 1: 类型 (全宽)
|
|
||||||
// Row 2: 全宽字段 (全宽)
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
|
|
||||||
>
|
|
||||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>类型</span>
|
|
||||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
|
||||||
{typeLabel}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
|
|
||||||
>
|
|
||||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{targetFullField.label}</span>
|
|
||||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
|
||||||
{targetFullField.render
|
|
||||||
? targetFullField.render(targetFullField.value)
|
|
||||||
: (
|
|
||||||
<span className={clsx(
|
|
||||||
typeof targetFullField.value === 'string' && (targetFullField.value.startsWith('http') || targetFullField.value.includes('.') || targetFullField.value.includes(':')) ? 'font-mono' : ''
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{String(targetFullField.value)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 模式2:无全宽字段,布局为 4 个小块 (2行 x 2列)
|
|
||||||
// Row 1: 类型 | Field 0
|
|
||||||
// Row 2: Field 1 | Field 2
|
|
||||||
const displayFields = fields.slice(0, 3);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
|
|
||||||
>
|
|
||||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>类型</span>
|
|
||||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
|
||||||
{typeLabel}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{displayFields.map((field, index) => (
|
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
|
className={`flex items-center gap-2 ${
|
||||||
|
field.label === 'URL' ? 'col-span-2' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{field.label}</span>
|
<span className='text-default-400'>{field.label}</span>
|
||||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
|
||||||
{field.render
|
{field.render
|
||||||
? (
|
? (
|
||||||
field.render(field.value)
|
field.render(field.value)
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<span className={clsx(
|
<span>{field.value}</span>
|
||||||
typeof field.value === 'string' && (field.value.startsWith('http') || field.value.includes('.') || field.value.includes(':')) ? 'font-mono' : ''
|
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
{String(field.value)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* 如果字段不足3个,可以补充空白块占位吗?或者是让它空着?用户说要高度一致。只要是grid,通常高度会被撑开。目前这样应该能保证最多2行。 */}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</DisplayCardContainer>
|
</DisplayCardContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,54 +1,55 @@
|
|||||||
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card';
|
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card';
|
||||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import key from '@/const/key';
|
|
||||||
|
import { title } from '../primitives';
|
||||||
|
|
||||||
export interface ContainerProps {
|
export interface ContainerProps {
|
||||||
title: string;
|
title: string
|
||||||
tag?: React.ReactNode;
|
tag?: React.ReactNode
|
||||||
action: React.ReactNode;
|
action: React.ReactNode
|
||||||
enableSwitch: React.ReactNode;
|
enableSwitch: React.ReactNode
|
||||||
children: React.ReactNode;
|
children: React.ReactNode
|
||||||
className?: string; // Add className prop
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DisplayCardProps {
|
export interface DisplayCardProps {
|
||||||
showType?: boolean;
|
showType?: boolean
|
||||||
onEdit: () => void;
|
onEdit: () => void
|
||||||
onEnable: () => Promise<void>;
|
onEnable: () => Promise<void>
|
||||||
onDelete: () => Promise<void>;
|
onDelete: () => Promise<void>
|
||||||
onEnableDebug: () => Promise<void>;
|
onEnableDebug: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const DisplayCardContainer: React.FC<ContainerProps> = ({
|
const DisplayCardContainer: React.FC<ContainerProps> = ({
|
||||||
title: _title,
|
title: _title,
|
||||||
action,
|
action,
|
||||||
|
tag,
|
||||||
enableSwitch,
|
enableSwitch,
|
||||||
children,
|
children,
|
||||||
className,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
|
||||||
const hasBackground = !!backgroundImage;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={clsx(
|
<Card className='bg-opacity-50 backdrop-blur-sm'>
|
||||||
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm rounded-2xl overflow-hidden transition-all',
|
<CardHeader className='pb-0 flex items-center'>
|
||||||
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40',
|
{tag && (
|
||||||
className
|
<div className='text-center text-default-400 mb-1 absolute top-0 left-1/2 -translate-x-1/2 text-sm pointer-events-none bg-warning-100 dark:bg-warning-50 px-2 rounded-b'>
|
||||||
|
{tag}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h2
|
||||||
|
className={clsx(
|
||||||
|
title({
|
||||||
|
color: 'foreground',
|
||||||
|
size: 'xs',
|
||||||
|
shadow: true,
|
||||||
|
}),
|
||||||
|
'truncate'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader className='p-4 pb-2 flex items-center justify-between gap-3'>
|
|
||||||
<div className='flex-1 min-w-0 mr-2'>
|
|
||||||
<div className='inline-flex items-center px-3 py-1 rounded-lg bg-default-100/50 dark:bg-white/10 border border-transparent dark:border-white/5'>
|
|
||||||
<span className='font-bold text-default-600 dark:text-white/90 text-sm truncate select-text'>
|
|
||||||
{_title}
|
{_title}
|
||||||
</span>
|
</h2>
|
||||||
</div>
|
<div className='ml-auto'>{enableSwitch}</div>
|
||||||
</div>
|
|
||||||
<div className='flex-shrink-0'>{enableSwitch}</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody className='px-4 py-2 text-sm text-default-600'>{children}</CardBody>
|
<CardBody className='text-sm'>{children}</CardBody>
|
||||||
<CardFooter className='px-4 pb-4 pt-2'>{action}</CardFooter>
|
<CardFooter>{action}</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
|
|||||||
import type { NetworkDisplayCardFields } from './common_card';
|
import type { NetworkDisplayCardFields } from './common_card';
|
||||||
|
|
||||||
interface HTTPClientDisplayCardProps {
|
interface HTTPClientDisplayCardProps {
|
||||||
data: OneBotConfig['network']['httpClients'][0];
|
data: OneBotConfig['network']['httpClients'][0]
|
||||||
showType?: boolean;
|
showType?: boolean
|
||||||
onEdit: () => void;
|
onEdit: () => void
|
||||||
onEnable: () => Promise<void>;
|
onEnable: () => Promise<void>
|
||||||
onDelete: () => Promise<void>;
|
onDelete: () => Promise<void>
|
||||||
onEnableDebug: () => Promise<void>;
|
onEnableDebug: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const HTTPClientDisplayCard: React.FC<HTTPClientDisplayCardProps> = (props) => {
|
const HTTPClientDisplayCard: React.FC<HTTPClientDisplayCardProps> = (props) => {
|
||||||
|
|||||||
@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
|
|||||||
import type { NetworkDisplayCardFields } from './common_card';
|
import type { NetworkDisplayCardFields } from './common_card';
|
||||||
|
|
||||||
interface HTTPServerDisplayCardProps {
|
interface HTTPServerDisplayCardProps {
|
||||||
data: OneBotConfig['network']['httpServers'][0];
|
data: OneBotConfig['network']['httpServers'][0]
|
||||||
showType?: boolean;
|
showType?: boolean
|
||||||
onEdit: () => void;
|
onEdit: () => void
|
||||||
onEnable: () => Promise<void>;
|
onEnable: () => Promise<void>
|
||||||
onDelete: () => Promise<void>;
|
onDelete: () => Promise<void>
|
||||||
onEnableDebug: () => Promise<void>;
|
onEnableDebug: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const HTTPServerDisplayCard: React.FC<HTTPServerDisplayCardProps> = (props) => {
|
const HTTPServerDisplayCard: React.FC<HTTPServerDisplayCardProps> = (props) => {
|
||||||
|
|||||||
@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
|
|||||||
import type { NetworkDisplayCardFields } from './common_card';
|
import type { NetworkDisplayCardFields } from './common_card';
|
||||||
|
|
||||||
interface HTTPSSEServerDisplayCardProps {
|
interface HTTPSSEServerDisplayCardProps {
|
||||||
data: OneBotConfig['network']['httpSseServers'][0];
|
data: OneBotConfig['network']['httpSseServers'][0]
|
||||||
showType?: boolean;
|
showType?: boolean
|
||||||
onEdit: () => void;
|
onEdit: () => void
|
||||||
onEnable: () => Promise<void>;
|
onEnable: () => Promise<void>
|
||||||
onDelete: () => Promise<void>;
|
onDelete: () => Promise<void>
|
||||||
onEnableDebug: () => Promise<void>;
|
onEnableDebug: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const HTTPSSEServerDisplayCard: React.FC<HTTPSSEServerDisplayCardProps> = (
|
const HTTPSSEServerDisplayCard: React.FC<HTTPSSEServerDisplayCardProps> = (
|
||||||
|
|||||||
@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
|
|||||||
import type { NetworkDisplayCardFields } from './common_card';
|
import type { NetworkDisplayCardFields } from './common_card';
|
||||||
|
|
||||||
interface WebsocketClientDisplayCardProps {
|
interface WebsocketClientDisplayCardProps {
|
||||||
data: OneBotConfig['network']['websocketClients'][0];
|
data: OneBotConfig['network']['websocketClients'][0]
|
||||||
showType?: boolean;
|
showType?: boolean
|
||||||
onEdit: () => void;
|
onEdit: () => void
|
||||||
onEnable: () => Promise<void>;
|
onEnable: () => Promise<void>
|
||||||
onDelete: () => Promise<void>;
|
onDelete: () => Promise<void>
|
||||||
onEnableDebug: () => Promise<void>;
|
onEnableDebug: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = (
|
const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = (
|
||||||
|
|||||||
@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
|
|||||||
import type { NetworkDisplayCardFields } from './common_card';
|
import type { NetworkDisplayCardFields } from './common_card';
|
||||||
|
|
||||||
interface WebsocketServerDisplayCardProps {
|
interface WebsocketServerDisplayCardProps {
|
||||||
data: OneBotConfig['network']['websocketServers'][0];
|
data: OneBotConfig['network']['websocketServers'][0]
|
||||||
showType?: boolean;
|
showType?: boolean
|
||||||
onEdit: () => void;
|
onEdit: () => void
|
||||||
onEnable: () => Promise<void>;
|
onEnable: () => Promise<void>
|
||||||
onDelete: () => Promise<void>;
|
onDelete: () => Promise<void>
|
||||||
onEnableDebug: () => Promise<void>;
|
onEnableDebug: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const WebsocketServerDisplayCard: React.FC<WebsocketServerDisplayCardProps> = (
|
const WebsocketServerDisplayCard: React.FC<WebsocketServerDisplayCardProps> = (
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
import { Card, CardBody } from '@heroui/card';
|
import { Card, CardBody } from '@heroui/card';
|
||||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import key from '@/const/key';
|
|
||||||
|
|
||||||
|
|
||||||
|
import { title } from '@/components/primitives';
|
||||||
|
|
||||||
export interface NetworkItemDisplayProps {
|
export interface NetworkItemDisplayProps {
|
||||||
count: number;
|
count: number
|
||||||
label: string;
|
label: string
|
||||||
size?: 'sm' | 'md';
|
size?: 'sm' | 'md'
|
||||||
}
|
}
|
||||||
|
|
||||||
const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
|
const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
|
||||||
@ -16,37 +14,38 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
|
|||||||
label,
|
label,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
}) => {
|
}) => {
|
||||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
|
||||||
const hasBackground = !!backgroundImage;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all',
|
'bg-opacity-60 shadow-sm md:rounded-3xl',
|
||||||
hasBackground
|
|
||||||
? 'bg-white/10 dark:bg-black/10 hover:bg-white/20 dark:hover:bg-black/20'
|
|
||||||
: 'bg-white/60 dark:bg-black/40 hover:bg-white/70 dark:hover:bg-black/30',
|
|
||||||
size === 'md'
|
size === 'md'
|
||||||
? 'col-span-8 md:col-span-2'
|
? 'col-span-8 md:col-span-2 bg-primary-50 shadow-primary-100'
|
||||||
: 'col-span-2 md:col-span-1'
|
: 'col-span-2 md:col-span-1 bg-warning-100 shadow-warning-200'
|
||||||
)}
|
)}
|
||||||
shadow='none'
|
shadow='sm'
|
||||||
>
|
>
|
||||||
<CardBody className='items-center md:gap-1 p-1 md:p-2'>
|
<CardBody className='items-center md:gap-1 p-1 md:p-2'>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex-1 font-mono font-bold',
|
'flex-1',
|
||||||
size === 'md' ? 'text-4xl md:text-5xl' : 'text-2xl md:text-3xl',
|
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
|
||||||
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
|
title({
|
||||||
|
color: size === 'md' ? 'pink' : 'yellow',
|
||||||
|
size,
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{count}
|
{count}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'whitespace-nowrap text-nowrap flex-shrink-0 font-medium',
|
'whitespace-nowrap text-nowrap flex-shrink-0',
|
||||||
size === 'md' ? 'text-sm' : 'text-xs',
|
size === 'md' ? 'text-sm md:text-base' : 'text-xs md:text-sm',
|
||||||
hasBackground ? 'text-white/80' : 'text-default-500'
|
title({
|
||||||
|
color: size === 'md' ? 'pink' : 'yellow',
|
||||||
|
shadow: true,
|
||||||
|
size: 'xxs',
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@ -94,7 +94,7 @@ const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
|
|||||||
ref={lightRef}
|
ref={lightRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
isShowLight ? 'opacity-100' : 'opacity-0',
|
isShowLight ? 'opacity-100' : 'opacity-0',
|
||||||
'absolute rounded-full blur-[100px] filter transition-opacity duration-300 bg-gradient-to-r from-primary-400 to-secondary-400 w-[150px] h-[150px]',
|
'absolute rounded-full blur-[150px] filter transition-opacity duration-300 dark:bg-[#2850ff] bg-[#ff4132] w-[100px] h-[100px]',
|
||||||
lightClassName
|
lightClassName
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -9,13 +9,13 @@ import {
|
|||||||
} from '@heroui/modal';
|
} from '@heroui/modal';
|
||||||
|
|
||||||
interface CreateFileModalProps {
|
interface CreateFileModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean
|
||||||
fileType: 'file' | 'directory';
|
fileType: 'file' | 'directory'
|
||||||
newFileName: string;
|
newFileName: string
|
||||||
onTypeChange: (type: 'file' | 'directory') => void;
|
onTypeChange: (type: 'file' | 'directory') => void
|
||||||
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
onClose: () => void;
|
onClose: () => void
|
||||||
onCreate: () => void;
|
onCreate: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CreateFileModal ({
|
export default function CreateFileModal ({
|
||||||
@ -28,12 +28,12 @@ export default function CreateFileModal ({
|
|||||||
onCreate,
|
onCreate,
|
||||||
}: CreateFileModalProps) {
|
}: CreateFileModalProps) {
|
||||||
return (
|
return (
|
||||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose}>
|
<Modal isOpen={isOpen} onClose={onClose}>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader>新建</ModalHeader>
|
<ModalHeader>新建</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<div className='flex flex-col gap-4'>
|
<div className='flex flex-col gap-4'>
|
||||||
<ButtonGroup radius='sm' color='primary'>
|
<ButtonGroup color='primary'>
|
||||||
<Button
|
<Button
|
||||||
variant={fileType === 'file' ? 'solid' : 'flat'}
|
variant={fileType === 'file' ? 'solid' : 'flat'}
|
||||||
onPress={() => onTypeChange('file')}
|
onPress={() => onTypeChange('file')}
|
||||||
@ -47,14 +47,14 @@ export default function CreateFileModal ({
|
|||||||
目录
|
目录
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
<Input radius='sm' label='名称' value={newFileName} onChange={onNameChange} />
|
<Input label='名称' value={newFileName} onChange={onNameChange} />
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
<Button color='primary' variant='flat' onPress={onClose}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button radius='sm' color='primary' onPress={onCreate}>
|
<Button color='primary' onPress={onCreate}>
|
||||||
创建
|
创建
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
|||||||
@ -11,11 +11,11 @@ import {
|
|||||||
import CodeEditor from '@/components/code_editor';
|
import CodeEditor from '@/components/code_editor';
|
||||||
|
|
||||||
interface FileEditModalProps {
|
interface FileEditModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean
|
||||||
file: { path: string; content: string; } | null;
|
file: { path: string; content: string } | null
|
||||||
onClose: () => void;
|
onClose: () => void
|
||||||
onSave: () => void;
|
onSave: () => void
|
||||||
onContentChange: (newContent?: string) => void;
|
onContentChange: (newContent?: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FileEditModal ({
|
export default function FileEditModal ({
|
||||||
@ -63,22 +63,14 @@ export default function FileEditModal ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal radius='sm' size='full' isOpen={isOpen} onClose={onClose}>
|
<Modal size='full' isOpen={isOpen} onClose={onClose}>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader className='flex items-center gap-2 border-b border-default-200/50'>
|
<ModalHeader className='flex items-center gap-2 bg-content2 bg-opacity-50'>
|
||||||
<span>编辑文件</span>
|
<span>编辑文件</span>
|
||||||
<Code radius='sm' className='text-xs'>{file?.path}</Code>
|
<Code 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>
|
</ModalHeader>
|
||||||
<ModalBody className='p-4 bg-content2/50'>
|
<ModalBody className='p-0'>
|
||||||
<div className='h-full' onKeyDown={(e) => {
|
<div className='h-full'>
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
||||||
e.preventDefault();
|
|
||||||
onSave();
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
height='100%'
|
height='100%'
|
||||||
value={file?.content || ''}
|
value={file?.content || ''}
|
||||||
@ -88,11 +80,11 @@ export default function FileEditModal ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter className="border-t border-default-200/50">
|
<ModalFooter>
|
||||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
<Button color='primary' variant='flat' onPress={onClose}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button radius='sm' color='primary' onPress={onSave}>
|
<Button color='primary' onPress={onSave}>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
|||||||
@ -14,9 +14,9 @@ import { useEffect } from 'react';
|
|||||||
import FileManager from '@/controllers/file_manager';
|
import FileManager from '@/controllers/file_manager';
|
||||||
|
|
||||||
interface FilePreviewModalProps {
|
interface FilePreviewModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean
|
||||||
filePath: string;
|
filePath: string
|
||||||
onClose: () => void;
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const videoExts = ['.mp4', '.webm'];
|
export const videoExts = ['.mp4', '.webm'];
|
||||||
@ -75,14 +75,14 @@ export default function FilePreviewModal ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose} scrollBehavior='inside' size='3xl'>
|
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior='inside' size='3xl'>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader>文件预览</ModalHeader>
|
<ModalHeader>文件预览</ModalHeader>
|
||||||
<ModalBody className='flex justify-center items-center'>
|
<ModalBody className='flex justify-center items-center'>
|
||||||
{contentElement}
|
{contentElement}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
<Button color='primary' variant='flat' onPress={onClose}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
|||||||
@ -25,21 +25,21 @@ import { supportedPreviewExts } from './file_preview_modal';
|
|||||||
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button';
|
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button';
|
||||||
|
|
||||||
export interface FileTableProps {
|
export interface FileTableProps {
|
||||||
files: FileInfo[];
|
files: FileInfo[]
|
||||||
currentPath: string;
|
currentPath: string
|
||||||
loading: boolean;
|
loading: boolean
|
||||||
sortDescriptor: SortDescriptor;
|
sortDescriptor: SortDescriptor
|
||||||
onSortChange: (descriptor: SortDescriptor) => void;
|
onSortChange: (descriptor: SortDescriptor) => void
|
||||||
selectedFiles: Selection;
|
selectedFiles: Selection
|
||||||
onSelectionChange: (selected: Selection) => void;
|
onSelectionChange: (selected: Selection) => void
|
||||||
onDirectoryClick: (dirPath: string) => void;
|
onDirectoryClick: (dirPath: string) => void
|
||||||
onEdit: (filePath: string) => void;
|
onEdit: (filePath: string) => void
|
||||||
onPreview: (filePath: string) => void;
|
onPreview: (filePath: string) => void
|
||||||
onRenameRequest: (name: string) => void;
|
onRenameRequest: (name: string) => void
|
||||||
onMoveRequest: (name: string) => void;
|
onMoveRequest: (name: string) => void
|
||||||
onCopyPath: (fileName: string) => void;
|
onCopyPath: (fileName: string) => void
|
||||||
onDelete: (filePath: string) => void;
|
onDelete: (filePath: string) => void
|
||||||
onDownload: (filePath: string) => void;
|
onDownload: (filePath: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@ -105,7 +105,6 @@ export default function FileTable ({
|
|||||||
/>
|
/>
|
||||||
<Table
|
<Table
|
||||||
aria-label='文件列表'
|
aria-label='文件列表'
|
||||||
radius='sm'
|
|
||||||
sortDescriptor={sortDescriptor}
|
sortDescriptor={sortDescriptor}
|
||||||
onSortChange={onSortChange}
|
onSortChange={onSortChange}
|
||||||
onSelectionChange={onSelectionChange}
|
onSelectionChange={onSelectionChange}
|
||||||
@ -113,7 +112,7 @@ export default function FileTable ({
|
|||||||
selectedKeys={selectedFiles}
|
selectedKeys={selectedFiles}
|
||||||
selectionMode='multiple'
|
selectionMode='multiple'
|
||||||
bottomContent={
|
bottomContent={
|
||||||
<div className='flex w-full justify-center p-2 border-t border-white/10'>
|
<div className='flex w-full justify-center'>
|
||||||
<Pagination
|
<Pagination
|
||||||
isCompact
|
isCompact
|
||||||
showControls
|
showControls
|
||||||
@ -122,29 +121,21 @@ export default function FileTable ({
|
|||||||
page={page}
|
page={page}
|
||||||
total={pages}
|
total={pages}
|
||||||
onChange={(page) => setPage(page)}
|
onChange={(page) => setPage(page)}
|
||||||
classNames={{
|
|
||||||
cursor: 'bg-primary shadow-lg',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
classNames={{
|
|
||||||
wrapper: 'bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm p-0',
|
|
||||||
th: 'bg-white/40 dark:bg-white/5 backdrop-blur-md text-default-600',
|
|
||||||
td: 'group-data-[first=true]:first:before:rounded-none group-data-[first=true]:last:before:rounded-none',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableColumn key='name' allowsSorting>
|
<TableColumn key='name' allowsSorting>
|
||||||
名称
|
名称
|
||||||
</TableColumn>
|
</TableColumn>
|
||||||
<TableColumn key='type' allowsSorting className='hidden md:table-cell'>
|
<TableColumn key='type' allowsSorting>
|
||||||
类型
|
类型
|
||||||
</TableColumn>
|
</TableColumn>
|
||||||
<TableColumn key='size' allowsSorting className='hidden md:table-cell'>
|
<TableColumn key='size' allowsSorting>
|
||||||
大小
|
大小
|
||||||
</TableColumn>
|
</TableColumn>
|
||||||
<TableColumn key='mtime' allowsSorting className='hidden md:table-cell'>
|
<TableColumn key='mtime' allowsSorting>
|
||||||
修改时间
|
修改时间
|
||||||
</TableColumn>
|
</TableColumn>
|
||||||
<TableColumn key='actions'>操作</TableColumn>
|
<TableColumn key='actions'>操作</TableColumn>
|
||||||
@ -176,7 +167,6 @@ export default function FileTable ({
|
|||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<Button
|
<Button
|
||||||
radius='sm'
|
|
||||||
variant='light'
|
variant='light'
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
file.isDirectory
|
file.isDirectory
|
||||||
@ -196,51 +186,51 @@ export default function FileTable ({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className='hidden md:table-cell'>{file.isDirectory ? '目录' : '文件'}</TableCell>
|
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
|
||||||
<TableCell className='hidden md:table-cell'>
|
<TableCell>
|
||||||
{isNaN(file.size) || file.isDirectory
|
{isNaN(file.size) || file.isDirectory
|
||||||
? '-'
|
? '-'
|
||||||
: `${file.size} 字节`}
|
: `${file.size} 字节`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className='hidden md:table-cell'>{new Date(file.mtime).toLocaleString()}</TableCell>
|
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<ButtonGroup radius='sm' size='sm' variant='light'>
|
<ButtonGroup size='sm'>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
color='default'
|
color='primary'
|
||||||
className='text-default-500 hover:text-primary'
|
variant='flat'
|
||||||
onPress={() => onRenameRequest(file.name)}
|
onPress={() => onRenameRequest(file.name)}
|
||||||
>
|
>
|
||||||
<BiRename />
|
<BiRename />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
color='default'
|
color='primary'
|
||||||
className='text-default-500 hover:text-primary'
|
variant='flat'
|
||||||
onPress={() => onMoveRequest(file.name)}
|
onPress={() => onMoveRequest(file.name)}
|
||||||
>
|
>
|
||||||
<FiMove />
|
<FiMove />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
color='default'
|
color='primary'
|
||||||
className='text-default-500 hover:text-primary'
|
variant='flat'
|
||||||
onPress={() => onCopyPath(file.name)}
|
onPress={() => onCopyPath(file.name)}
|
||||||
>
|
>
|
||||||
<FiCopy />
|
<FiCopy />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
color='default'
|
color='primary'
|
||||||
className='text-default-500 hover:text-primary'
|
variant='flat'
|
||||||
onPress={() => onDownload(filePath)}
|
onPress={() => onDownload(filePath)}
|
||||||
>
|
>
|
||||||
<FiDownload />
|
<FiDownload />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
color='danger'
|
color='primary'
|
||||||
className='text-danger hover:bg-danger/10'
|
variant='flat'
|
||||||
onPress={() => onDelete(filePath)}
|
onPress={() => onDelete(filePath)}
|
||||||
>
|
>
|
||||||
<FiTrash2 />
|
<FiTrash2 />
|
||||||
|
|||||||
@ -10,17 +10,17 @@ import FileManager from '@/controllers/file_manager';
|
|||||||
import FileIcon from '../file_icon';
|
import FileIcon from '../file_icon';
|
||||||
|
|
||||||
export interface PreviewImage {
|
export interface PreviewImage {
|
||||||
key: string;
|
key: string
|
||||||
src: string;
|
src: string
|
||||||
alt: string;
|
alt: string
|
||||||
}
|
}
|
||||||
export const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp'];
|
export const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp'];
|
||||||
|
|
||||||
export interface ImageNameButtonProps {
|
export interface ImageNameButtonProps {
|
||||||
name: string;
|
name: string
|
||||||
filePath: string;
|
filePath: string
|
||||||
onPreview: () => void;
|
onPreview: () => void
|
||||||
onAddPreview: (image: PreviewImage) => void;
|
onAddPreview: (image: PreviewImage) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ImageNameButton ({
|
export default function ImageNameButton ({
|
||||||
@ -61,7 +61,6 @@ export default function ImageNameButton ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
radius='sm'
|
|
||||||
variant='light'
|
variant='light'
|
||||||
className='text-left justify-start'
|
className='text-left justify-start'
|
||||||
onPress={onPreview}
|
onPress={onPreview}
|
||||||
|
|||||||
@ -83,16 +83,15 @@ function DirectoryTree ({
|
|||||||
return (
|
return (
|
||||||
<div className='ml-4'>
|
<div className='ml-4'>
|
||||||
<Button
|
<Button
|
||||||
radius='sm'
|
|
||||||
onPress={handleClick}
|
onPress={handleClick}
|
||||||
className='py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-sm'
|
className='py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md'
|
||||||
size='sm'
|
size='sm'
|
||||||
color='primary'
|
color='primary'
|
||||||
variant={variant}
|
variant={variant}
|
||||||
startContent={
|
startContent={
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'rounded-sm',
|
'rounded-md',
|
||||||
isSeleted ? 'bg-primary-600' : 'bg-primary-50'
|
isSeleted ? 'bg-primary-600' : 'bg-primary-50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -141,11 +140,11 @@ export default function MoveModal ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
}: MoveModalProps) {
|
}: MoveModalProps) {
|
||||||
return (
|
return (
|
||||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose}>
|
<Modal isOpen={isOpen} onClose={onClose}>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader>选择目标目录</ModalHeader>
|
<ModalHeader>选择目标目录</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<div className='rounded-sm p-2 border border-default-300 overflow-auto max-h-60'>
|
<div className='rounded-md p-2 border border-default-300 overflow-auto max-h-60'>
|
||||||
<DirectoryTree
|
<DirectoryTree
|
||||||
basePath='/'
|
basePath='/'
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
@ -158,10 +157,10 @@ export default function MoveModal ({
|
|||||||
<p className='text-sm text-default-500'>移动项:{selectionInfo}</p>
|
<p className='text-sm text-default-500'>移动项:{selectionInfo}</p>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
<Button color='primary' variant='flat' onPress={onClose}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button radius='sm' color='primary' onPress={onMove}>
|
<Button color='primary' onPress={onMove}>
|
||||||
确定
|
确定
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user