Compare commits

...

11 Commits

Author SHA1 Message Date
Makoto
2d3f4e696b feat: Add OB11GroupGrayTipEvent for detecting forged gray tip attacks (#1492)
* feat: Add OB11GroupGrayTipEvent for detecting forged gray tip attacks

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

* fix: simplify logWarn to match upstream style

* fix: remove extra closing brace that broke class structure

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

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

* fix: 完善插件模板

* Update index.ts
2025-12-31 13:58:55 +08:00
时瑾
3365211507 ci: 添加构建结果评论中的下载链接和获取 artifacts 列表功能 2025-12-29 03:14:17 +08:00
时瑾
05b38825c0 ci: 使用 type 导入 PullRequest 和 BuildStatus 类型 2025-12-29 03:01:21 +08:00
时瑾
95f4a4d37e ci: pr build 2025-12-29 02:55:11 +08:00
41 changed files with 1541 additions and 263 deletions

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

@@ -0,0 +1,128 @@
/**
* 构建状态评论模板
*/
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, '\\`\\`\\`');
}
// ============== 状态图标 ==============
export function getStatusIcon (status: BuildStatus): string {
switch (status) {
case 'success':
return '✅ 成功';
case 'pending':
return '⏳ 构建中...';
case 'cancelled':
return '⚪ 已取消';
default:
return '❌ 失败';
}
}
// ============== 构建中评论 ==============
export function generateBuildingComment (prSha: string, targets: string[]): string {
const time = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
const lines: string[] = [
COMMENT_MARKER,
'## 🔨 构建状态',
'',
'| 构建目标 | 状态 |',
'| :--- | :--- |',
...targets.map(name => `| ${name} | ⏳ 构建中... |`),
'',
'---',
'',
`📝 **提交**: \`${formatSha(prSha)}\``,
`🕐 **开始时间**: ${time}`,
'',
'> 构建进行中,请稍候...',
];
return lines.join('\n');
}
// ============== 构建结果评论 ==============
export function generateResultComment (
targets: BuildTarget[],
prSha: string,
runId: string,
repository: string
): string {
// 链接到 run 详情页,页面底部有 Artifacts 下载区域
const runUrl = `https://github.com/${repository}/actions/runs/${runId}`;
const allSuccess = targets.every(t => t.status === 'success');
const anyCancelled = targets.some(t => t.status === 'cancelled');
const headerIcon = allSuccess
? '✅ 构建成功'
: anyCancelled
? '⚪ 构建已取消'
: '❌ 构建失败';
const downloadLink = (target: BuildTarget) => {
if (target.status !== 'success') return '—';
if (target.downloadUrl) {
return `[📦 下载](${target.downloadUrl})`;
}
// 回退到 run 详情页
return `[📦 下载](${runUrl}#artifacts)`;
};
const lines: string[] = [
COMMENT_MARKER,
`## ${headerIcon}`,
'',
'| 构建目标 | 状态 | 下载 |',
'| :--- | :--- | :--- |',
...targets.map(t => `| ${t.name} | ${getStatusIcon(t.status)} | ${downloadLink(t)} |`),
'',
'---',
'',
`📝 **提交**: \`${formatSha(prSha)}\``,
`🔗 **构建日志**: [查看详情](${runUrl})`,
];
// 添加错误详情
const failedTargets = targets.filter(t => t.status === 'failure' && t.error);
if (failedTargets.length > 0) {
lines.push('', '---', '', '## ⚠️ 错误详情');
for (const target of failedTargets) {
lines.push('', `### ${target.name} 构建错误`, '```', escapeCodeBlock(target.error!), '```');
}
}
// 添加底部提示
if (allSuccess) {
lines.push('', '> 🎉 所有构建均已成功完成,可点击上方下载链接获取构建产物进行测试。');
} else if (anyCancelled) {
lines.push('', '> ⚪ 构建已被取消,可能是由于新的提交触发了新的构建。');
} else {
lines.push('', '> ⚠️ 部分构建失败,请查看上方错误详情或点击构建日志查看完整输出。');
}
return lines.join('\n');
}

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

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

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

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

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

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

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

