Compare commits

..

No commits in common. "main" and "v4.9.78" have entirely different histories.

149 changed files with 3166 additions and 22878 deletions

View File

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

View File

@ -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` 等关键词识别新功能
- 忽略纯重构(代码移动但功能不变)和格式化变更
### 截断说明
- 如果看到 `[... 已截断 ...]`,表示内容过长被截断
- 根据已有信息推断完整变更意图即可

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -3,11 +3,11 @@ import http from 'node:http';
export class RequestUtil { 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, maxRedirects: number = 5): Promise<T> { } = {}, isJsonRet: boolean = true, isArgJson: boolean = true): 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);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -34,9 +34,8 @@ export async function NCoreInitFramework (
}); });
const pathWrapper = new NapCatPathWrapper(); const pathWrapper = new NapCatPathWrapper();
await applyPendingUpdates(pathWrapper);
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 +72,14 @@ 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); 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 +90,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 +102,7 @@ export class NapCatFramework {
wrapper, wrapper,
session, session,
logger, logger,
loginService,
basicInfoWrapper, basicInfoWrapper,
pathWrapper, pathWrapper,
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -319,7 +319,7 @@ 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); await applyPendingUpdates(pathWrapper);
// 初始化 FFmpeg 服务 // 初始化 FFmpeg 服务
await FFmpegService.init(pathWrapper.binaryPath, logger); await FFmpegService.init(pathWrapper.binaryPath, logger);
@ -418,6 +418,7 @@ export async function NCoreInitShell () {
wrapper, wrapper,
session, session,
logger, logger,
loginService,
selfInfo, selfInfo,
basicInfoWrapper, basicInfoWrapper,
pathWrapper, pathWrapper,
@ -433,6 +434,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 +446,7 @@ export class NapCatShell {
wrapper, wrapper,
session, session,
logger, logger,
loginService,
basicInfoWrapper, basicInfoWrapper,
pathWrapper, pathWrapper,
}; };
@ -452,11 +455,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));
} }
} }

View File

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

View File

@ -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 });
@ -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.replace(/^v/, ''));
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 ?? '0.0.0';
} }
} }
@ -177,6 +115,3 @@ export default function vitePluginNapcatVersion () {
}, },
}; };
} }
// Export validateVersion for external use
export { validateVersion };

View File

@ -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,7 +92,6 @@ 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();
@ -152,118 +142,39 @@ 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-96 是性能和压缩率的平衡点
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();
} }
}); });
// 如果是自定义色彩构建一个css文件 // 如果是自定义色彩构建一个css文件
app.use('/files/theme.css', async (_req, res) => { app.use('/files/theme.css', async (_req, res) => {
const theme = await WebUiConfig.GetTheme(); const colors = await WebUiConfig.GetTheme();
const fontMode = theme.fontMode || 'system';
let css = ''; let css = ':root, .light, [data-theme="light"] {';
for (const key in colors.light) {
// 生成字体 @font-face css += `${key}: ${colors.light[key]};`;
if (fontMode === 'aacute') {
css += `
@font-face {
font-family: 'Aa偷吃可爱长大的';
src: url('/webui/fonts/AaCute.woff') format('woff');
font-display: swap;
}
`;
} else if (fontMode === 'custom') {
css += `
@font-face {
font-family: 'CustomFont';
src: url('/webui/fonts/CustomFont.woff') format('woff');
font-display: swap;
}
`;
}
// 生成颜色主题和字体变量
css += ':root, .light, [data-theme="light"] {';
for (const key in theme.light) {
css += `${key}: ${theme.light[key]};`;
}
// 添加字体变量
if (fontMode === 'aacute') {
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
} else if (fontMode === 'custom') {
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
} else {
css += '--font-family-base: var(--font-family-fallbacks) !important;';
} }
css += '}'; css += '}';
css += '.dark, [data-theme="dark"] {'; css += '.dark, [data-theme="dark"] {';
for (const key in theme.dark) { for (const key in colors.dark) {
css += `${key}: ${theme.dark[key]};`; css += `${key}: ${colors.dark[key]};`;
}
// 添加字体变量
if (fontMode === 'aacute') {
css += "--font-family-base: 'Aa偷吃可爱长大的', var(--font-family-fallbacks) !important;";
} else if (fontMode === 'custom') {
css += "--font-family-base: 'CustomFont', var(--font-family-fallbacks) !important;";
} else {
css += '--font-family-base: var(--font-family-fallbacks) !important;';
} }
css += '}'; css += '}';
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');
}
});
// ------------中间件结束------------ // ------------中间件结束------------
// ------------挂载路由------------ // ------------挂载路由------------
@ -276,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);

View File

@ -20,7 +20,6 @@
"@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 +29,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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,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;
}, },
@ -155,12 +154,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;
},
}; };

View File

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

View File

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

View File

@ -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, getLatestTagHandler } 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';
@ -8,7 +8,6 @@ const router = Router();
router.get('/QQVersion', QQVersionHandler); router.get('/QQVersion', QQVersionHandler);
router.get('/GetNapCatVersion', GetNapCatVersion); router.get('/GetNapCatVersion', GetNapCatVersion);
router.get('/getLatestTag', getLatestTagHandler); 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);

View File

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

View File

@ -47,7 +47,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>;

View File

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

View File

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

View File

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

View File

@ -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,11 @@
"@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",
"@simplewebauthn/browser": "^13.2.2",
"@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 +58,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 +78,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 +106,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"
}, },

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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} scrollBehavior="inside"> <Modal size='full' isOpen={isOpen} onClose={onClose}>
<ModalContent className="flex flex-col h-full max-h-[100dvh]"> <ModalContent>
<ModalHeader className='flex items-center gap-2 border-b border-default-200/50 flex-shrink-0'> <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 flex-1 min-h-0 overflow-hidden'> <ModalBody className='p-0'>
<div className='h-full w-full overflow-auto' 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 flex-shrink-0"> <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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,19 +10,18 @@ import {
import React from 'react'; import React from 'react';
export interface ModalProps { export interface ModalProps {
content: React.ReactNode; content: React.ReactNode
title?: React.ReactNode; title?: React.ReactNode
size?: React.ComponentProps<typeof NextUIModal>['size']; size?: React.ComponentProps<typeof NextUIModal>['size']
scrollBehavior?: React.ComponentProps<typeof NextUIModal>['scrollBehavior']; scrollBehavior?: React.ComponentProps<typeof NextUIModal>['scrollBehavior']
onClose?: () => void; onClose?: () => void
onConfirm?: () => void; onConfirm?: () => void
onCancel?: () => void; onCancel?: () => void
backdrop?: 'opaque' | 'blur' | 'transparent'; backdrop?: 'opaque' | 'blur' | 'transparent'
showCancel?: boolean; showCancel?: boolean
dismissible?: boolean; dismissible?: boolean
confirmText?: string; confirmText?: string
cancelText?: string; cancelText?: string
hideFooter?: boolean;
} }
const Modal: React.FC<ModalProps> = React.memo((props) => { const Modal: React.FC<ModalProps> = React.memo((props) => {
@ -34,7 +33,6 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
dismissible, dismissible,
confirmText = '确定', confirmText = '确定',
cancelText = '取消', cancelText = '取消',
hideFooter = false,
onClose, onClose,
onConfirm, onConfirm,
onCancel, onCancel,
@ -64,7 +62,6 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
<ModalHeader className='flex flex-col gap-1'>{title}</ModalHeader> <ModalHeader className='flex flex-col gap-1'>{title}</ModalHeader>
)} )}
<ModalBody className='break-all'>{content}</ModalBody> <ModalBody className='break-all'>{content}</ModalBody>
{!hideFooter && (
<ModalFooter> <ModalFooter>
{showCancel && ( {showCancel && (
<Button <Button
@ -88,7 +85,6 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
{confirmText} {confirmText}
</Button> </Button>
</ModalFooter> </ModalFooter>
)}
</> </>
)} )}
</ModalContent> </ModalContent>

View File

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

View File

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

View File

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

View File

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

View File

@ -13,18 +13,18 @@ import type {
import { renderMessageContent } from '../render_message'; import { renderMessageContent } from '../render_message';
export interface OneBotMessageProps { export interface OneBotMessageProps {
data: OB11Message; data: OB11Message
} }
export interface OneBotMessageGroupProps { export interface OneBotMessageGroupProps {
data: OB11GroupMessage; data: OB11GroupMessage
} }
export interface OneBotMessagePrivateProps { export interface OneBotMessagePrivateProps {
data: OB11PrivateMessage; data: OB11PrivateMessage
} }
const MessageContent: React.FC<{ data: OB11Message; }> = ({ data }) => { const MessageContent: React.FC<{ data: OB11Message }> = ({ data }) => {
return ( return (
<div className='h-full flex flex-col overflow-hidden flex-1'> <div className='h-full flex flex-col overflow-hidden flex-1'>
<div className='flex gap-2 items-center flex-shrink-0'> <div className='flex gap-2 items-center flex-shrink-0'>
@ -73,7 +73,7 @@ const OneBotMessageGroup: React.FC<OneBotMessageGroupProps> = ({ data }) => {
<div className='h-full overflow-hidden flex flex-col w-full'> <div className='h-full overflow-hidden flex flex-col w-full'>
<div className='flex items-center p-1 flex-shrink-0'> <div className='flex items-center p-1 flex-shrink-0'>
<Avatar <Avatar
src={`https://p.qlogo.cn/gh/${data.group_id}/${data.group_id}/0/`} src={`https://p.qlogo.cn/gh/${data.group_id}/${data.group_id}/640/`}
alt='群头像' alt='群头像'
size='sm' size='sm'
className='flex-shrink-0 mr-2' className='flex-shrink-0 mr-2'

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