mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-10 15:00:26 +00:00
Compare commits
11 Commits
style-webu
...
v4.10.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d3f4e696b | ||
|
|
b241881c74 | ||
|
|
aecf33f4dc | ||
|
|
dd4374389b | ||
|
|
100efb03ab | ||
|
|
ce9482f19d | ||
|
|
4e37b002f9 | ||
|
|
7e7262415b | ||
|
|
3365211507 | ||
|
|
05b38825c0 | ||
|
|
95f4a4d37e |
128
.github/scripts/lib/comment.ts
vendored
Normal file
128
.github/scripts/lib/comment.ts
vendored
Normal 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
189
.github/scripts/lib/github.ts
vendored
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* GitHub API 工具库
|
||||
*/
|
||||
|
||||
import { appendFileSync } from 'node:fs';
|
||||
|
||||
// ============== 类型定义 ==============
|
||||
|
||||
export interface PullRequest {
|
||||
number: number;
|
||||
state: string;
|
||||
head: {
|
||||
sha: string;
|
||||
ref: string;
|
||||
repo: {
|
||||
full_name: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface Repository {
|
||||
owner: {
|
||||
type: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
id: number;
|
||||
name: string;
|
||||
size_in_bytes: number;
|
||||
archive_download_url: string;
|
||||
}
|
||||
|
||||
// ============== GitHub API Client ==========================
|
||||
|
||||
export class GitHubAPI {
|
||||
private token: string;
|
||||
private baseUrl = 'https://api.github.com';
|
||||
|
||||
constructor (token: string) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
private async request<T> (endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async getPullRequest (owner: string, repo: string, pullNumber: number): Promise<PullRequest> {
|
||||
return this.request<PullRequest>(`/repos/${owner}/${repo}/pulls/${pullNumber}`);
|
||||
}
|
||||
|
||||
async getCollaboratorPermission (owner: string, repo: string, username: string): Promise<string> {
|
||||
const data = await this.request<{ permission: string; }>(
|
||||
`/repos/${owner}/${repo}/collaborators/${username}/permission`
|
||||
);
|
||||
return data.permission;
|
||||
}
|
||||
|
||||
async getRepository (owner: string, repo: string): Promise<Repository> {
|
||||
return this.request(`/repos/${owner}/${repo}`);
|
||||
}
|
||||
|
||||
async checkOrgMembership (org: string, username: string): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/orgs/${org}/members/${username}`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getRunArtifacts (owner: string, repo: string, runId: string): Promise<Artifact[]> {
|
||||
const data = await this.request<{ artifacts: Artifact[]; }>(
|
||||
`/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`
|
||||
);
|
||||
return data.artifacts;
|
||||
}
|
||||
|
||||
async createComment (owner: string, repo: string, issueNumber: number, body: string): Promise<void> {
|
||||
await this.request(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ body }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
async findComment (owner: string, repo: string, issueNumber: number, marker: string): Promise<number | null> {
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (page <= 10) { // 最多检查 1000 条评论
|
||||
const comments = await this.request<Array<{ id: number, body: string; }>>(
|
||||
`/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=${perPage}&page=${page}`
|
||||
);
|
||||
|
||||
if (comments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const found = comments.find(c => c.body.includes(marker));
|
||||
if (found) {
|
||||
return found.id;
|
||||
}
|
||||
|
||||
if (comments.length < perPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async updateComment (owner: string, repo: string, commentId: number, body: string): Promise<void> {
|
||||
await this.request(`/repos/${owner}/${repo}/issues/comments/${commentId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ body }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
async createOrUpdateComment (
|
||||
owner: string,
|
||||
repo: string,
|
||||
issueNumber: number,
|
||||
body: string,
|
||||
marker: string
|
||||
): Promise<void> {
|
||||
const existingId = await this.findComment(owner, repo, issueNumber, marker);
|
||||
if (existingId) {
|
||||
await this.updateComment(owner, repo, existingId, body);
|
||||
console.log(`✓ Updated comment #${existingId}`);
|
||||
} else {
|
||||
await this.createComment(owner, repo, issueNumber, body);
|
||||
console.log('✓ Created new comment');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Output 工具 ==============
|
||||
|
||||
export function setOutput (name: string, value: string): void {
|
||||
const outputFile = process.env.GITHUB_OUTPUT;
|
||||
if (outputFile) {
|
||||
appendFileSync(outputFile, `${name}=${value}\n`);
|
||||
}
|
||||
console.log(` ${name}=${value}`);
|
||||
}
|
||||
|
||||
export function setMultilineOutput (name: string, value: string): void {
|
||||
const outputFile = process.env.GITHUB_OUTPUT;
|
||||
if (outputFile) {
|
||||
const delimiter = `EOF_${Date.now()}`;
|
||||
appendFileSync(outputFile, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 环境变量工具 ==============
|
||||
|
||||
export function getEnv (name: string, required: true): string;
|
||||
export function getEnv (name: string, required?: false): string | undefined;
|
||||
export function getEnv (name: string, required = false): string | undefined {
|
||||
const value = process.env[name];
|
||||
if (required && !value) {
|
||||
throw new Error(`Environment variable ${name} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getRepository (): { owner: string, repo: string; } {
|
||||
const repository = getEnv('GITHUB_REPOSITORY', true);
|
||||
const [owner, repo] = repository.split('/');
|
||||
return { owner, repo };
|
||||
}
|
||||
36
.github/scripts/pr-build-building.ts
vendored
Normal file
36
.github/scripts/pr-build-building.ts
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* PR Build - 更新构建中状态评论
|
||||
*
|
||||
* 环境变量:
|
||||
* - GITHUB_TOKEN: GitHub API Token
|
||||
* - PR_NUMBER: PR 编号
|
||||
* - PR_SHA: PR 提交 SHA
|
||||
*/
|
||||
|
||||
import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
|
||||
import { generateBuildingComment, COMMENT_MARKER } from './lib/comment.ts';
|
||||
|
||||
const BUILD_TARGETS = ['NapCat.Framework', 'NapCat.Shell'];
|
||||
|
||||
async function main (): Promise<void> {
|
||||
console.log('🔨 Updating building status comment\n');
|
||||
|
||||
const token = getEnv('GITHUB_TOKEN', true);
|
||||
const prNumber = parseInt(getEnv('PR_NUMBER', true), 10);
|
||||
const prSha = getEnv('PR_SHA', true);
|
||||
const { owner, repo } = getRepository();
|
||||
|
||||
console.log(`PR: #${prNumber}`);
|
||||
console.log(`SHA: ${prSha}`);
|
||||
console.log(`Repo: ${owner}/${repo}\n`);
|
||||
|
||||
const github = new GitHubAPI(token);
|
||||
const comment = generateBuildingComment(prSha, BUILD_TARGETS);
|
||||
|
||||
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
206
.github/scripts/pr-build-check.ts
vendored
Normal file
206
.github/scripts/pr-build-check.ts
vendored
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* PR Build Check Script
|
||||
* 检查 PR 构建触发条件和用户权限
|
||||
*
|
||||
* 环境变量:
|
||||
* - GITHUB_TOKEN: GitHub API Token
|
||||
* - GITHUB_EVENT_NAME: 事件名称
|
||||
* - GITHUB_EVENT_PATH: 事件 payload 文件路径
|
||||
* - GITHUB_REPOSITORY: 仓库名称 (owner/repo)
|
||||
* - GITHUB_OUTPUT: 输出文件路径
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { GitHubAPI, getEnv, getRepository, setOutput } from './lib/github.ts';
|
||||
import type { PullRequest } from './lib/github.ts';
|
||||
|
||||
// ============== 类型定义 ==============
|
||||
|
||||
interface GitHubPayload {
|
||||
pull_request?: PullRequest;
|
||||
issue?: {
|
||||
number: number;
|
||||
pull_request?: object;
|
||||
};
|
||||
comment?: {
|
||||
body: string;
|
||||
user: { login: string; };
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckResult {
|
||||
should_build: boolean;
|
||||
pr_number?: number;
|
||||
pr_sha?: string;
|
||||
pr_head_repo?: string;
|
||||
pr_head_ref?: string;
|
||||
}
|
||||
|
||||
// ============== 权限检查 ==============
|
||||
|
||||
async function checkUserPermission (
|
||||
github: GitHubAPI,
|
||||
owner: string,
|
||||
repo: string,
|
||||
username: string
|
||||
): Promise<boolean> {
|
||||
// 方法1:检查仓库协作者权限
|
||||
try {
|
||||
const permission = await github.getCollaboratorPermission(owner, repo, username);
|
||||
if (['admin', 'write', 'maintain'].includes(permission)) {
|
||||
console.log(`✓ User ${username} has ${permission} permission`);
|
||||
return true;
|
||||
}
|
||||
console.log(`✗ User ${username} has ${permission} permission (insufficient)`);
|
||||
} catch (e) {
|
||||
console.log(`✗ Failed to get collaborator permission: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
// 方法2:检查组织成员身份
|
||||
try {
|
||||
const repoInfo = await github.getRepository(owner, repo);
|
||||
if (repoInfo.owner.type === 'Organization') {
|
||||
const isMember = await github.checkOrgMembership(owner, username);
|
||||
if (isMember) {
|
||||
console.log(`✓ User ${username} is organization member`);
|
||||
return true;
|
||||
}
|
||||
console.log(`✗ User ${username} is not organization member`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`✗ Failed to check org membership: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============== 事件处理 ==============
|
||||
|
||||
function handlePullRequestTarget (payload: GitHubPayload): CheckResult {
|
||||
const pr = payload.pull_request;
|
||||
|
||||
if (!pr) {
|
||||
console.log('✗ No pull_request in payload');
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
if (pr.state !== 'open') {
|
||||
console.log(`✗ PR is not open (state: ${pr.state})`);
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
console.log(`✓ PR #${pr.number} is open, triggering build`);
|
||||
return {
|
||||
should_build: true,
|
||||
pr_number: pr.number,
|
||||
pr_sha: pr.head.sha,
|
||||
pr_head_repo: pr.head.repo.full_name,
|
||||
pr_head_ref: pr.head.ref,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleIssueComment (
|
||||
payload: GitHubPayload,
|
||||
github: GitHubAPI,
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<CheckResult> {
|
||||
const { issue, comment } = payload;
|
||||
|
||||
if (!issue || !comment) {
|
||||
console.log('✗ No issue or comment in payload');
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
// 检查是否是 PR 的评论
|
||||
if (!issue.pull_request) {
|
||||
console.log('✗ Comment is not on a PR');
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
// 检查是否是 /build 命令
|
||||
if (!comment.body.trim().startsWith('/build')) {
|
||||
console.log('✗ Comment is not a /build command');
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
console.log(`→ /build command from @${comment.user.login}`);
|
||||
|
||||
// 获取 PR 详情
|
||||
const pr = await github.getPullRequest(owner, repo, issue.number);
|
||||
|
||||
// 检查 PR 状态
|
||||
if (pr.state !== 'open') {
|
||||
console.log(`✗ PR is not open (state: ${pr.state})`);
|
||||
await github.createComment(owner, repo, issue.number, '⚠️ 此 PR 已关闭,无法触发构建。');
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
// 检查用户权限
|
||||
const username = comment.user.login;
|
||||
const hasPermission = await checkUserPermission(github, owner, repo, username);
|
||||
|
||||
if (!hasPermission) {
|
||||
console.log(`✗ User ${username} has no permission`);
|
||||
await github.createComment(
|
||||
owner,
|
||||
repo,
|
||||
issue.number,
|
||||
`⚠️ @${username} 您没有权限使用 \`/build\` 命令,仅仓库协作者或组织成员可使用。`
|
||||
);
|
||||
return { should_build: false };
|
||||
}
|
||||
|
||||
console.log(`✓ Build triggered by @${username}`);
|
||||
return {
|
||||
should_build: true,
|
||||
pr_number: issue.number,
|
||||
pr_sha: pr.head.sha,
|
||||
pr_head_repo: pr.head.repo.full_name,
|
||||
pr_head_ref: pr.head.ref,
|
||||
};
|
||||
}
|
||||
|
||||
// ============== 主函数 ==============
|
||||
|
||||
async function main (): Promise<void> {
|
||||
console.log('🔍 PR Build Check\n');
|
||||
|
||||
const token = getEnv('GITHUB_TOKEN', true);
|
||||
const eventName = getEnv('GITHUB_EVENT_NAME', true);
|
||||
const eventPath = getEnv('GITHUB_EVENT_PATH', true);
|
||||
const { owner, repo } = getRepository();
|
||||
|
||||
console.log(`Event: ${eventName}`);
|
||||
console.log(`Repository: ${owner}/${repo}\n`);
|
||||
|
||||
const payload = JSON.parse(readFileSync(eventPath, 'utf-8')) as GitHubPayload;
|
||||
const github = new GitHubAPI(token);
|
||||
|
||||
let result: CheckResult;
|
||||
|
||||
switch (eventName) {
|
||||
case 'pull_request_target':
|
||||
result = handlePullRequestTarget(payload);
|
||||
break;
|
||||
case 'issue_comment':
|
||||
result = await handleIssueComment(payload, github, owner, repo);
|
||||
break;
|
||||
default:
|
||||
console.log(`✗ Unsupported event: ${eventName}`);
|
||||
result = { should_build: false };
|
||||
}
|
||||
|
||||
// 输出结果
|
||||
console.log('\n=== Outputs ===');
|
||||
setOutput('should_build', String(result.should_build));
|
||||
setOutput('pr_number', String(result.pr_number ?? ''));
|
||||
setOutput('pr_sha', result.pr_sha ?? '');
|
||||
setOutput('pr_head_repo', result.pr_head_repo ?? '');
|
||||
setOutput('pr_head_ref', result.pr_head_ref ?? '');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
87
.github/scripts/pr-build-result.ts
vendored
Normal file
87
.github/scripts/pr-build-result.ts
vendored
Normal 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
149
.github/scripts/pr-build-run.ts
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* PR Build Runner
|
||||
* 执行构建步骤
|
||||
*
|
||||
* 用法: node pr-build-run.ts <target>
|
||||
* target: framework | shell
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, renameSync, unlinkSync } from 'node:fs';
|
||||
import { setOutput } from './lib/github.ts';
|
||||
|
||||
type BuildTarget = 'framework' | 'shell';
|
||||
|
||||
interface BuildStep {
|
||||
name: string;
|
||||
command: string;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
// ============== 构建步骤 ==============
|
||||
|
||||
function getCommonSteps (): BuildStep[] {
|
||||
return [
|
||||
{
|
||||
name: 'Install pnpm',
|
||||
command: 'npm i -g pnpm',
|
||||
errorMessage: 'Failed to install pnpm',
|
||||
},
|
||||
{
|
||||
name: 'Install dependencies',
|
||||
command: 'pnpm i',
|
||||
errorMessage: 'Failed to install dependencies',
|
||||
},
|
||||
{
|
||||
name: 'Type check',
|
||||
command: 'pnpm run typecheck',
|
||||
errorMessage: 'Type check failed',
|
||||
},
|
||||
{
|
||||
name: 'Test',
|
||||
command: 'pnpm test',
|
||||
errorMessage: 'Tests failed',
|
||||
},
|
||||
{
|
||||
name: 'Build WebUI',
|
||||
command: 'pnpm --filter napcat-webui-frontend run build',
|
||||
errorMessage: 'WebUI build failed',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getTargetSteps (target: BuildTarget): BuildStep[] {
|
||||
if (target === 'framework') {
|
||||
return [
|
||||
{
|
||||
name: 'Build Framework',
|
||||
command: 'pnpm run build:framework',
|
||||
errorMessage: 'Framework build failed',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: 'Build Shell',
|
||||
command: 'pnpm run build:shell',
|
||||
errorMessage: 'Shell build failed',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ============== 执行器 ==============
|
||||
|
||||
function runStep (step: BuildStep): boolean {
|
||||
console.log(`\n::group::${step.name}`);
|
||||
console.log(`> ${step.command}\n`);
|
||||
|
||||
try {
|
||||
execSync(step.command, {
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash',
|
||||
});
|
||||
console.log('::endgroup::');
|
||||
console.log(`✓ ${step.name}`);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
console.log('::endgroup::');
|
||||
console.log(`✗ ${step.name}`);
|
||||
setOutput('error', step.errorMessage);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function postBuild (target: BuildTarget): void {
|
||||
const srcDir = target === 'framework'
|
||||
? 'packages/napcat-framework/dist'
|
||||
: 'packages/napcat-shell/dist';
|
||||
const destDir = target === 'framework' ? 'framework-dist' : 'shell-dist';
|
||||
|
||||
console.log(`\n→ Moving ${srcDir} to ${destDir}`);
|
||||
|
||||
if (!existsSync(srcDir)) {
|
||||
throw new Error(`Build output not found: ${srcDir}`);
|
||||
}
|
||||
|
||||
renameSync(srcDir, destDir);
|
||||
|
||||
// Install production dependencies
|
||||
console.log('→ Installing production dependencies');
|
||||
execSync('npm install --omit=dev', {
|
||||
cwd: destDir,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash',
|
||||
});
|
||||
|
||||
// Remove package-lock.json
|
||||
const lockFile = `${destDir}/package-lock.json`;
|
||||
if (existsSync(lockFile)) {
|
||||
unlinkSync(lockFile);
|
||||
}
|
||||
|
||||
console.log(`✓ Build output ready at ${destDir}`);
|
||||
}
|
||||
|
||||
// ============== 主函数 ==============
|
||||
|
||||
function main (): void {
|
||||
const target = process.argv[2] as BuildTarget;
|
||||
|
||||
if (!target || !['framework', 'shell'].includes(target)) {
|
||||
console.error('Usage: node pr-build-run.ts <framework|shell>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`🔨 Building NapCat.${target === 'framework' ? 'Framework' : 'Shell'}\n`);
|
||||
|
||||
const steps = [...getCommonSteps(), ...getTargetSteps(target)];
|
||||
|
||||
for (const step of steps) {
|
||||
if (!runStep(step)) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
postBuild(target);
|
||||
console.log('\n✅ Build completed successfully!');
|
||||
}
|
||||
|
||||
main();
|
||||
252
.github/workflows/pr-build.yml
vendored
Normal file
252
.github/workflows/pr-build.yml
vendored
Normal 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
|
||||
4
packages/napcat-core/external/appid.json
vendored
4
packages/napcat-core/external/appid.json
vendored
@@ -498,5 +498,9 @@
|
||||
"6.9.86-42941": {
|
||||
"appid": 537328648,
|
||||
"qua": "V1_MAC_NQ_6.9.86_42941_GW_B"
|
||||
},
|
||||
"9.9.26-44175": {
|
||||
"appid": 537336450,
|
||||
"qua": "V1_WIN_NQ_9.9.26_44175_GW_B"
|
||||
}
|
||||
}
|
||||
@@ -126,5 +126,9 @@
|
||||
"6.9.86-42941-arm64": {
|
||||
"send": "2346108",
|
||||
"recv": "09675F0"
|
||||
},
|
||||
"9.9.26-44175-x64": {
|
||||
"send": "0A0F2EC",
|
||||
"recv": "1D3AD4D"
|
||||
}
|
||||
}
|
||||
4
packages/napcat-core/external/packet.json
vendored
4
packages/napcat-core/external/packet.json
vendored
@@ -638,5 +638,9 @@
|
||||
"6.9.86-42941-arm64": {
|
||||
"send": "3DDDAD0",
|
||||
"recv": "3DE03E0"
|
||||
},
|
||||
"9.9.26-44175-x64": {
|
||||
"send": "2CD84A0",
|
||||
"recv": "2CDBA20"
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,12 @@ import { NapProtoMsg } from 'napcat-protobuf';
|
||||
import * as proto from '@/napcat-core/packet/transformer/proto';
|
||||
import * as trans from '@/napcat-core/packet/transformer';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { NapCoreContext } from '@/napcat-core/packet/context/napCoreContext';
|
||||
import { PacketClientContext } from '@/napcat-core/packet/context/clientContext';
|
||||
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import { defaultVideoThumbB64 } from '@/napcat-core/helper/ffmpeg/video';
|
||||
import { calculateFileMD5 } from 'napcat-common/src/file';
|
||||
|
||||
export const BlockSize = 1024 * 1024;
|
||||
|
||||
@@ -105,13 +109,89 @@ export class PacketHighwayContext {
|
||||
if (+(video.fileSize ?? 0) > 1024 * 1024 * 100) {
|
||||
throw new Error(`[Highway] 视频文件过大: ${(+(video.fileSize ?? 0) / (1024 * 1024)).toFixed(2)} MB > 100 MB,请使用文件上传!`);
|
||||
}
|
||||
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
|
||||
await this.uploadGroupVideo(+peer.peerUid, video);
|
||||
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
|
||||
await this.uploadC2CVideo(peer.peerUid, video);
|
||||
} else {
|
||||
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
|
||||
|
||||
// 如果缺少视频缩略图,自动生成一个
|
||||
let tempThumbPath: string | null = null;
|
||||
let thumbExists = false;
|
||||
if (video.thumbPath) {
|
||||
try {
|
||||
await fs.promises.access(video.thumbPath, fs.constants.F_OK);
|
||||
thumbExists = true;
|
||||
} catch {
|
||||
thumbExists = false;
|
||||
}
|
||||
}
|
||||
if (!video.thumbPath || !thumbExists) {
|
||||
tempThumbPath = await this.ensureVideoThumb(video);
|
||||
}
|
||||
|
||||
try {
|
||||
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
|
||||
await this.uploadGroupVideo(+peer.peerUid, video);
|
||||
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
|
||||
await this.uploadC2CVideo(peer.peerUid, video);
|
||||
} else {
|
||||
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
|
||||
}
|
||||
} finally {
|
||||
// 清理临时生成的缩略图文件
|
||||
if (tempThumbPath) {
|
||||
const thumbToClean = tempThumbPath;
|
||||
fs.promises.unlink(thumbToClean)
|
||||
.then(() => this.logger.debug(`[Highway] Cleaned up temp thumbnail: ${thumbToClean}`))
|
||||
.catch((e) => {
|
||||
// 文件不存在时忽略错误
|
||||
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
this.logger.warn(`[Highway] Failed to clean up temp thumbnail: ${thumbToClean}, reason: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保视频缩略图存在,如果不存在则自动生成
|
||||
* @returns 生成的临时缩略图路径,用于后续清理
|
||||
*/
|
||||
private async ensureVideoThumb (video: PacketMsgVideoElement): Promise<string> {
|
||||
if (!video.filePath) {
|
||||
throw new Error('video.filePath is empty, cannot generate thumbnail');
|
||||
}
|
||||
|
||||
// 生成缩略图路径
|
||||
const videoDir = path.dirname(video.filePath);
|
||||
const videoBasename = path.basename(video.filePath, path.extname(video.filePath));
|
||||
const thumbPath = path.join(videoDir, `${videoBasename}_thumb.png`);
|
||||
|
||||
this.logger.debug(`[Highway] Video thumb missing, generating at: ${thumbPath}`);
|
||||
|
||||
try {
|
||||
// 尝试使用 FFmpeg 提取视频缩略图
|
||||
await FFmpegService.extractThumbnail(video.filePath, thumbPath);
|
||||
try {
|
||||
await fs.promises.access(thumbPath, fs.constants.F_OK);
|
||||
this.logger.debug('[Highway] Video thumbnail generated successfully using FFmpeg');
|
||||
} catch {
|
||||
throw new Error('FFmpeg failed to generate thumbnail');
|
||||
}
|
||||
} catch (e) {
|
||||
// FFmpeg 失败时(包括未初始化的情况)使用默认缩略图
|
||||
this.logger.warn(`[Highway] Failed to extract thumbnail, using default. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
await fs.promises.writeFile(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
|
||||
}
|
||||
|
||||
// 更新视频元素的缩略图信息
|
||||
video.thumbPath = thumbPath;
|
||||
const thumbStat = await fs.promises.stat(thumbPath);
|
||||
video.thumbSize = thumbStat.size;
|
||||
video.thumbMd5 = await calculateFileMD5(thumbPath);
|
||||
// 默认缩略图尺寸(与 defaultVideoThumbB64 匹配的尺寸)
|
||||
if (!video.thumbWidth) video.thumbWidth = 240;
|
||||
if (!video.thumbHeight) video.thumbHeight = 383;
|
||||
|
||||
this.logger.debug(`[Highway] Video thumb info set: path=${thumbPath}, size=${video.thumbSize}, md5=${video.thumbMd5}`);
|
||||
|
||||
return thumbPath;
|
||||
}
|
||||
|
||||
async uploadPtt (peer: Peer, ptt: PacketMsgPttElement): Promise<void> {
|
||||
|
||||
@@ -14,7 +14,7 @@ export class PacketMsgBuilder {
|
||||
|
||||
buildFakeMsg (selfUid: string, element: PacketMsg[]): NapProtoEncodeStructType<typeof PushMsgBody>[] {
|
||||
return element.map((node): NapProtoEncodeStructType<typeof PushMsgBody> => {
|
||||
const avatar = `https://q.qlogo.cn/headimg_dl?dst_uin=${node.senderUin}&spec=640&img_type=jpg`;
|
||||
const avatar = `https://q.qlogo.cn/headimg_dl?dst_uin=${node.senderUin}&spec=0&img_type=jpg`;
|
||||
const msgContent = node.msg.reduceRight((acc: undefined | Uint8Array, msg: IPacketMsgElement<PacketSendMsgElement>) => {
|
||||
return acc ?? msg.buildContent();
|
||||
}, undefined);
|
||||
|
||||
@@ -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;
|
||||
|
||||
35
packages/napcat-onebot/event/notice/OB11GroupGrayTipEvent.ts
Normal file
35
packages/napcat-onebot/event/notice/OB11GroupGrayTipEvent.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { OB11BaseNoticeEvent } from './OB11BaseNoticeEvent';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
|
||||
/**
|
||||
* 群灰条消息事件
|
||||
* 用于上报未知类型的灰条消息,便于下游检测和处理伪造灰条攻击
|
||||
*/
|
||||
export class OB11GroupGrayTipEvent extends OB11BaseNoticeEvent {
|
||||
notice_type = 'notify';
|
||||
sub_type = 'gray_tip';
|
||||
group_id: number;
|
||||
user_id: number; // 真实发送者QQ(如果是伪造的灰条,这就是攻击者)
|
||||
message_id: number; // 消息ID,可用于撤回
|
||||
busi_id: string; // 业务ID
|
||||
content: string; // 灰条内容(JSON字符串)
|
||||
raw_info: unknown; // 原始信息
|
||||
|
||||
constructor (
|
||||
core: NapCatCore,
|
||||
groupId: number,
|
||||
userId: number,
|
||||
messageId: number,
|
||||
busiId: string,
|
||||
content: string,
|
||||
rawInfo: unknown
|
||||
) {
|
||||
super(core);
|
||||
this.group_id = groupId;
|
||||
this.user_id = userId;
|
||||
this.message_id = messageId;
|
||||
this.busi_id = busiId;
|
||||
this.content = content;
|
||||
this.raw_info = rawInfo;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
import type { createActionMap } from 'napcat-onebot/action';
|
||||
import { EventType } from 'napcat-onebot/event/OneBotEvent';
|
||||
import type { PluginModule } from 'napcat-onebot/network/plugin';
|
||||
|
||||
/**
|
||||
* 导入 napcat 包时候不使用 @/napcat...,直接使用 napcat...
|
||||
* 因为 @/napcat... 会导致打包时包含整个 napcat 包,而不是只包含需要的部分
|
||||
*/
|
||||
|
||||
// action 作为参数传递时请用这个
|
||||
let actionMap: ReturnType<typeof createActionMap> | undefined = undefined;
|
||||
|
||||
const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => {
|
||||
console.log('[Plugin: example] 插件已初始化');
|
||||
actionMap = _actions;
|
||||
};
|
||||
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, actions, instance) => {
|
||||
if (event.post_type === EventType.MESSAGE && event.raw_message.includes('ping')) {
|
||||
await actions.get('send_group_msg')?.handle({ group_id: String(event.group_id), message: 'pong' }, adapter, instance.config);
|
||||
}
|
||||
};
|
||||
export { plugin_init, plugin_onmessage };
|
||||
export { plugin_init, plugin_onmessage, actionMap };
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
conditions: ['node', 'default'],
|
||||
alias: {
|
||||
'@/napcat-core': resolve(__dirname, '../core'),
|
||||
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
||||
'@': resolve(__dirname, '../'),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@echo off
|
||||
chcp 65001
|
||||
chcp 65001 >nul
|
||||
set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json
|
||||
set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js
|
||||
set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll
|
||||
@@ -27,6 +27,6 @@ if not exist "%QQpath%" (
|
||||
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
|
||||
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
|
||||
|
||||
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %1
|
||||
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
|
||||
|
||||
pause
|
||||
pause
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@echo off
|
||||
chcp 65001
|
||||
chcp 65001 >nul
|
||||
set NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json
|
||||
set NAPCAT_LOAD_PATH=%cd%\loadNapCat.js
|
||||
set NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll
|
||||
@@ -26,8 +26,9 @@ if not exist "%QQpath%" (
|
||||
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
|
||||
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
|
||||
|
||||
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %1
|
||||
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
|
||||
|
||||
REM "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" 123456
|
||||
REM Optional: -q <QQ_NUMBER> for quick login, omit for QR code login
|
||||
REM Example: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456
|
||||
|
||||
pause
|
||||
pause
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
@echo off
|
||||
chcp 65001
|
||||
chcp 65001 >nul
|
||||
net session >nul 2>&1
|
||||
if %errorLevel% == 0 (
|
||||
if %ERRORLEVEL% == 0 (
|
||||
echo Administrator mode detected.
|
||||
) else (
|
||||
echo Please run this script in administrator mode.
|
||||
powershell -Command "Start-Process 'cmd.exe' -ArgumentList '/c cd /d \"%cd%\" && \"%~f0\" %1' -Verb runAs"
|
||||
powershell -Command "Start-Process 'cmd.exe' -ArgumentList '/c cd /d \"%cd%\" && \"%~f0\" %*' -Verb runAs"
|
||||
exit
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ if not exist "%QQPath%" (
|
||||
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
|
||||
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
|
||||
|
||||
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %1
|
||||
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
|
||||
|
||||
REM "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" 123456
|
||||
REM Optional: -q <QQ_NUMBER> for quick login, omit for QR code login
|
||||
REM Example: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
@echo off
|
||||
chcp 65001
|
||||
chcp 65001 >nul
|
||||
net session >nul 2>&1
|
||||
if %errorLevel% == 0 (
|
||||
if %ERRORLEVEL% == 0 (
|
||||
echo Administrator mode detected.
|
||||
) else (
|
||||
echo Please run this script in administrator mode.
|
||||
powershell -Command "Start-Process 'wt.exe' -ArgumentList 'cmd /c cd /d \"%cd%\" && \"%~f0\" %1' -Verb runAs"
|
||||
powershell -Command "Start-Process 'wt.exe' -ArgumentList 'cmd /c cd /d \"%cd%\" && \"%~f0\" %*' -Verb runAs"
|
||||
exit
|
||||
)
|
||||
|
||||
@@ -36,4 +36,4 @@ if not exist "%QQPath%" (
|
||||
set NAPCAT_MAIN_PATH=%NAPCAT_MAIN_PATH:\=/%
|
||||
echo (async () =^> {await import("file:///%NAPCAT_MAIN_PATH%")})() > "%NAPCAT_LOAD_PATH%"
|
||||
|
||||
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %1
|
||||
"%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %*
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
@echo off
|
||||
REM ./launcher.bat 123456
|
||||
REM ./launcher-win10.bat 123456
|
||||
REM 带有REM的为注释 删掉你需要的系统的那行REM这三个单词 修改QQ本脚本启动即可
|
||||
REM 快速登录示例脚本
|
||||
REM -q 参数是可选的,不传则使用二维码登录
|
||||
REM
|
||||
REM 使用方法(删掉对应系统那行的 REM):
|
||||
REM ./launcher.bat -q 123456
|
||||
REM ./launcher-win10.bat -q 123456
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
VITE_DEBUG_BACKEND_URL="http://127.0.0.1:6099"
|
||||
VITE_DEBUG_BACKEND_URL="http://127.0.0.1:6099"
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,9 +14,9 @@ import { useEffect } from 'react';
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
isOpen: boolean
|
||||
filePath: string
|
||||
onClose: () => void
|
||||
isOpen: boolean;
|
||||
filePath: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const videoExts = ['.mp4', '.webm'];
|
||||
@@ -75,14 +75,14 @@ export default function FilePreviewModal ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior='inside' size='3xl'>
|
||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose} scrollBehavior='inside' size='3xl'>
|
||||
<ModalContent>
|
||||
<ModalHeader>文件预览</ModalHeader>
|
||||
<ModalBody className='flex justify-center items-center'>
|
||||
{contentElement}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -105,6 +105,7 @@ export default function FileTable ({
|
||||
/>
|
||||
<Table
|
||||
aria-label='文件列表'
|
||||
radius='sm'
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={onSortChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
@@ -175,6 +176,7 @@ export default function FileTable ({
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
radius='sm'
|
||||
variant='light'
|
||||
onPress={() =>
|
||||
file.isDirectory
|
||||
@@ -202,7 +204,7 @@ export default function FileTable ({
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>{new Date(file.mtime).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<ButtonGroup size='sm' variant='light'>
|
||||
<ButtonGroup radius='sm' size='sm' variant='light'>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='default'
|
||||
|
||||
@@ -10,17 +10,17 @@ import FileManager from '@/controllers/file_manager';
|
||||
import FileIcon from '../file_icon';
|
||||
|
||||
export interface PreviewImage {
|
||||
key: string
|
||||
src: string
|
||||
alt: string
|
||||
key: string;
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
export const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp'];
|
||||
|
||||
export interface ImageNameButtonProps {
|
||||
name: string
|
||||
filePath: string
|
||||
onPreview: () => void
|
||||
onAddPreview: (image: PreviewImage) => void
|
||||
name: string;
|
||||
filePath: string;
|
||||
onPreview: () => void;
|
||||
onAddPreview: (image: PreviewImage) => void;
|
||||
}
|
||||
|
||||
export default function ImageNameButton ({
|
||||
@@ -61,6 +61,7 @@ export default function ImageNameButton ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
radius='sm'
|
||||
variant='light'
|
||||
className='text-left justify-start'
|
||||
onPress={onPreview}
|
||||
|
||||
@@ -83,15 +83,16 @@ function DirectoryTree ({
|
||||
return (
|
||||
<div className='ml-4'>
|
||||
<Button
|
||||
radius='sm'
|
||||
onPress={handleClick}
|
||||
className='py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md'
|
||||
className='py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-sm'
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant={variant}
|
||||
startContent={
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-md',
|
||||
'rounded-sm',
|
||||
isSeleted ? 'bg-primary-600' : 'bg-primary-50'
|
||||
)}
|
||||
>
|
||||
@@ -140,11 +141,11 @@ export default function MoveModal ({
|
||||
onSelect,
|
||||
}: MoveModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>选择目标目录</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='rounded-md p-2 border border-default-300 overflow-auto max-h-60'>
|
||||
<div className='rounded-sm p-2 border border-default-300 overflow-auto max-h-60'>
|
||||
<DirectoryTree
|
||||
basePath='/'
|
||||
onSelect={onSelect}
|
||||
@@ -157,10 +158,10 @@ export default function MoveModal ({
|
||||
<p className='text-sm text-default-500'>移动项:{selectionInfo}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color='primary' onPress={onMove}>
|
||||
<Button radius='sm' color='primary' onPress={onMove}>
|
||||
确定
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
} from '@heroui/modal';
|
||||
|
||||
interface RenameModalProps {
|
||||
isOpen: boolean
|
||||
newFileName: string
|
||||
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onClose: () => void
|
||||
onRename: () => void
|
||||
isOpen: boolean;
|
||||
newFileName: string;
|
||||
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onClose: () => void;
|
||||
onRename: () => void;
|
||||
}
|
||||
|
||||
export default function RenameModal ({
|
||||
@@ -24,17 +24,17 @@ export default function RenameModal ({
|
||||
onRename,
|
||||
}: RenameModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<Modal radius='sm' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>重命名</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input label='新名称' value={newFileName} onChange={onNameChange} />
|
||||
<Input radius='sm' label='新名称' value={newFileName} onChange={onNameChange} />
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color='primary' variant='flat' onPress={onClose}>
|
||||
<Button radius='sm' color='primary' variant='flat' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color='primary' onPress={onRename}>
|
||||
<Button radius='sm' color='primary' onPress={onRename}>
|
||||
确定
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable @stylistic/jsx-closing-bracket-location */
|
||||
/* eslint-disable @stylistic/jsx-closing-tag-location */
|
||||
import { Button } from '@heroui/button';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -48,7 +48,7 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
<Image
|
||||
src={
|
||||
data?.avatarUrl ??
|
||||
`https://q1.qlogo.cn/g?b=qq&nk=${data?.uin}&s=1`
|
||||
`https://q1.qlogo.cn/g?b=qq&nk=${data?.uin}&s=0`
|
||||
}
|
||||
className='shadow-sm rounded-full w-14 aspect-square ring-2 ring-white/50 dark:ring-white/10'
|
||||
/>
|
||||
@@ -63,13 +63,15 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
<div className={clsx(
|
||||
'text-xl font-bold truncate mb-0.5',
|
||||
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-800 dark:text-gray-100'
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
{data?.nick || '未知用户'}
|
||||
</div>
|
||||
<div className={clsx(
|
||||
'font-mono text-xs tracking-wider',
|
||||
hasBackground ? 'text-white/80' : 'text-default-500 opacity-80'
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
{data?.uin || 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,17 +7,17 @@ import { IoMdRefresh } from 'react-icons/io';
|
||||
import { isQQQuickNewItem } from '@/utils/qq';
|
||||
|
||||
export interface QQItem {
|
||||
uin: string
|
||||
uin: string;
|
||||
}
|
||||
|
||||
interface QuickLoginProps {
|
||||
qqList: (QQItem | LoginListItem)[]
|
||||
refresh: boolean
|
||||
isLoading: boolean
|
||||
selectedQQ: string
|
||||
onUpdateQQList: () => void
|
||||
handleSelectionChange: React.ChangeEventHandler<HTMLSelectElement>
|
||||
onSubmit: () => void
|
||||
qqList: (QQItem | LoginListItem)[];
|
||||
refresh: boolean;
|
||||
isLoading: boolean;
|
||||
selectedQQ: string;
|
||||
onUpdateQQList: () => void;
|
||||
handleSelectionChange: React.ChangeEventHandler<HTMLSelectElement>;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
const QuickLogin: React.FC<QuickLoginProps> = ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -102,7 +102,7 @@ const DashboardIndexPage: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<title>基础信息 - NapCat WebUI</title>
|
||||
<section className='w-full p-2 md:p-4 md:max-w-[1000px] mx-auto'>
|
||||
<section className='w-full p-2 md:p-4 md:max-w-[1000px] mx-auto overflow-hidden'>
|
||||
<div className='grid grid-cols-1 lg:grid-cols-3 gap-4 items-stretch'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<QQInfo />
|
||||
@@ -112,10 +112,11 @@ const DashboardIndexPage: React.FC = () => {
|
||||
</div>
|
||||
<Networks />
|
||||
<Card className={clsx(
|
||||
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all',
|
||||
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all overflow-hidden',
|
||||
hasBackground ? 'bg-white/10 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
|
||||
)}>
|
||||
<CardBody>
|
||||
)}
|
||||
>
|
||||
<CardBody className='overflow-hidden'>
|
||||
<Hitokoto />
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function TerminalPage () {
|
||||
variant='flat'
|
||||
onPress={createNewTerminal}
|
||||
startContent={<IoAdd />}
|
||||
className='text-xl'
|
||||
className='text-xl ml-auto'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-grow overflow-hidden'>
|
||||
|
||||
Reference in New Issue
Block a user