@@ -0,0 +1,87 @@
/**
* PR Build - 更新构建结果评论
*
* 环境变量:
* - GITHUB_TOKEN: GitHub API Token
* - PR_NUMBER: PR 编号
* - PR_SHA: PR 提交 SHA
* - RUN_ID: GitHub Actions Run ID
* - 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 { 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(`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);
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
}
main().catch((error) => {
console.error('❌ Error:', error);
process.exit(1);
});

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

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

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

@@ -0,0 +1,252 @@
# =============================================================================
# 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 的多次构建会取消之前未完成的构建,避免资源浪费
concurrency:
group: pr-build-${{ github.event.pull_request.number || 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 }} # 错误信息(如有)
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
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 执行构建,使用 base 分支的脚本处理 workspace 中的代码
- name: Build
id: build
working-directory: workspace
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 }} # 错误信息(如有)
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
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 执行构建
- name: Build
id: build
working-directory: workspace
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 }}
# 获取构建状态,如果 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

@@ -498,5 +498,9 @@
"6.9.86-42941": {
"appid": 537328648,
"qua": "V1_MAC_NQ_6.9.86_42941_GW_B"
},
"9.9.26-44175": {
"appid": 537336450,
"qua": "V1_WIN_NQ_9.9.26_44175_GW_B"
}
}

View File

@@ -126,5 +126,9 @@
"6.9.86-42941-arm64": {
"send": "2346108",
"recv": "09675F0"
},
"9.9.26-44175-x64": {
"send": "0A0F2EC",
"recv": "1D3AD4D"
}
}

View File

@@ -638,5 +638,9 @@
"6.9.86-42941-arm64": {
"send": "3DDDAD0",
"recv": "3DE03E0"
},
"9.9.26-44175-x64": {
"send": "2CD84A0",
"recv": "2CDBA20"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,6 +37,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
onEnable,
onDelete,
onEnableDebug,
showType,
}: NetworkDisplayCardProps<T>) => {
const { name, enable, debug } = data;
const [editing, setEditing] = useState(false);
@@ -60,15 +61,16 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
return (
<DisplayCardContainer
className="w-full max-w-[420px]"
className='w-full max-w-[420px]'
tag={showType ? typeLabel : undefined}
action={
<div className="flex gap-2 w-full">
<div className='flex gap-2 w-full'>
<Button
fullWidth
radius='full'
size='sm'
variant='flat'
className="flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-warning/20 hover:text-warning transition-colors"
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-warning/20 hover:text-warning transition-colors'
startContent={<FiEdit3 size={16} />}
onPress={onEdit}
isDisabled={editing}
@@ -82,10 +84,10 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
size='sm'
variant='flat'
className={clsx(
"flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors",
'flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors',
debug
? "hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary"
: "hover:bg-success/20 hover:text-success data-[hover=true]:text-success"
? 'hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary'
: 'hover:bg-success/20 hover:text-success data-[hover=true]:text-success'
)}
startContent={<CgDebug size={16} />}
onPress={handleEnableDebug}
@@ -113,11 +115,11 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
isSelected={enable}
onChange={handleEnable}
classNames={{
wrapper: "group-data-[selected=true]:bg-primary-400",
wrapper: 'group-data-[selected=true]:bg-primary-400',
}}
/>
}
title={typeLabel}
title={name}
>
<div className='grid grid-cols-2 gap-3'>
{(() => {
@@ -125,29 +127,30 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
if (targetFullField) {
// 模式1存在全宽字段如URL布局为
// Row 1: 名称 (全宽)
// Row 1: 类型 (全宽)
// Row 2: 全宽字段 (全宽)
return (
<>
<div
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'></span>
<div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
{name}
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'></span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{typeLabel}
</div>
</div>
<div
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{targetFullField.label}</span>
<div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{targetFullField.render
? targetFullField.render(targetFullField.value)
: (
<span className={clsx(
typeof targetFullField.value === 'string' && (targetFullField.value.startsWith('http') || targetFullField.value.includes('.') || targetFullField.value.includes(':')) ? 'font-mono' : ''
)}>
)}
>
{String(targetFullField.value)}
</span>
)}
@@ -157,7 +160,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
);
} else {
// 模式2无全宽字段布局为 4 个小块 (2行 x 2列)
// Row 1: 名称 | Field 0
// Row 1: 类型 | Field 0
// Row 2: Field 1 | Field 2
const displayFields = fields.slice(0, 3);
return (
@@ -165,9 +168,9 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
<div
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'></span>
<div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
{name}
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'></span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{typeLabel}
</div>
</div>
{displayFields.map((field, index) => (
@@ -176,7 +179,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{field.label}</span>
<div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{field.render
? (
field.render(field.value)
@@ -184,7 +187,8 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
: (
<span className={clsx(
typeof field.value === 'string' && (field.value.startsWith('http') || field.value.includes('.') || field.value.includes(':')) ? 'font-mono' : ''
)}>
)}
>
{String(field.value)}
</span>
)}

View File

@@ -3,7 +3,6 @@ import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import key from '@/const/key';
export interface ContainerProps {
title: string;
tag?: React.ReactNode;
@@ -24,7 +23,6 @@ export interface DisplayCardProps {
const DisplayCardContainer: React.FC<ContainerProps> = ({
title: _title,
action,
tag,
enableSwitch,
children,
className,
@@ -40,11 +38,6 @@ const DisplayCardContainer: React.FC<ContainerProps> = ({
)}
>
<CardHeader className='p-4 pb-2 flex items-center justify-between gap-3'>
{tag && (
<div className='text-center text-default-500 font-medium mb-1 absolute top-0 left-1/2 -translate-x-1/2 text-xs pointer-events-none bg-default-200/50 dark:bg-default-100/50 backdrop-blur-sm px-3 py-0.5 rounded-b-lg shadow-sm z-10'>
{tag}
</div>
)}
<div className='flex-1 min-w-0 mr-2'>
<div className='inline-flex items-center px-3 py-1 rounded-lg bg-default-100/50 dark:bg-white/10 border border-transparent dark:border-white/5'>
<span className='font-bold text-default-600 dark:text-white/90 text-sm truncate select-text'>

View File

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

View File

@@ -63,11 +63,11 @@ export default function FileEditModal ({
};
return (
<Modal size='full' isOpen={isOpen} onClose={onClose}>
<Modal radius='sm' size='full' isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader className='flex items-center gap-2 border-b border-default-200/50'>
<span></span>
<Code className='text-xs'>{file?.path}</Code>
<Code radius='sm' className='text-xs'>{file?.path}</Code>
<div className="ml-auto text-xs text-default-400 font-normal px-2">
<span className="px-1 py-0.5 rounded border border-default-300 bg-default-100">Ctrl/Cmd + S</span>
</div>
@@ -89,10 +89,10 @@ export default function FileEditModal ({
</div>
</ModalBody>
<ModalFooter className="border-t border-default-200/50">
<Button color='primary' variant='flat' onPress={onClose}>
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
</Button>
<Button color='primary' onPress={onSave}>
<Button radius='sm' color='primary' onPress={onSave}>
</Button>
</ModalFooter>

View File

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

View File

@@ -105,6 +105,7 @@ export default function FileTable ({
/>
<Table
aria-label='文件列表'
radius='sm'
sortDescriptor={sortDescriptor}
onSortChange={onSortChange}
onSelectionChange={onSelectionChange}
@@ -175,6 +176,7 @@ export default function FileTable ({
)
: (
<Button
radius='sm'
variant='light'
onPress={() =>
file.isDirectory
@@ -202,7 +204,7 @@ export default function FileTable ({
</TableCell>
<TableCell className='hidden md:table-cell'>{new Date(file.mtime).toLocaleString()}</TableCell>
<TableCell>
<ButtonGroup size='sm' variant='light'>
<ButtonGroup radius='sm' size='sm' variant='light'>
<Button
isIconOnly
color='default'

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
/* eslint-disable @stylistic/jsx-closing-bracket-location */
/* eslint-disable @stylistic/jsx-closing-tag-location */
import { Button } from '@heroui/button';
import { Tooltip } from '@heroui/tooltip';
import { useLocalStorage } from '@uidotdev/usehooks';
@@ -40,30 +42,36 @@ export default function Hitokoto () {
}
};
return (
<div>
<div className='relative flex flex-col items-center justify-center p-6 min-h-[120px]'>
<div className='overflow-hidden'>
<div className='relative flex flex-col items-center justify-center p-4 md:p-6'>
{loading && !data && <PageLoading />}
{data && (
<>
<IoMdQuote className={clsx(
"text-4xl mb-4",
hasBackground ? "text-white/30" : "text-primary/20"
)} />
'text-4xl mb-4',
hasBackground ? 'text-white/30' : 'text-primary/20'
)}
/>
<div className={clsx(
"text-xl font-medium tracking-wide leading-relaxed italic",
hasBackground ? "text-white drop-shadow-sm" : "text-default-700 dark:text-gray-200"
)}>
'text-xl font-medium tracking-wide leading-relaxed italic',
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
)}
>
" {data?.hitokoto} "
</div>
<div className='mt-4 flex flex-col items-center text-sm'>
<span className={clsx(
'font-bold',
hasBackground ? 'text-white/90' : 'text-primary-500/80'
)}> {data?.from}</span>
)}
> {data?.from}
</span>
{data?.from_who && <span className={clsx(
"text-xs mt-1",
hasBackground ? "text-white/70" : "text-default-400"
)}>{data?.from_who}</span>}
'text-xs mt-1',
hasBackground ? 'text-white/70' : 'text-default-400'
)}
> {data?.from_who}
</span>}
</div>
</>
)}
@@ -72,8 +80,8 @@ export default function Hitokoto () {
<Tooltip content='刷新' placement='top'>
<Button
className={clsx(
"transition-colors",
hasBackground ? "text-white/60 hover:text-white" : "text-default-400 hover:text-primary"
'transition-colors',
hasBackground ? 'text-white/60 hover:text-white' : 'text-default-400 hover:text-primary'
)}
onPress={run}
size='sm'
@@ -88,8 +96,8 @@ export default function Hitokoto () {
<Tooltip content='复制' placement='top'>
<Button
className={clsx(
"transition-colors",
hasBackground ? "text-white/60 hover:text-white" : "text-default-400 hover:text-success"
'transition-colors',
hasBackground ? 'text-white/60 hover:text-white' : 'text-default-400 hover:text-success'
)}
onPress={onCopy}
size='sm'

View File

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

View File

@@ -48,7 +48,7 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
<Image
src={
data?.avatarUrl ??
`https://q1.qlogo.cn/g?b=qq&nk=${data?.uin}&s=1`
`https://q1.qlogo.cn/g?b=qq&nk=${data?.uin}&s=0`
}
className='shadow-sm rounded-full w-14 aspect-square ring-2 ring-white/50 dark:ring-white/10'
/>
@@ -63,13 +63,15 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
<div className={clsx(
'text-xl font-bold truncate mb-0.5',
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-800 dark:text-gray-100'
)}>
)}
>
{data?.nick || '未知用户'}
</div>
<div className={clsx(
'font-mono text-xs tracking-wider',
hasBackground ? 'text-white/80' : 'text-default-500 opacity-80'
)}>
)}
>
{data?.uin || 'Unknown'}
</div>
</div>

View File

@@ -7,17 +7,17 @@ import { IoMdRefresh } from 'react-icons/io';
import { isQQQuickNewItem } from '@/utils/qq';
export interface QQItem {
uin: string
uin: string;
}
interface QuickLoginProps {
qqList: (QQItem | LoginListItem)[]
refresh: boolean
isLoading: boolean
selectedQQ: string
onUpdateQQList: () => void
handleSelectionChange: React.ChangeEventHandler<HTMLSelectElement>
onSubmit: () => void
qqList: (QQItem | LoginListItem)[];
refresh: boolean;
isLoading: boolean;
selectedQQ: string;
onUpdateQQList: () => void;
handleSelectionChange: React.ChangeEventHandler<HTMLSelectElement>;
onSubmit: () => void;
}
const QuickLogin: React.FC<QuickLoginProps> = ({

View File

@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
import clsx from 'clsx';
import { Tooltip } from '@heroui/tooltip';
import { useTheme } from '@/hooks/use-theme';
@@ -18,9 +19,13 @@ const UsagePie: React.FC<UsagePieProps> = ({
}) => {
const { theme } = useTheme();
// Ensure values are clean
const cleanSystem = Math.min(Math.max(systemUsage || 0, 0), 100);
const cleanProcess = Math.min(Math.max(processUsage || 0, 0), cleanSystem);
// Ensure values are clean and consistent
// Process usage cannot exceed system usage, and system usage cannot be less than process usage.
const rawSystem = Math.max(systemUsage || 0, 0);
const rawProcess = Math.max(processUsage || 0, 0);
const cleanSystem = Math.min(Math.max(rawSystem, rawProcess), 100);
const cleanProcess = Math.min(rawProcess, cleanSystem);
// SVG Config
const size = 100;
@@ -47,75 +52,102 @@ const UsagePie: React.FC<UsagePieProps> = ({
return `${(cleanProcess / 100) * circumference} ${circumference}`;
}, [cleanProcess, circumference]);
return (
<div className="relative w-36 h-36 flex items-center justify-center">
<svg
className="w-full h-full -rotate-90"
viewBox={`0 0 ${size} ${size}`}
>
{/* Track / Free Space */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={colors.track}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
// 计算其他进程占用(系统总占用 - QQ占用
const otherUsage = Math.max(cleanSystem - cleanProcess, 0);
{/* System Usage (Background for QQ) - effectively "Others" + "QQ" */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={colors.other}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={systemDash}
className="transition-all duration-700 ease-out"
/>
{/* QQ Usage - Layered on top */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={colors.qq}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={processDash}
className="transition-all duration-700 ease-out"
/>
</svg>
{/* Center Content */}
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none select-none">
{title && (
<span className={clsx(
"text-[10px] font-medium mb-0.5 opacity-80 uppercase tracking-widest scale-90",
hasBackground ? 'text-white/80' : 'text-default-500 dark:text-default-400'
)}>
{title}
</span>
)}
<div className="flex items-baseline gap-0.5">
<span className={clsx(
"text-2xl font-bold font-mono tracking-tight",
hasBackground ? 'text-white' : 'text-default-900 dark:text-white'
)}>
{Math.round(cleanSystem)}
</span>
<span className={clsx(
"text-xs font-bold",
hasBackground ? 'text-white/60' : 'text-default-400 dark:text-default-500'
)}>%</span>
</div>
// Tooltip 内容
const tooltipContent = (
<div className='flex flex-col gap-1 p-1 text-xs'>
<div className='flex items-center gap-2'>
<span className='w-2 h-2 rounded-full' style={{ backgroundColor: colors.qq }} />
<span>QQ进程: {cleanProcess.toFixed(1)}%</span>
</div>
<div className='flex items-center gap-2'>
<span className='w-2 h-2 rounded-full' style={{ backgroundColor: colors.other }} />
<span>: {otherUsage.toFixed(1)}%</span>
</div>
<div className='flex items-center gap-2'>
<span className='w-2 h-2 rounded-full' style={{ backgroundColor: colors.track }} />
<span>: {(100 - cleanSystem).toFixed(1)}%</span>
</div>
</div>
);
return (
<Tooltip content={tooltipContent} placement='top'>
<div className='relative w-36 h-36 flex items-center justify-center cursor-pointer'>
<svg
className='w-full h-full -rotate-90'
viewBox={`0 0 ${size} ${size}`}
>
{/* Track / Free Space */}
<circle
cx={center}
cy={center}
r={radius}
fill='none'
stroke={colors.track}
strokeWidth={strokeWidth}
strokeLinecap='round'
/>
{/* System Usage (Background for QQ) - effectively "Others" + "QQ" */}
<circle
cx={center}
cy={center}
r={radius}
fill='none'
stroke={colors.other}
strokeWidth={strokeWidth}
strokeLinecap='round'
strokeDasharray={systemDash}
className='transition-all duration-700 ease-out'
/>
{/* QQ Usage - Layered on top */}
<circle
cx={center}
cy={center}
r={radius}
fill='none'
stroke={colors.qq}
strokeWidth={strokeWidth}
strokeLinecap='round'
strokeDasharray={processDash}
className='transition-all duration-700 ease-out'
/>
</svg>
{/* Center Content */}
<div className='absolute inset-0 flex flex-col items-center justify-center pointer-events-none select-none'>
{title && (
<span className={clsx(
'text-[10px] font-medium mb-0.5 opacity-80 uppercase tracking-widest scale-90',
hasBackground ? 'text-white/80' : 'text-default-500 dark:text-default-400'
)}
>
{title}
</span>
)}
<div className='flex items-baseline gap-0.5'>
<span className={clsx(
'text-2xl font-bold font-mono tracking-tight',
hasBackground ? 'text-white' : 'text-default-900 dark:text-white'
)}
>
{Math.round(cleanSystem)}
</span>
<span className={clsx(
'text-xs font-bold',
hasBackground ? 'text-white/60' : 'text-default-400 dark:text-default-500'
)}
>%
</span>
</div>
</div>
</div>
</Tooltip>
);
};
export default UsagePie;

View File

@@ -10,9 +10,8 @@ import { Controller, useForm, useWatch } from 'react-hook-form';
import toast from 'react-hot-toast';
import { FaFont, FaUserAstronaut, FaCheck } from 'react-icons/fa';
import { FaPaintbrush } from 'react-icons/fa6';
import { IoIosColorPalette } from 'react-icons/io';
import { IoIosColorPalette, IoMdRefresh } from 'react-icons/io';
import { MdDarkMode, MdLightMode } from 'react-icons/md';
import { IoMdRefresh } from 'react-icons/io';
import themes from '@/const/themes';
@@ -77,8 +76,8 @@ function PreviewThemeCard ({ theme, onPreview, isSelected }: PreviewThemeCardPro
)}
>
{isSelected && (
<div className="absolute top-1 right-1 z-10">
<Chip size="sm" color="primary" variant="solid">
<div className='absolute top-1 right-1 z-10'>
<Chip size='sm' color='primary' variant='solid'>
<FaCheck size={10} />
</Chip>
</div>
@@ -91,20 +90,20 @@ function PreviewThemeCard ({ theme, onPreview, isSelected }: PreviewThemeCardPro
<FaUserAstronaut />
{theme.author ?? '未知'}
</div>
<div className='text-xs text-primary-200'>{theme.description}</div>
<div className='text-xs text-primary-200 whitespace-nowrap overflow-hidden text-ellipsis w-full'>{theme.description}</div>
</CardHeader>
<CardBody>
<div className='flex flex-col gap-1'>
{colors.map((color) => (
<div className='flex gap-1 items-center flex-wrap' key={color}>
<div className='text-xs w-4 text-right'>
<div className='flex gap-1 items-center flex-nowrap' key={color}>
<div className='text-xs w-4 text-right flex-shrink-0'>
{color[0].toUpperCase()}
</div>
{values.map((value) => (
<div
key={value}
className={clsx(
'w-2 h-2 rounded-full shadow-small',
'w-2 h-2 rounded-full shadow-small flex-shrink-0',
`bg-${color}${value}`
)}
/>
@@ -135,9 +134,9 @@ const isThemeColorsEqual = (a: ThemeConfig, b: ThemeConfig): boolean => {
// 字体模式显示名称映射
const fontModeNames: Record<string, string> = {
'aacute': 'Aa 偷吃可爱长大的',
'system': '系统默认',
'custom': '自定义字体',
aacute: 'Aa 偷吃可爱长大的',
system: '系统默认',
custom: '自定义字体',
};
const ThemeConfigCard = () => {
@@ -169,11 +168,16 @@ const ThemeConfigCard = () => {
const originalDataRef = useRef<ThemeConfig | null>(null);
// 在组件挂载时创建 style 标签,并在卸载时清理
// 同时在卸载时恢复字体到已保存的状态(避免"伪自动保存"问题)
useEffect(() => {
const styleTag = document.createElement('style');
document.head.appendChild(styleTag);
styleTagRef.current = styleTag;
return () => {
// 组件卸载时,恢复到已保存的字体设置
if (originalDataRef.current?.fontMode) {
applyFont(originalDataRef.current.fontMode);
}
if (styleTagRef.current) {
document.head.removeChild(styleTagRef.current);
}
@@ -259,14 +263,12 @@ const ThemeConfigCard = () => {
const savedThemeName = useMemo(() => {
if (!originalDataRef.current) return null;
return themes.find(t => isThemeColorsEqual(t.theme, originalDataRef.current!))?.name || '自定义';
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataLoaded, hasUnsavedChanges]);
// 已保存的字体模式显示名称
const savedFontModeDisplayName = useMemo(() => {
const mode = originalDataRef.current?.fontMode || 'aacute';
return fontModeNames[mode] || mode;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataLoaded, hasUnsavedChanges]);
if (loading) return <PageLoading loading />;
@@ -282,33 +284,33 @@ const ThemeConfigCard = () => {
<title> - NapCat WebUI</title>
{/* 顶部操作栏 */}
<div className="sticky top-0 z-20 bg-background/80 backdrop-blur-md border-b border-divider">
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 text-sm">
<span className="text-default-400">:</span>
<Chip size="sm" color="primary" variant="flat">
<div className='sticky top-0 z-20 bg-background/80 backdrop-blur-md border-b border-divider'>
<div className='flex items-center justify-between p-4'>
<div className='flex items-center gap-3 flex-wrap'>
<div className='flex items-center gap-2 text-sm'>
<span className='text-default-400'>:</span>
<Chip size='sm' color='primary' variant='flat'>
{savedThemeName || '加载中...'}
</Chip>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-default-400">:</span>
<Chip size="sm" color="secondary" variant="flat">
<div className='flex items-center gap-2 text-sm'>
<span className='text-default-400'>:</span>
<Chip size='sm' color='secondary' variant='flat'>
{savedFontModeDisplayName}
</Chip>
</div>
{hasUnsavedChanges && (
<Chip size="sm" color="warning" variant="solid">
<Chip size='sm' color='warning' variant='solid'>
</Chip>
)}
</div>
<div className="flex items-center gap-2">
<div className='flex items-center gap-2'>
<Button
size="sm"
radius="full"
variant="flat"
className="font-medium bg-default-100 text-default-600 dark:bg-default-50/50"
size='sm'
radius='full'
variant='flat'
className='font-medium bg-default-100 text-default-600 dark:bg-default-50/50'
onPress={() => {
reset();
toast.success('已重置');
@@ -318,10 +320,10 @@ const ThemeConfigCard = () => {
</Button>
<Button
size="sm"
size='sm'
color='primary'
radius="full"
className="font-medium shadow-md shadow-primary/20"
radius='full'
className='font-medium shadow-md shadow-primary/20'
isLoading={isSubmitting}
onPress={() => onSubmit()}
isDisabled={!hasUnsavedChanges}
@@ -329,11 +331,11 @@ const ThemeConfigCard = () => {
</Button>
<Button
size="sm"
size='sm'
isIconOnly
radius='full'
variant='flat'
className="text-default-500 bg-default-100 dark:bg-default-50/50"
className='text-default-500 bg-default-100 dark:bg-default-50/50'
onPress={onRefresh}
>
<IoMdRefresh size={18} />
@@ -342,7 +344,7 @@ const ThemeConfigCard = () => {
</div>
</div>
<div className="p-4">
<div className='p-4'>
<Accordion variant='splitted' defaultExpandedKeys={['font', 'select']}>
<AccordionItem
key='font'
@@ -355,18 +357,18 @@ const ThemeConfigCard = () => {
<div className='flex flex-col gap-4'>
<Controller
control={control}
name="theme.fontMode"
name='theme.fontMode'
render={({ field }) => (
<Select
label="字体预设"
label='字体预设'
selectedKeys={field.value ? [field.value] : ['aacute']}
onChange={(e) => field.onChange(e.target.value)}
className="max-w-xs"
className='max-w-xs'
disallowEmptySelection
>
<SelectItem key="aacute">Aa </SelectItem>
<SelectItem key="system"></SelectItem>
<SelectItem key="custom"></SelectItem>
<SelectItem key='aacute'>Aa </SelectItem>
<SelectItem key='system'></SelectItem>
<SelectItem key='custom'></SelectItem>
</Select>
)}
/>

View File

@@ -337,7 +337,7 @@ export default function FileManagerPage () {
return (
<div className='h-full flex flex-col relative gap-4 w-full p-2 md:p-4'>
<div className={clsx(
'mb-4 flex flex-col md:flex-row items-stretch md:items-center gap-4 sticky top-14 z-10 backdrop-blur-sm shadow-sm py-2 px-4 rounded-xl transition-colors',
'mb-4 flex flex-col md:flex-row items-stretch md:items-center gap-4 sticky top-14 z-10 backdrop-blur-sm shadow-sm py-2 px-4 rounded-sm transition-colors',
hasBackground
? 'bg-white/20 dark:bg-black/10 border border-white/40 dark:border-white/10'
: 'bg-white/60 dark:bg-black/40 border border-white/40 dark:border-white/10'
@@ -345,6 +345,7 @@ export default function FileManagerPage () {
>
<div className='flex items-center gap-2 overflow-x-auto hide-scrollbar pb-1 md:pb-0'>
<Button
radius='sm'
color='primary'
size='sm'
isIconOnly
@@ -356,6 +357,7 @@ export default function FileManagerPage () {
</Button>
<Button
radius='sm'
color='primary'
size='sm'
isIconOnly
@@ -367,6 +369,7 @@ export default function FileManagerPage () {
</Button>
<Button
radius='sm'
color='primary'
isLoading={loading}
size='sm'
@@ -378,6 +381,7 @@ export default function FileManagerPage () {
<MdRefresh />
</Button>
<Button
radius='sm'
color='primary'
size='sm'
isIconOnly
@@ -392,6 +396,7 @@ export default function FileManagerPage () {
selectedFiles === 'all') && (
<>
<Button
radius='sm'
color='primary'
size='sm'
variant='flat'
@@ -404,6 +409,7 @@ export default function FileManagerPage () {
)
</Button>
<Button
radius='sm'
color='primary'
size='sm'
variant='flat'
@@ -419,6 +425,7 @@ export default function FileManagerPage () {
)
</Button>
<Button
radius='sm'
color='primary'
size='sm'
variant='flat'
@@ -435,7 +442,10 @@ export default function FileManagerPage () {
</div>
<div className='flex flex-col md:flex-row flex-1 gap-2 overflow-hidden items-stretch md:items-center'>
<Breadcrumbs className='flex-1 bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border border-white/20 px-2 py-2 rounded-lg overflow-x-auto hide-scrollbar whitespace-nowrap'>
<Breadcrumbs
radius='sm'
className='flex-1 bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border border-white/20 px-2 py-2 rounded-sm overflow-x-auto hide-scrollbar whitespace-nowrap'
>
{currentPath.split('/').map((part, index, parts) => (
<BreadcrumbItem
key={part}
@@ -450,6 +460,7 @@ export default function FileManagerPage () {
))}
</Breadcrumbs>
<Input
radius='sm'
type='text'
placeholder='输入跳转路径'
value={jumpPath}
@@ -472,7 +483,7 @@ export default function FileManagerPage () {
animate={{ height: showUpload ? 'auto' : 0 }}
transition={{ duration: 0.2 }}
className={clsx(
'border-dashed rounded-lg text-center overflow-hidden',
'border-dashed rounded-sm text-center overflow-hidden',
isDragActive ? 'border-primary bg-primary/10' : 'border-default-300',
showUpload ? 'mb-4 border-2' : 'border-none'
)}
@@ -486,7 +497,7 @@ export default function FileManagerPage () {
<div className='flex flex-col items-center gap-2'>
<FiUpload className='text-3xl text-primary' />
<p className='text-default-600'></p>
<Button color='primary' size='sm' variant='flat' onPress={open}>
<Button radius='sm' color='primary' size='sm' variant='flat' onPress={open}>
</Button>
</div>

View File

@@ -102,7 +102,7 @@ const DashboardIndexPage: React.FC = () => {
return (
<>
<title> - NapCat WebUI</title>
<section className='w-full p-2 md:p-4 md:max-w-[1000px] mx-auto'>
<section className='w-full p-2 md:p-4 md:max-w-[1000px] mx-auto overflow-hidden'>
<div className='grid grid-cols-1 lg:grid-cols-3 gap-4 items-stretch'>
<div className='flex flex-col gap-2'>
<QQInfo />
@@ -112,10 +112,11 @@ const DashboardIndexPage: React.FC = () => {
</div>
<Networks />
<Card className={clsx(
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all',
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all overflow-hidden',
hasBackground ? 'bg-white/10 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
)}>
<CardBody>
)}
>
<CardBody className='overflow-hidden'>
<Hitokoto />
</CardBody>
</Card>

View File

@@ -158,7 +158,7 @@ export default function TerminalPage () {
variant='flat'
onPress={createNewTerminal}
startContent={<IoAdd />}
className='text-xl'
className='text-xl ml-auto'
/>
</div>
<div className='flex-grow overflow-hidden'>