Compare commits

..

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

128 changed files with 2711 additions and 20756 deletions

View File

@ -1,128 +0,0 @@
/**
*
*/
export const COMMENT_MARKER = '<!-- napcat-pr-build -->';
export type BuildStatus = 'success' | 'failure' | 'cancelled' | 'pending' | 'unknown';
export interface BuildTarget {
name: string;
status: BuildStatus;
error?: string;
downloadUrl?: string; // Artifact 直接下载链接
}
// ============== 辅助函数 ==============
function formatSha (sha: string): string {
return sha && sha.length >= 7 ? sha.substring(0, 7) : sha || 'unknown';
}
function escapeCodeBlock (text: string): string {
// 替换 ``` 为转义形式,避免破坏 Markdown 代码块
return text.replace(/```/g, '\\`\\`\\`');
}
// ============== 状态图标 ==============
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');
}

View File

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

View File

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

View File

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

View File

@ -1,87 +0,0 @@
/**
* 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);
});

View File

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

View File

@ -1,252 +0,0 @@
# =============================================================================
# PR 构建工作流
# =============================================================================
# 功能:
# 1. 在 PR 提交时自动构建 Framework 和 Shell 包
# 2. 支持通过 /build 命令手动触发构建(仅协作者/组织成员)
# 3. 在 PR 中发布构建状态评论,并持续更新(不会重复创建)
# 4. 支持 Fork PR 的构建(使用 pull_request_target 获取写权限)
#
# 安全说明:
# - 使用 pull_request_target 事件,在 base 分支上下文运行
# - 构建脚本始终从 base 分支 checkout避免恶意 PR 篡改脚本
# - PR 代码单独 checkout 到 workspace 目录
# =============================================================================
name: PR Build
# =============================================================================
# 触发条件
# =============================================================================
on:
# PR 事件:打开、同步(新推送)、重新打开时触发
# 注意:使用 pull_request_target 而非 pull_request以便对 Fork PR 有写权限
pull_request_target:
types: [opened, synchronize, reopened]
# Issue 评论事件:用于响应 /build 命令
issue_comment:
types: [created]
# =============================================================================
# 权限配置
# =============================================================================
permissions:
contents: read # 读取仓库内容
pull-requests: write # 写入 PR 评论
issues: write # 写入 Issue 评论(/build 命令响应)
actions: read # 读取 Actions 信息(获取构建日志链接)
# =============================================================================
# 并发控制
# =============================================================================
# 同一 PR 的多次构建会取消之前未完成的构建,避免资源浪费
concurrency:
group: pr-build-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}
cancel-in-progress: true
# =============================================================================
# 任务定义
# =============================================================================
jobs:
# ---------------------------------------------------------------------------
# Job 1: 检查构建条件
# ---------------------------------------------------------------------------
# 判断是否应该触发构建:
# - pull_request_target 事件:总是触发
# - issue_comment 事件:检查是否为 /build 命令,且用户有权限
# ---------------------------------------------------------------------------
check-build:
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.check.outputs.should_build }} # 是否应该构建
pr_number: ${{ steps.check.outputs.pr_number }} # PR 编号
pr_sha: ${{ steps.check.outputs.pr_sha }} # PR 最新提交 SHA
pr_head_repo: ${{ steps.check.outputs.pr_head_repo }} # PR 源仓库(用于 Fork
pr_head_ref: ${{ steps.check.outputs.pr_head_ref }} # PR 源分支
steps:
# 仅 checkout 脚本目录,加快速度
- name: Checkout scripts
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
# 使用 Node.js 24 以支持原生 TypeScript 执行
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 执行检查脚本,判断是否触发构建
- name: Check trigger condition
id: check
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node --experimental-strip-types .github/scripts/pr-build-check.ts
# ---------------------------------------------------------------------------
# Job 2: 更新评论为"构建中"状态
# ---------------------------------------------------------------------------
# 在 PR 中创建或更新评论,显示构建正在进行中
# ---------------------------------------------------------------------------
update-comment-building:
needs: check-build
if: needs.check-build.outputs.should_build == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout scripts
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 更新 PR 评论,显示构建中状态
- name: Update building comment
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ needs.check-build.outputs.pr_number }}
PR_SHA: ${{ needs.check-build.outputs.pr_sha }}
run: node --experimental-strip-types .github/scripts/pr-build-building.ts
# ---------------------------------------------------------------------------
# Job 3: 构建 Framework 包
# ---------------------------------------------------------------------------
# 执行 napcat-framework 的构建流程
# ---------------------------------------------------------------------------
build-framework:
needs: [check-build, update-comment-building]
if: needs.check-build.outputs.should_build == 'true'
runs-on: ubuntu-latest
outputs:
status: ${{ steps.build.outcome }} # 构建结果success/failure
error: ${{ steps.build.outputs.error }} # 错误信息(如有)
steps:
# 【安全】先从 base 分支 checkout 构建脚本
# 这样即使 PR 中修改了脚本,也不会被执行
- name: Checkout scripts from base
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
path: _scripts
# 将 PR 代码 checkout 到单独的 workspace 目录
- name: Checkout PR code
uses: actions/checkout@v4
with:
repository: ${{ needs.check-build.outputs.pr_head_repo }}
ref: ${{ needs.check-build.outputs.pr_sha }}
path: workspace
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 执行构建,使用 base 分支的脚本处理 workspace 中的代码
- name: Build
id: build
working-directory: workspace
run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts framework
continue-on-error: true # 允许失败,后续更新评论时处理
# 构建成功时上传产物
- name: Upload Artifact
if: steps.build.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: NapCat.Framework
path: workspace/framework-dist
retention-days: 7 # 保留 7 天
# ---------------------------------------------------------------------------
# Job 4: 构建 Shell 包
# ---------------------------------------------------------------------------
# 执行 napcat-shell 的构建流程(与 Framework 并行执行)
# ---------------------------------------------------------------------------
build-shell:
needs: [check-build, update-comment-building]
if: needs.check-build.outputs.should_build == 'true'
runs-on: ubuntu-latest
outputs:
status: ${{ steps.build.outcome }} # 构建结果success/failure
error: ${{ steps.build.outputs.error }} # 错误信息(如有)
steps:
# 【安全】先从 base 分支 checkout 构建脚本
- name: Checkout scripts from base
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
path: _scripts
# 将 PR 代码 checkout 到单独的 workspace 目录
- name: Checkout PR code
uses: actions/checkout@v4
with:
repository: ${{ needs.check-build.outputs.pr_head_repo }}
ref: ${{ needs.check-build.outputs.pr_sha }}
path: workspace
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 执行构建
- name: Build
id: build
working-directory: workspace
run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts shell
continue-on-error: true
# 构建成功时上传产物
- name: Upload Artifact
if: steps.build.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: NapCat.Shell
path: workspace/shell-dist
retention-days: 7 # 保留 7 天
# ---------------------------------------------------------------------------
# Job 5: 更新评论为构建结果
# ---------------------------------------------------------------------------
# 汇总所有构建结果,更新 PR 评论显示最终状态
# 使用 always() 确保即使构建失败/取消也会执行
# ---------------------------------------------------------------------------
update-comment-result:
needs: [check-build, update-comment-building, build-framework, build-shell]
if: always() && needs.check-build.outputs.should_build == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout scripts
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 更新评论,显示构建结果和下载链接
- name: Update result comment
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ needs.check-build.outputs.pr_number }}
PR_SHA: ${{ needs.check-build.outputs.pr_sha }}
RUN_ID: ${{ github.run_id }}
# 获取构建状态,如果 job 被跳过则标记为 cancelled
FRAMEWORK_STATUS: ${{ needs.build-framework.outputs.status || 'cancelled' }}
FRAMEWORK_ERROR: ${{ needs.build-framework.outputs.error }}
SHELL_STATUS: ${{ needs.build-shell.outputs.status || 'cancelled' }}
SHELL_ERROR: ${{ needs.build-shell.outputs.error }}
run: node --experimental-strip-types .github/scripts/pr-build-result.ts

View File

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

View File

@ -2,7 +2,6 @@ import path from 'node:path';
import fs from 'fs'; import fs from 'fs';
import os from 'node:os'; import os from 'node:os';
import { QQVersionConfigType, QQLevel } from './types'; import { QQVersionConfigType, QQLevel } from './types';
import { RequestUtil } from './request';
export async function solveProblem<T extends (...arg: any[]) => any> (func: T, ...args: Parameters<T>): Promise<ReturnType<T> | undefined> { export async function solveProblem<T extends (...arg: any[]) => any> (func: T, ...args: Parameters<T>): Promise<ReturnType<T> | undefined> {
return new Promise<ReturnType<T> | undefined>((resolve) => { return new Promise<ReturnType<T> | undefined>((resolve) => {
@ -212,81 +211,3 @@ export function parseAppidFromMajor (nodeMajor: string): string | undefined {
return undefined; return undefined;
} }
const baseUrl = 'https://github.com/NapNeko/NapCatQQ.git/info/refs?service=git-upload-pack';
const urls = [
'https://j.1win.ggff.net/' + baseUrl,
'https://git.yylx.win/' + baseUrl,
'https://ghfile.geekertao.top/' + baseUrl,
'https://gh-proxy.net/' + baseUrl,
'https://ghm.078465.xyz/' + baseUrl,
'https://gitproxy.127731.xyz/' + baseUrl,
'https://jiashu.1win.eu.org/' + baseUrl,
baseUrl,
];
async function testUrl (url: string): Promise<boolean> {
try {
await PromiseTimer(RequestUtil.HttpGetText(url), 5000);
return true;
} catch {
return false;
}
}
async function findAvailableUrl (): Promise<string | null> {
for (const url of urls) {
if (await testUrl(url)) {
return url;
}
}
return null;
}
export async function getAllTags (): Promise<string[]> {
const availableUrl = await findAvailableUrl();
if (!availableUrl) {
throw new Error('No available URL for fetching tags');
}
const raw = await RequestUtil.HttpGetText(availableUrl);
return raw
.split('\n')
.map(line => {
const match = line.match(/refs\/tags\/(.+)$/);
return match ? match[1] : null;
})
.filter(tag => tag !== null && !tag!.endsWith('^{}')) as string[];
}
export async function getLatestTag (): Promise<string> {
const tags = await getAllTags();
tags.sort((a, b) => compareVersion(a, b));
const latest = tags.at(-1);
if (!latest) {
throw new Error('No tags found');
}
// 去掉开头的 v
return latest.replace(/^v/, '');
}
function compareVersion (a: string, b: string): number {
const normalize = (v: string) =>
v.replace(/^v/, '') // 去掉开头的 v
.split('.')
.map(n => parseInt(n) || 0);
const pa = normalize(a);
const pb = normalize(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const na = pa[i] || 0;
const nb = pb[i] || 0;
if (na !== nb) return na - nb;
}
return 0;
}

View File

@ -5,7 +5,7 @@ export class NodeIDependsAdapter {
} }
onMSFSsoError (_code: number, _desc: string) { onMSFSsoError (_args: unknown) {
} }

View File

@ -466,37 +466,5 @@
"6.9.85-42086": { "6.9.85-42086": {
"appid": 537320237, "appid": 537320237,
"qua": "V1_MAC_NQ_6.9.85_42086_GW_B" "qua": "V1_MAC_NQ_6.9.85_42086_GW_B"
},
"9.9.23-42430": {
"appid": 537320212,
"qua": "V1_WIN_NQ_9.9.23_42430_GW_B"
},
"9.9.25-42744": {
"appid": 537328470,
"qua": "V1_WIN_NQ_9.9.23_42744_GW_B"
},
"6.9.86-42744": {
"appid": 537328495,
"qua": "V1_MAC_NQ_6.9.85_42744_GW_B"
},
"9.9.25-42905": {
"appid": 537328521,
"qua": "V1_WIN_NQ_9.9.25_42905_GW_B"
},
"6.9.86-42905": {
"appid": 537328546,
"qua": "V1_MAC_NQ_6.9.86_42905_GW_B"
},
"3.2.22-42941": {
"appid": 537328659,
"qua": "V1_LNX_NQ_3.2.22_42941_GW_B"
},
"9.9.25-42941": {
"appid": 537328623,
"qua": "V1_WIN_NQ_9.9.25_42941_GW_B"
},
"6.9.86-42941": {
"appid": 537328648,
"qua": "V1_MAC_NQ_6.9.86_42941_GW_B"
} }
} }

View File

@ -90,41 +90,5 @@
"3.2.21-42086-x64": { "3.2.21-42086-x64": {
"send": "5B42CF0", "send": "5B42CF0",
"recv": "2FDA6F0" "recv": "2FDA6F0"
},
"9.9.23-42430-x64": {
"send": "0A01A34",
"recv": "1D1CFF9"
},
"9.9.25-42744-x64": {
"send": "0A0D104",
"recv": "1D3E7F9"
},
"6.9.85-42744-arm64": {
"send": "23DFEF0",
"recv": "095FD80"
},
"9.9.25-42905-x64": {
"send": "0A12E74",
"recv": "1D450FD"
},
"6.9.86-42905-arm64": {
"send": "2342408",
"recv": "09639B8"
},
"3.2.22-42941-x64": {
"send": "5BC1630",
"recv": "3011E00"
},
"3.2.22-42941-arm64": {
"send": "3DC90AC",
"recv": "1497A70"
},
"9.9.25-42941-x64": {
"send": "0A131D4",
"recv": "1D4547D"
},
"6.9.86-42941-arm64": {
"send": "2346108",
"recv": "09675F0"
} }
} }

View File

@ -602,41 +602,5 @@
"3.2.21-42086-arm64": { "3.2.21-42086-arm64": {
"send": "6B13038", "send": "6B13038",
"recv": "6B169C8" "recv": "6B169C8"
},
"9.9.23-42430-x64": {
"send": "2C9A4A0",
"recv": "2C9DA20"
},
"9.9.25-42744-x64": {
"send": "2CD8E40",
"recv": "2CDC3C0"
},
"6.9.86-42744-arm64": {
"send": "3DCC840",
"recv": "3DCF150"
},
"9.9.25-42905-x64": {
"send": "2CE46A0",
"recv": "2CE7C20"
},
"6.9.86-42905-arm64": {
"send": "3DD6098",
"recv": "3DD89A8"
},
"3.2.22-42941-x64": {
"send": "A8AD8A0",
"recv": "A8B1320"
},
"9.9.25-42941-x64": {
"send": "2CE4DA0",
"recv": "2CE8320"
},
"3.2.22-42941-arm64": {
"send": "6BC95E8",
"recv": "6BCCF78"
},
"6.9.86-42941-arm64": {
"send": "3DDDAD0",
"recv": "3DE03E0"
} }
} }

View File

@ -126,7 +126,7 @@ export class NapCatCore {
container.bind(TypedEventEmitter).toConstantValue(this.event); container.bind(TypedEventEmitter).toConstantValue(this.event);
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => { ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
container.bind(ServiceClass).toSelf(); container.bind(ServiceClass).toSelf();
//console.log(`Registering service handler for: ${serviceName}`); console.log(`Registering service handler for: ${serviceName}`);
this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => { this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => {
const serviceInstance = container.get(ServiceClass); const serviceInstance = container.get(ServiceClass);
return serviceInstance.handler(seq, hex_data); return serviceInstance.handler(seq, hex_data);

View File

@ -3,7 +3,7 @@ export class NodeIKernelStorageCleanListener {
} }
onScanCacheProgressChanged (_current_progress: number, _total_progress: number): any { onScanCacheProgressChanged (_args: unknown): any {
} }
@ -11,7 +11,7 @@ export class NodeIKernelStorageCleanListener {
} }
onFinishScan (_sizes: Array<`${number}`>): any { onFinishScan (_args: unknown): any {
} }

View File

@ -3,56 +3,39 @@ import { GeneralCallResult } from './common';
export interface NodeIKernelStorageCleanService { export interface NodeIKernelStorageCleanService {
addKernelStorageCleanListener (listener: NodeIKernelStorageCleanListener): number; addKernelStorageCleanListener(listener: NodeIKernelStorageCleanListener): number;
removeKernelStorageCleanListener (listenerId: number): void; removeKernelStorageCleanListener(listenerId: number): void;
// [
// "hotUpdate",
// [
// "C:\\Users\\nanaeo\\AppData\\Roaming\\QQ\\packages"
// ]
// ],
// [
// "tmp",
// [
// "C:\\Users\\nanaeo\\AppData\\Roaming\\QQ\\tmp"
// ]
// ],
// [
// "SilentCacheappSessionPartation9212",
// [
// "C:\\Users\\nanaeo\\AppData\\Roaming\\QQ\\Partitions\\qqnt_9212"
// ]
// ]
addCacheScanedPaths (paths: Map<`tmp` | `SilentCacheappSessionPartation9212` | `hotUpdate`, unknown>): unknown;
addFilesScanedPaths (arg: unknown): unknown; addCacheScanedPaths(arg: unknown): unknown;
scanCache (): Promise<GeneralCallResult & { addFilesScanedPaths(arg: unknown): unknown;
size: string[];
scanCache(): Promise<GeneralCallResult & {
size: string[]
}>; }>;
addReportData (arg: unknown): unknown; addReportData(arg: unknown): unknown;
reportData (): unknown; reportData(): unknown;
getChatCacheInfo (arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown): unknown; getChatCacheInfo(arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown): unknown;
getFileCacheInfo (arg1: unknown, arg2: unknown, arg3: unknown, arg44: unknown, args5: unknown): unknown; getFileCacheInfo(arg1: unknown, arg2: unknown, arg3: unknown, arg44: unknown, args5: unknown): unknown;
clearChatCacheInfo (arg1: unknown, arg2: unknown): unknown; clearChatCacheInfo(arg1: unknown, arg2: unknown): unknown;
clearCacheDataByKeys (keys: Array<string>): Promise<GeneralCallResult>; clearCacheDataByKeys(arg: unknown): unknown;
setSilentScan (is_silent: boolean): unknown; setSilentScan(arg: unknown): unknown;
closeCleanWindow (): unknown; closeCleanWindow(): unknown;
clearAllChatCacheInfo (): unknown; clearAllChatCacheInfo(): unknown;
endScan (arg: unknown): unknown; endScan(arg: unknown): unknown;
addNewDownloadOrUploadFile (arg: unknown): unknown; addNewDownloadOrUploadFile(arg: unknown): unknown;
isNull (): boolean; isNull(): boolean;
} }

View File

@ -8,8 +8,6 @@ import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
import { InstanceContext, loadQQWrapper, NapCatCore, NapCatCoreWorkingEnv, NodeIKernelLoginListener, NodeIKernelLoginService, NodeIQQNTWrapperSession, SelfInfo, WrapperNodeApi } from '@/napcat-core'; import { InstanceContext, loadQQWrapper, NapCatCore, NapCatCoreWorkingEnv, NodeIKernelLoginListener, NodeIKernelLoginService, NodeIQQNTWrapperSession, SelfInfo, WrapperNodeApi } from '@/napcat-core';
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler'; import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
import { statusHelperSubscription } from '@/napcat-core/helper/status'; import { statusHelperSubscription } from '@/napcat-core/helper/status';
import { applyPendingUpdates } from '@/napcat-webui-backend/src/api/UpdateNapCat';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
// Framework ES入口文件 // Framework ES入口文件
export async function getWebUiUrl () { export async function getWebUiUrl () {
@ -34,7 +32,6 @@ export async function NCoreInitFramework (
}); });
const pathWrapper = new NapCatPathWrapper(); const pathWrapper = new NapCatPathWrapper();
await applyPendingUpdates(pathWrapper);
const logger = new LogWrapper(pathWrapper.logsPath); const logger = new LogWrapper(pathWrapper.logsPath);
const basicInfoWrapper = new QQBasicInfoWrapper({ logger }); const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion()); const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
@ -76,13 +73,9 @@ export async function NCoreInitFramework (
await loaderObject.core.initCore(); await loaderObject.core.initCore();
// 启动WebUi // 启动WebUi
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework);
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e)); InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
// 初始化LLNC的Onebot实现 // 初始化LLNC的Onebot实现
const oneBotAdapter = new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper); await new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper).InitOneBot();
// 注册到 WebUiDataRuntime供调试功能使用
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
await oneBotAdapter.InitOneBot();
} }
export class NapCatFramework { export class NapCatFramework {

View File

@ -6,23 +6,23 @@ import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({ const SchemaData = Type.Object({
user_id: Type.Optional(Type.Union([Type.Number(), Type.String()])), user_id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
group_id: Type.Optional(Type.Union([Type.Number(), Type.String()])), group_id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
phone_number: Type.String({ default: '' }), phoneNumber: Type.String({ default: '' }),
}); });
type Payload = Static<typeof SchemaData>; type Payload = Static<typeof SchemaData>;
export class SharePeerBase extends OneBotAction<Payload, GeneralCallResult & { export class SharePeer extends OneBotAction<Payload, GeneralCallResult & {
arkMsg?: string; arkMsg?: string;
arkJson?: string; arkJson?: string;
}> { }> {
override actionName = ActionName.SharePeer;
override payloadSchema = SchemaData; override payloadSchema = SchemaData;
async _handle (payload: Payload) { async _handle (payload: Payload) {
if (payload.group_id) { if (payload.group_id) {
return await this.core.apis.GroupApi.getGroupRecommendContactArkJson(payload.group_id.toString()); return await this.core.apis.GroupApi.getGroupRecommendContactArkJson(payload.group_id.toString());
} else if (payload.user_id) { } else if (payload.user_id) {
return await this.core.apis.UserApi.getBuddyRecommendContactArkJson(payload.user_id.toString(), payload.phone_number); return await this.core.apis.UserApi.getBuddyRecommendContactArkJson(payload.user_id.toString(), payload.phoneNumber);
} }
throw new Error('group_id or user_id is required'); throw new Error('group_id or user_id is required');
} }
@ -31,25 +31,14 @@ export class SharePeerBase extends OneBotAction<Payload, GeneralCallResult & {
const SchemaDataGroupEx = Type.Object({ const SchemaDataGroupEx = Type.Object({
group_id: Type.Union([Type.Number(), Type.String()]), group_id: Type.Union([Type.Number(), Type.String()]),
}); });
export class SharePeer extends SharePeerBase {
override actionName = ActionName.SharePeer;
}
type PayloadGroupEx = Static<typeof SchemaDataGroupEx>; type PayloadGroupEx = Static<typeof SchemaDataGroupEx>;
export class ShareGroupExBase extends OneBotAction<PayloadGroupEx, string> { export class ShareGroupEx extends OneBotAction<PayloadGroupEx, string> {
override actionName = ActionName.ShareGroupEx;
override payloadSchema = SchemaDataGroupEx; override payloadSchema = SchemaDataGroupEx;
async _handle (payload: PayloadGroupEx) { async _handle (payload: PayloadGroupEx) {
return await this.core.apis.GroupApi.getArkJsonGroupShare(payload.group_id.toString()); return await this.core.apis.GroupApi.getArkJsonGroupShare(payload.group_id.toString());
} }
} }
export class ShareGroupEx extends ShareGroupExBase {
override actionName = ActionName.ShareGroupEx;
}
export class SendGroupArkShare extends ShareGroupExBase {
override actionName = ActionName.SendGroupArkShare;
}
export class SendArkShare extends SharePeerBase {
override actionName = ActionName.SendArkShare;
}

View File

@ -14,11 +14,10 @@ const SchemaData = Type.Object({
user_id: Type.String(), user_id: Type.String(),
message_seq: Type.Optional(Type.String()), message_seq: Type.Optional(Type.String()),
count: Type.Number({ default: 20 }), count: Type.Number({ default: 20 }),
reverse_order: Type.Boolean({ default: false }), reverseOrder: Type.Boolean({ default: false }),
disable_get_url: Type.Boolean({ default: false }), disable_get_url: Type.Boolean({ default: false }),
parse_mult_msg: Type.Boolean({ default: true }), parse_mult_msg: Type.Boolean({ default: true }),
quick_reply: Type.Boolean({ default: false }), quick_reply: Type.Boolean({ default: false }),
reverseOrder: Type.Boolean({ default: false }),// @deprecated 兼容旧版本
}); });
type Payload = Static<typeof SchemaData>; type Payload = Static<typeof SchemaData>;
@ -36,7 +35,7 @@ export default class GetFriendMsgHistory extends OneBotAction<Payload, Response>
const hasMessageSeq = !payload.message_seq ? !!payload.message_seq : !(payload.message_seq?.toString() === '' || payload.message_seq?.toString() === '0'); const hasMessageSeq = !payload.message_seq ? !!payload.message_seq : !(payload.message_seq?.toString() === '' || payload.message_seq?.toString() === '0');
const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0'; const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0';
const msgList = hasMessageSeq const msgList = hasMessageSeq
? (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverse_order || payload.reverseOrder)).msgList ? (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverseOrder)).msgList
: (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList; : (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList;
if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`); if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`);
// 转换序号 // 转换序号

View File

@ -14,11 +14,10 @@ const SchemaData = Type.Object({
group_id: Type.String(), group_id: Type.String(),
message_seq: Type.Optional(Type.String()), message_seq: Type.Optional(Type.String()),
count: Type.Number({ default: 20 }), count: Type.Number({ default: 20 }),
reverse_order: Type.Boolean({ default: false }), reverseOrder: Type.Boolean({ default: false }),
disable_get_url: Type.Boolean({ default: false }), disable_get_url: Type.Boolean({ default: false }),
parse_mult_msg: Type.Boolean({ default: true }), parse_mult_msg: Type.Boolean({ default: true }),
quick_reply: Type.Boolean({ default: false }), quick_reply: Type.Boolean({ default: false }),
reverseOrder: Type.Boolean({ default: false }),// @deprecated 兼容旧版本
}); });
type Payload = Static<typeof SchemaData>; type Payload = Static<typeof SchemaData>;
@ -33,7 +32,7 @@ export default class GoCQHTTPGetGroupMsgHistory extends OneBotAction<Payload, Re
// 拉取消息 // 拉取消息
const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0'; const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0';
const msgList = hasMessageSeq const msgList = hasMessageSeq
? (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverse_order || payload.reverseOrder)).msgList ? (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverseOrder)).msgList
: (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList; : (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList;
if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`); if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`);
// 转换序号 // 转换序号

View File

@ -54,7 +54,7 @@ import { GetOnlineClient } from './go-cqhttp/GetOnlineClient';
import { IOCRImage, OCRImage } from './extends/OCRImage'; import { IOCRImage, OCRImage } from './extends/OCRImage';
import { TranslateEnWordToZn } from './extends/TranslateEnWordToZn'; import { TranslateEnWordToZn } from './extends/TranslateEnWordToZn';
import { SetQQProfile } from './go-cqhttp/SetQQProfile'; import { SetQQProfile } from './go-cqhttp/SetQQProfile';
import { SendArkShare, SendGroupArkShare, ShareGroupEx, SharePeer } from './extends/ShareContact'; import { ShareGroupEx, SharePeer } from './extends/ShareContact';
import { CreateCollection } from './extends/CreateCollection'; import { CreateCollection } from './extends/CreateCollection';
import { SetLongNick } from './extends/SetLongNick'; import { SetLongNick } from './extends/SetLongNick';
import DelEssenceMsg from './group/DelEssenceMsg'; import DelEssenceMsg from './group/DelEssenceMsg';
@ -170,8 +170,6 @@ export function createActionMap (obContext: NapCatOneBot11Adapter, core: NapCatC
new SetQQProfile(obContext, core), new SetQQProfile(obContext, core),
new ShareGroupEx(obContext, core), new ShareGroupEx(obContext, core),
new SharePeer(obContext, core), new SharePeer(obContext, core),
new SendGroupArkShare(obContext, core),
new SendArkShare(obContext, core),
new CreateCollection(obContext, core), new CreateCollection(obContext, core),
new SetLongNick(obContext, core), new SetLongNick(obContext, core),
new ForwardFriendSingleMsg(obContext, core), new ForwardFriendSingleMsg(obContext, core),

View File

@ -125,11 +125,8 @@ export const ActionName = {
// 以下为扩展napcat扩展 // 以下为扩展napcat扩展
Unknown: 'unknown', Unknown: 'unknown',
SetDiyOnlineStatus: 'set_diy_online_status', SetDiyOnlineStatus: 'set_diy_online_status',
SharePeer: 'ArkSharePeer',// @deprecated SharePeer: 'ArkSharePeer',
ShareGroupEx: 'ArkShareGroup',// @deprecated ShareGroupEx: 'ArkShareGroup',
// 标准化接口
SendGroupArkShare: 'send_group_ark_share',
SendArkShare: 'send_ark_share',
// RebootNormal : 'reboot_normal', //无快速登录重新启动 // RebootNormal : 'reboot_normal', //无快速登录重新启动
GetRobotUinRange: 'get_robot_uin_range', GetRobotUinRange: 'get_robot_uin_range',
SetOnlineStatus: 'set_online_status', SetOnlineStatus: 'set_online_status',

View File

@ -174,6 +174,7 @@ export class OneBotGroupApi {
async registerParseGroupReactEventByCore () { async registerParseGroupReactEventByCore () {
this.core.event.on('event:emoji_like', async (data) => { this.core.event.on('event:emoji_like', async (data) => {
console.log('Received emoji_like event from core:', data);
const event = await this.createGroupEmojiLikeEvent( const event = await this.createGroupEmojiLikeEvent(
data.groupId, data.groupId,
data.senderUin, data.senderUin,

View File

@ -749,31 +749,26 @@ export class OneBotMsgApi {
[OB11MessageDataType.music]: async ({ data }, context) => { [OB11MessageDataType.music]: async ({ data }, context) => {
// 保留, 直到...找到更好的解决方案 // 保留, 直到...找到更好的解决方案
const supportedPlatforms = ['qq', '163', 'kugou', 'kuwo', 'migu'];
const supportedPlatformsWithCustom = [...supportedPlatforms, 'custom'];
// 验证音乐类型
if (data.id !== undefined) { if (data.id !== undefined) {
if (!supportedPlatforms.includes(data.type)) { if (!['qq', '163', 'kugou', 'kuwo', 'migu'].includes(data.type)) {
this.core.context.logger.logError(`[音乐卡片] type参数错误: "${data.type}",仅支持: ${supportedPlatforms.join('、')}`); this.core.context.logger.logError('音乐卡片type错误, 只支持qq、163、kugou、kuwo、migu当前type:', data.type);
return undefined; return undefined;
} }
} else { } else {
if (!supportedPlatformsWithCustom.includes(data.type)) { if (!['qq', '163', 'kugou', 'kuwo', 'migu', 'custom'].includes(data.type)) {
this.core.context.logger.logError(`[音乐卡片] type参数错误: "${data.type}",仅支持: ${supportedPlatformsWithCustom.join('、')}`); this.core.context.logger.logError('音乐卡片type错误, 只支持qq、163、kugou、kuwo、migu、custom当前type:', data.type);
return undefined; return undefined;
} }
if (!data.url) { if (!data.url) {
this.core.context.logger.logError('[音乐卡片] 自定义音缺少必需参数: url'); this.core.context.logger.logError('自定义音卡缺少参数url');
return undefined; return undefined;
} }
if (!data.image) { if (!data.image) {
this.core.context.logger.logError('[音乐卡片] 自定义音缺少必需参数: image'); this.core.context.logger.logError('自定义音卡缺少参数image');
return undefined; return undefined;
} }
} }
// 构建请求数据
let postData: IdMusicSignPostData | CustomMusicSignPostData; let postData: IdMusicSignPostData | CustomMusicSignPostData;
if (data.id === undefined && data.content) { if (data.id === undefined && data.content) {
const { content, ...others } = data; const { content, ...others } = data;
@ -781,14 +776,11 @@ export class OneBotMsgApi {
} else { } else {
postData = data; postData = data;
} }
// 获取签名服务地址
let signUrl = this.obContext.configLoader.configData.musicSignUrl; let signUrl = this.obContext.configLoader.configData.musicSignUrl;
if (!signUrl) { if (!signUrl) {
signUrl = 'https://ss.xingzhige.com/music_card/card';// 感谢思思!已获思思许可 其余地方使用请自行询问 signUrl = 'https://ss.xingzhige.com/music_card/card';// 感谢思思!已获思思许可 其余地方使用请自行询问
// throw Error('音乐消息签名地址未配置');
} }
// 请求签名服务
try { try {
const musicJson = await RequestUtil.HttpGetJson<string>(signUrl, 'POST', postData); const musicJson = await RequestUtil.HttpGetJson<string>(signUrl, 'POST', postData);
return this.ob11ToRawConverters.json({ return this.ob11ToRawConverters.json({
@ -796,16 +788,9 @@ export class OneBotMsgApi {
type: OB11MessageDataType.json, type: OB11MessageDataType.json,
}, context); }, context);
} catch (e) { } catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e); this.core.context.logger.logError('生成音乐消息失败', e);
this.core.context.logger.logError(
'[音乐卡片签名失败] 签名服务请求出错!\n' +
` ├─ 音乐类型: ${data.type}\n` +
` ├─ 音乐ID: ${data.id ?? '自定义'}\n` +
` ├─ 错误信息: ${errorMessage}\n` +
' └─ 提示: 请检查网络连接,或尝试在配置中更换其他音乐签名服务地址(musicSignUrl)'
);
return undefined;
} }
return undefined;
}, },
[OB11MessageDataType.node]: async () => undefined, [OB11MessageDataType.node]: async () => undefined,

View File

@ -12,17 +12,7 @@ import { ArgvOrCommandLine } from '@homebridge/node-pty-prebuilt-multiarch/src/t
import { assign } from '@homebridge/node-pty-prebuilt-multiarch/src/utils'; import { assign } from '@homebridge/node-pty-prebuilt-multiarch/src/utils';
import { pty_loader } from './prebuild-loader'; import { pty_loader } from './prebuild-loader';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
export const pty = pty_loader();
// 懒加载pty避免在模块导入时立即执行pty_loader()
let _pty: any;
export const pty: any = new Proxy({}, {
get (_target, prop) {
if (!_pty) {
_pty = pty_loader();
}
return _pty[prop];
}
});
let helperPath: string; let helperPath: string;
helperPath = '../build/Release/spawn-helper'; helperPath = '../build/Release/spawn-helper';

View File

@ -35,7 +35,6 @@ import { logSubscription, LogWrapper } from '@/napcat-core/helper/log';
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler'; import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info'; import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
import { statusHelperSubscription } from '@/napcat-core/helper/status'; import { statusHelperSubscription } from '@/napcat-core/helper/status';
import { applyPendingUpdates } from '@/napcat-webui-backend/src/api/UpdateNapCat';
// NapCat Shell App ES 入口文件 // NapCat Shell App ES 入口文件
async function handleUncaughtExceptions (logger: LogWrapper) { async function handleUncaughtExceptions (logger: LogWrapper) {
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
@ -319,7 +318,6 @@ export async function NCoreInitShell () {
const pathWrapper = new NapCatPathWrapper(); const pathWrapper = new NapCatPathWrapper();
const logger = new LogWrapper(pathWrapper.logsPath); const logger = new LogWrapper(pathWrapper.logsPath);
handleUncaughtExceptions(logger); handleUncaughtExceptions(logger);
await applyPendingUpdates(pathWrapper);
// 初始化 FFmpeg 服务 // 初始化 FFmpeg 服务
await FFmpegService.init(pathWrapper.binaryPath, logger); await FFmpegService.init(pathWrapper.binaryPath, logger);
@ -340,8 +338,8 @@ export async function NCoreInitShell () {
o3Service.addO3MiscListener(new NodeIO3MiscListener()); o3Service.addO3MiscListener(new NodeIO3MiscListener());
logger.log('[NapCat] [Core] NapCat.Core Version: ' + napCatVersion); logger.log('[NapCat] [Core] NapCat.Core Version: ' + napCatVersion);
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Shell);
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e)); InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
const engine = wrapper.NodeIQQNTWrapperEngine.get(); const engine = wrapper.NodeIQQNTWrapperEngine.get();
const loginService = wrapper.NodeIKernelLoginService.get(); const loginService = wrapper.NodeIKernelLoginService.get();
let session: NodeIQQNTWrapperSession; let session: NodeIQQNTWrapperSession;
@ -455,11 +453,7 @@ export class NapCatShell {
async InitNapCat () { async InitNapCat () {
await this.core.initCore(); await this.core.initCore();
const oneBotAdapter = new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper); new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper).InitOneBot()
// 注册到 WebUiDataRuntime供调试功能使用
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
oneBotAdapter.InitOneBot()
.catch(e => this.context.logger.logError('初始化OneBot失败', e)); .catch(e => this.context.logger.logError('初始化OneBot失败', e));
} }
} }

View File

@ -27,7 +27,6 @@ const ShellBaseConfigPlugin: PluginOption[] = [
targets: [ targets: [
{ src: '../napcat-native/', dest: 'dist/native', flatten: false }, { src: '../napcat-native/', dest: 'dist/native', flatten: false },
{ src: '../napcat-webui-frontend/dist/', dest: 'dist/static/', flatten: false }, { src: '../napcat-webui-frontend/dist/', dest: 'dist/static/', flatten: false },
{ src: '../napcat-webui-backend/src/assets/sw_template.js', dest: 'dist/static/' },
{ src: '../napcat-core/external/napcat.json', dest: 'dist/config/' }, { src: '../napcat-core/external/napcat.json', dest: 'dist/config/' },
{ src: '../../package.json', dest: 'dist' }, { src: '../../package.json', dest: 'dist' },
{ src: '../napcat-shell-loader', dest: 'dist' }, { src: '../napcat-shell-loader', dest: 'dist' },

View File

@ -26,7 +26,7 @@ export default function vitePluginNapcatVersion () {
const data = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); const data = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
if (data?.tag) return data.tag; if (data?.tag) return data.tag;
} }
} catch { } } catch {}
return null; return null;
} }
@ -36,7 +36,7 @@ export default function vitePluginNapcatVersion () {
cacheFile, cacheFile,
JSON.stringify({ tag, time: new Date().toISOString() }, null, 2) JSON.stringify({ tag, time: new Date().toISOString() }, null, 2)
); );
} catch { } } catch {}
} }
async function fetchLatestTag () { async function fetchLatestTag () {
@ -58,7 +58,7 @@ export default function vitePluginNapcatVersion () {
try { try {
const json = JSON.parse(data); const json = JSON.parse(data);
if (Array.isArray(json) && json[0]?.name) { if (Array.isArray(json) && json[0]?.name) {
resolve(json[0].name.replace(/^v/, '')); resolve(json[0].name);
} else reject(new Error('Invalid GitHub tag response')); } else reject(new Error('Invalid GitHub tag response'));
} catch (e) { } catch (e) {
reject(e); reject(e);
@ -79,7 +79,7 @@ export default function vitePluginNapcatVersion () {
return tag; return tag;
} catch (e) { } catch (e) {
console.warn('[vite-plugin-napcat-version] Failed to fetch tag:', e.message); console.warn('[vite-plugin-napcat-version] Failed to fetch tag:', e.message);
return cached ?? '0.0.0'; return cached ?? 'v0.0.0';
} }
} }
@ -110,7 +110,7 @@ export default function vitePluginNapcatVersion () {
lastTag = tag; lastTag = tag;
ctx.server?.ws.send({ type: 'full-reload' }); ctx.server?.ws.send({ type: 'full-reload' });
} }
} catch { } } catch {}
} }
}, },
}; };

View File

@ -22,14 +22,6 @@ import { existsSync, readFileSync } from 'node:fs'; // 引入multer用于错误
import { ILogWrapper } from 'napcat-common/src/log-interface'; import { ILogWrapper } from 'napcat-common/src/log-interface';
import { ISubscription } from 'napcat-common/src/subscription-interface'; import { ISubscription } from 'napcat-common/src/subscription-interface';
import { IStatusHelperSubscription } from '@/napcat-common/src/status-interface'; import { IStatusHelperSubscription } from '@/napcat-common/src/status-interface';
import { handleDebugWebSocket } from '@/napcat-webui-backend/src/api/Debug';
import compression from 'compression';
import { napCatVersion } from 'napcat-common/src/version';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 实例化Express // 实例化Express
const app = express(); const app = express();
/** /**
@ -103,13 +95,7 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
WebUiConfig = new WebUiConfigWrapper(); WebUiConfig = new WebUiConfigWrapper();
let config = await WebUiConfig.GetWebUIConfig(); let config = await WebUiConfig.GetWebUIConfig();
// 检查是否禁用WebUI若禁用则不进行密码检测 // 检查并更新默认密码 - 最高优先级
if (config.disableWebUI) {
logger.log('[NapCat] [WebUi] WebUI is disabled by configuration.');
return;
}
// 检查并更新默认密码仅在启用WebUI时
if (config.token === 'napcat' || !config.token) { if (config.token === 'napcat' || !config.token) {
const randomToken = process.env['NAPCAT_WEBUI_SECRET_KEY'] || getRandomToken(8); const randomToken = process.env['NAPCAT_WEBUI_SECRET_KEY'] || getRandomToken(8);
await WebUiConfig.UpdateWebUIConfig({ token: randomToken }); await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
@ -126,6 +112,12 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
// 存储启动时的初始token用于鉴权 // 存储启动时的初始token用于鉴权
setInitialWebUiToken(config.token); setInitialWebUiToken(config.token);
// 检查是否禁用WebUI
if (config.disableWebUI) {
logger.log('[NapCat] [WebUi] WebUI is disabled by configuration.');
return;
}
const [host, port, token] = await InitPort(config); const [host, port, token] = await InitPort(config);
webUiRuntimePort = port; webUiRuntimePort = port;
if (port === 0) { if (port === 0) {
@ -150,31 +142,18 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
// ------------注册中间件------------ // ------------注册中间件------------
// 使用express的json中间件 // 使用express的json中间件
app.use(express.json()); app.use(express.json());
// 启用gzip压缩对所有响应启用阈值1KB
app.use(compression({
level: 6, // 压缩级别 1-96 是性能和压缩率的平衡点
threshold: 1024, // 只压缩大于 1KB 的响应
filter: (req, res) => {
// 不压缩 SSE 和 WebSocket 升级请求
if (req.headers['accept'] === 'text/event-stream') {
return false;
}
// 使用默认过滤器
return compression.filter(req, res);
},
}));
// CORS中间件 // CORS中间件
// TODO: // TODO:
app.use(cors); app.use(cors);
// 自定义字体文件路由 - 返回用户上传的字体文件 // 如果是webui字体文件挂载字体文件
app.use('/webui/fonts/CustomFont.woff', async (_req, res) => { app.use('/webui/fonts/AaCute.woff', async (_req, res, next) => {
const fontPath = await WebUiConfig.GetWebUIFontPath(); const isFontExist = await WebUiConfig.CheckWebUIFontExist();
if (fontPath) { if (isFontExist) {
res.sendFile(fontPath); res.sendFile(WebUiConfig.GetWebUIFontPath());
} else { } else {
res.status(404).send('Custom font not found'); next();
} }
}); });
@ -196,32 +175,6 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
res.send(css); res.send(css);
}); });
// 动态生成 sw.js
app.get('/webui/sw.js', async (_req, res) => {
try {
// 读取模板文件
let templatePath = resolve(__dirname, 'static', 'sw_template.js');
if (!existsSync(templatePath)) {
templatePath = resolve(__dirname, 'src', 'assets', 'sw_template.js');
}
let swContent = readFileSync(templatePath, 'utf-8');
// 替换版本号
// 使用 napCatVersion如果为 alpha 则尝试加上时间戳或其他标识以避免缓存冲突,或者直接使用
// 用户要求控制 sw.js 版本napCatVersion 是核心控制点
swContent = swContent.replace('{{VERSION}}', napCatVersion);
res.header('Content-Type', 'application/javascript');
res.header('Service-Worker-Allowed', '/webui/');
res.header('Cache-Control', 'no-cache, no-store, must-revalidate');
res.send(swContent);
} catch (error) {
console.error('[NapCat] [WebUi] Error generating sw.js', error);
res.status(500).send('Error generating service worker');
}
});
// ------------中间件结束------------ // ------------中间件结束------------
// ------------挂载路由------------ // ------------挂载路由------------
@ -234,15 +187,7 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
const isHttps = !!sslCerts; const isHttps = !!sslCerts;
const server = isHttps && sslCerts ? createHttpsServer(sslCerts, app) : createServer(app); const server = isHttps && sslCerts ? createHttpsServer(sslCerts, app) : createServer(app);
server.on('upgrade', (request, socket, head) => { server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url || '', `http://${request.headers.host}`); terminalManager.initialize(request, socket, head, logger);
// 检查是否是调试 WebSocket 连接
if (url.pathname.startsWith('/api/Debug/ws')) {
handleDebugWebSocket(request, socket, head);
} else {
// 默认为终端 WebSocket
terminalManager.initialize(request, socket, head, logger);
}
}); });
// 挂载API接口 // 挂载API接口
app.use('/api', ALLRouter); app.use('/api', ALLRouter);

View File

@ -16,11 +16,9 @@
} }
}, },
"dependencies": { "dependencies": {
"@simplewebauthn/server": "^13.2.2",
"@sinclair/typebox": "^0.34.38", "@sinclair/typebox": "^0.34.38",
"ajv": "^8.13.0", "ajv": "^8.13.0",
"compressing": "^1.10.3", "compressing": "^1.10.3",
"compression": "^1.8.1",
"express": "^5.0.0", "express": "^5.0.0",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"json5": "^2.2.3", "json5": "^2.2.3",
@ -30,7 +28,6 @@
"ws": "^8.18.3" "ws": "^8.18.3"
}, },
"devDependencies": { "devDependencies": {
"@types/compression": "^1.8.1",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/multer": "^1.4.12", "@types/multer": "^1.4.12",
"@types/node": "^22.0.1", "@types/node": "^22.0.1",

View File

@ -1,6 +1,5 @@
import { RequestHandler } from 'express'; import { RequestHandler } from 'express';
import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken'; import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
import { PasskeyHelper } from '@/napcat-webui-backend/src/helper/PasskeyHelper';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data'; import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response'; import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response';
import { isEmpty } from '@/napcat-webui-backend/src/utils/check'; import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
@ -149,115 +148,3 @@ export const UpdateTokenHandler: RequestHandler = async (req, res) => {
return sendError(res, `Failed to update token: ${e.message}`); return sendError(res, `Failed to update token: ${e.message}`);
} }
}; };
// 生成Passkey注册选项
export const GeneratePasskeyRegistrationOptionsHandler: RequestHandler = async (_req, res) => {
try {
// 使用固定用户ID因为WebUI只有一个用户
const userId = 'napcat-user';
const userName = 'NapCat User';
// 从请求头获取host来确定RP_ID
const host = _req.get('host') || 'localhost';
const hostname = host.split(':')[0] || 'localhost'; // 移除端口
// 对于本地开发使用localhost而不是IP地址
const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname;
const options = await PasskeyHelper.generateRegistrationOptions(userId, userName, rpId);
return sendSuccess(res, options);
} catch (error) {
return sendError(res, `Failed to generate registration options: ${(error as Error).message}`);
}
};
// 验证Passkey注册
export const VerifyPasskeyRegistrationHandler: RequestHandler = async (req, res) => {
try {
const { response } = req.body;
if (!response) {
return sendError(res, 'Response is required');
}
const origin = req.get('origin') || req.protocol + '://' + req.get('host');
const host = req.get('host') || 'localhost';
const hostname = host.split(':')[0] || 'localhost'; // 移除端口
// 对于本地开发使用localhost而不是IP地址
const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname;
const userId = 'napcat-user';
const verification = await PasskeyHelper.verifyRegistration(userId, response, origin, rpId);
if (verification.verified) {
return sendSuccess(res, { verified: true });
} else {
return sendError(res, 'Registration failed');
}
} catch (error) {
return sendError(res, `Registration verification failed: ${(error as Error).message}`);
}
};
// 生成Passkey认证选项
export const GeneratePasskeyAuthenticationOptionsHandler: RequestHandler = async (_req, res) => {
try {
const userId = 'napcat-user';
if (!(await PasskeyHelper.hasPasskeys(userId))) {
return sendError(res, 'No passkeys registered');
}
// 从请求头获取host来确定RP_ID
const host = _req.get('host') || 'localhost';
const hostname = host.split(':')[0] || 'localhost'; // 移除端口
// 对于本地开发使用localhost而不是IP地址
const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname;
const options = await PasskeyHelper.generateAuthenticationOptions(userId, rpId);
return sendSuccess(res, options);
} catch (error) {
return sendError(res, `Failed to generate authentication options: ${(error as Error).message}`);
}
};
// 验证Passkey认证
export const VerifyPasskeyAuthenticationHandler: RequestHandler = async (req, res) => {
try {
const { response } = req.body;
if (!response) {
return sendError(res, 'Response is required');
}
// 获取WebUI配置用于限速检查
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
// 获取客户端IP
const clientIP = req.ip || req.socket.remoteAddress || '';
// 检查登录频率
if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
return sendError(res, 'login rate limit');
}
const origin = req.get('origin') || req.protocol + '://' + req.get('host');
const host = req.get('host') || 'localhost';
const hostname = host.split(':')[0] || 'localhost'; // 移除端口
// 对于本地开发使用localhost而不是IP地址
const rpId = (hostname === '127.0.0.1' || hostname === 'localhost') ? 'localhost' : hostname;
const userId = 'napcat-user';
const verification = await PasskeyHelper.verifyAuthentication(userId, response, origin, rpId);
if (verification.verified) {
// 使用与普通登录相同的凭证签发
const initialToken = getInitialWebUiToken();
if (!initialToken) {
return sendError(res, 'Server token not initialized');
}
const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(AuthHelper.generatePasswordHash(initialToken)))).toString('base64');
return sendSuccess(res, {
Credential: signCredential,
});
} else {
return sendError(res, 'Authentication failed');
}
} catch (error) {
return sendError(res, `Authentication verification failed: ${(error as Error).message}`);
}
};

View File

@ -3,22 +3,12 @@ import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { sendSuccess } from '@/napcat-webui-backend/src/utils/response'; import { sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { WebUiConfig } from '@/napcat-webui-backend/index'; import { WebUiConfig } from '@/napcat-webui-backend/index';
import { getLatestTag } from 'napcat-common/src/helper';
export const GetNapCatVersion: RequestHandler = (_, res) => { export const GetNapCatVersion: RequestHandler = (_, res) => {
const data = WebUiDataRuntime.GetNapCatVersion(); const data = WebUiDataRuntime.GetNapCatVersion();
sendSuccess(res, { version: data }); sendSuccess(res, { version: data });
}; };
export const getLatestTagHandler: RequestHandler = async (_, res) => {
try {
const latestTag = await getLatestTag();
sendSuccess(res, latestTag);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch latest tag' });
}
};
export const QQVersionHandler: RequestHandler = (_, res) => { export const QQVersionHandler: RequestHandler = (_, res) => {
const data = WebUiDataRuntime.getQQVersion(); const data = WebUiDataRuntime.getQQVersion();
sendSuccess(res, data); sendSuccess(res, data);

View File

@ -1,406 +0,0 @@
import { Router, Request, Response } from 'express';
import { WebSocket, WebSocketServer } from 'ws';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { IncomingMessage } from 'http';
import { OB11Response } from '@/napcat-onebot/action/OneBotAction';
import { ActionName } from '@/napcat-onebot/action/router';
import { OB11LifeCycleEvent, LifeCycleSubType } from '@/napcat-onebot/event/meta/OB11LifeCycleEvent';
const router = Router();
const DEFAULT_ADAPTER_NAME = 'debug-primary';
/**
*
* OneBot NetworkManager WebSocket
*/
class DebugAdapter {
name: string;
isEnable: boolean = true;
// 安全令牌
readonly token: string;
// 添加 config 属性,模拟 PluginConfig 结构
config: {
enable: boolean;
name: string;
messagePostFormat?: string;
reportSelfMessage?: boolean;
debug?: boolean;
token?: string;
heartInterval?: number;
};
wsClients: Set<WebSocket> = new Set();
lastActivityTime: number = Date.now();
inactivityTimer: NodeJS.Timeout | null = null;
readonly INACTIVITY_TIMEOUT = 5 * 60 * 1000; // 5分钟不活跃
constructor (sessionId: string) {
this.name = `debug-${sessionId}`;
// 生成简单的随机 token
this.token = Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2);
this.config = {
enable: true,
name: this.name,
messagePostFormat: 'array',
reportSelfMessage: true,
debug: true,
token: this.token,
heartInterval: 30000
};
this.startInactivityCheck();
}
// 实现 IOB11NetworkAdapter 接口所需的抽象方法
async open (): Promise<void> { }
async close (): Promise<void> { this.cleanup(); }
async reload (_config: any): Promise<any> { return 0; }
/**
* OneBot - WebSocket ()
*/
async onEvent (event: any) {
this.updateActivity();
const payload = JSON.stringify(event);
if (this.wsClients.size === 0) {
return;
}
this.wsClients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
try {
client.send(payload);
} catch (error) {
console.error('[Debug] 发送事件到 WebSocket 失败:', error);
}
}
});
}
/**
* OneBot API (HTTP 使)
*/
async callApi (actionName: string, params: any): Promise<any> {
this.updateActivity();
const oneBotContext = WebUiDataRuntime.getOneBotContext();
if (!oneBotContext) {
throw new Error('OneBot 未初始化');
}
const action = oneBotContext.actions.get(actionName);
if (!action) {
throw new Error(`不支持的 API: ${actionName}`);
}
return await action.handle(params, this.name, {
name: this.name,
enable: true,
messagePostFormat: 'array',
reportSelfMessage: true,
debug: true,
});
}
/**
* WebSocket (OneBot )
*/
async handleWsMessage (ws: WebSocket, message: string | Buffer) {
this.updateActivity();
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any; } = { action: ActionName.Unknown, params: {} };
let echo;
try {
receiveData = JSON.parse(message.toString());
echo = receiveData.echo;
} catch {
this.sendWsResponse(ws, OB11Response.error('json解析失败,请检查数据格式', 1400, echo));
return;
}
receiveData.params = (receiveData?.params) ? receiveData.params : {};
// 兼容 WebUI 之前可能的一些非标准格式 (如果用户是旧前端)
// 但既然用户说要"原始流",我们优先支持标准格式
const oneBotContext = WebUiDataRuntime.getOneBotContext();
if (!oneBotContext) {
this.sendWsResponse(ws, OB11Response.error('OneBot 未初始化', 1404, echo));
return;
}
const action = oneBotContext.actions.get(receiveData.action as any);
if (!action) {
this.sendWsResponse(ws, OB11Response.error('不支持的API ' + receiveData.action, 1404, echo));
return;
}
try {
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
send: async (data: object) => {
this.sendWsResponse(ws, OB11Response.ok(data, echo ?? '', true));
},
});
this.sendWsResponse(ws, retdata);
} catch (e: any) {
this.sendWsResponse(ws, OB11Response.error(e.message || '内部错误', 1200, echo));
}
}
sendWsResponse (ws: WebSocket, data: any) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
}
/**
* WebSocket
*/
addWsClient (ws: WebSocket) {
this.wsClients.add(ws);
this.updateActivity();
// 发送生命周期事件 (Connect)
const oneBotContext = WebUiDataRuntime.getOneBotContext();
if (oneBotContext && oneBotContext.core) {
try {
const event = new OB11LifeCycleEvent(oneBotContext.core, LifeCycleSubType.CONNECT);
ws.send(JSON.stringify(event));
} catch (e) {
console.error('[Debug] 发送生命周期事件失败', e);
}
}
}
/**
* WebSocket
*/
removeWsClient (ws: WebSocket) {
this.wsClients.delete(ws);
}
updateActivity () {
this.lastActivityTime = Date.now();
}
startInactivityCheck () {
this.inactivityTimer = setInterval(() => {
const inactive = Date.now() - this.lastActivityTime;
// 如果没有 WebSocket 连接且超时,则自动清理
if (inactive > this.INACTIVITY_TIMEOUT && this.wsClients.size === 0) {
console.log(`[Debug] Adapter ${this.name} 不活跃,自动关闭`);
this.cleanup();
}
}, 30000);
}
cleanup () {
if (this.inactivityTimer) {
clearInterval(this.inactivityTimer);
this.inactivityTimer = null;
}
// 关闭所有 WebSocket 连接
this.wsClients.forEach((client) => {
try {
client.close();
} catch (error) {
// ignore
}
});
this.wsClients.clear();
// 从 OneBot NetworkManager 移除
const oneBotContext = WebUiDataRuntime.getOneBotContext();
if (oneBotContext) {
oneBotContext.networkManager.adapters.delete(this.name);
}
// 从管理器中移除
debugAdapterManager.removeAdapter(this.name);
}
/**
* Token
*/
validateToken (inputToken: string): boolean {
return this.token === inputToken;
}
}
/**
*
*/
class DebugAdapterManager {
private currentAdapter: DebugAdapter | null = null;
getOrCreateAdapter (): DebugAdapter {
// 如果已存在且活跃,直接返回
if (this.currentAdapter) {
this.currentAdapter.updateActivity();
return this.currentAdapter;
}
// 创建新实例
const adapter = new DebugAdapter('primary');
this.currentAdapter = adapter;
// 注册到 OneBot NetworkManager
const oneBotContext = WebUiDataRuntime.getOneBotContext();
if (oneBotContext) {
oneBotContext.networkManager.adapters.set(adapter.name, adapter as any);
} else {
console.warn('[Debug] OneBot 未初始化,无法注册适配器');
}
return adapter;
}
getAdapter (name: string): DebugAdapter | undefined {
if (this.currentAdapter && this.currentAdapter.name === name) {
return this.currentAdapter;
}
return undefined;
}
removeAdapter (name: string) {
if (this.currentAdapter && this.currentAdapter.name === name) {
this.currentAdapter = null;
}
}
}
const debugAdapterManager = new DebugAdapterManager();
/**
*
*/
router.post('/create', async (_req: Request, res: Response) => {
try {
const adapter = debugAdapterManager.getOrCreateAdapter();
sendSuccess(res, {
adapterName: adapter.name,
token: adapter.token,
message: '调试适配器已就绪',
});
} catch (error: any) {
sendError(res, error.message);
}
});
/**
* HTTP OneBot API ( adapter)
*/
const handleCallApi = async (req: Request, res: Response) => {
try {
let adapterName = req.params['adapterName'] || req.body.adapterName || DEFAULT_ADAPTER_NAME;
let adapter = debugAdapterManager.getAdapter(adapterName);
// 如果是默认 adapter 且不存在,尝试创建
if (!adapter && adapterName === DEFAULT_ADAPTER_NAME) {
adapter = debugAdapterManager.getOrCreateAdapter();
}
if (!adapter) {
return sendError(res, '调试适配器不存在');
}
const { action, params } = req.body;
const result = await adapter.callApi(action, params || {});
sendSuccess(res, result);
} catch (error: any) {
sendError(res, error.message);
}
};
router.post('/call/:adapterName', handleCallApi);
router.post('/call', handleCallApi);
/**
*
*/
router.post('/close/:adapterName', async (req: Request, res: Response) => {
try {
const { adapterName } = req.params;
if (!adapterName) {
return sendError(res, '缺少 adapterName 参数');
}
debugAdapterManager.removeAdapter(adapterName);
sendSuccess(res, { message: '调试适配器已关闭' });
} catch (error: any) {
sendError(res, error.message);
}
});
/**
* WebSocket
* : /api/Debug/ws?adapterName=xxx&token=xxx
*/
export function handleDebugWebSocket (request: IncomingMessage, socket: any, head: any) {
const url = new URL(request.url || '', `http://${request.headers.host}`);
let adapterName = url.searchParams.get('adapterName');
const token = url.searchParams.get('token') || url.searchParams.get('access_token');
// 默认 adapterName
if (!adapterName) {
adapterName = DEFAULT_ADAPTER_NAME;
}
// Debug session should provide token
if (!token) {
console.log('[Debug] WebSocket 连接被拒绝: 缺少 Token');
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
let adapter = debugAdapterManager.getAdapter(adapterName);
// 如果是默认 adapter 且不存在,尝试创建
if (!adapter && adapterName === DEFAULT_ADAPTER_NAME) {
adapter = debugAdapterManager.getOrCreateAdapter();
}
if (!adapter) {
console.log('[Debug] WebSocket 连接被拒绝: 适配器不存在');
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
socket.destroy();
return;
}
if (!adapter.validateToken(token)) {
console.log('[Debug] WebSocket 连接被拒绝: Token 无效');
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
// 创建 WebSocket 服务器
const wsServer = new WebSocketServer({ noServer: true });
wsServer.handleUpgrade(request, socket, head, (ws) => {
adapter.addWsClient(ws);
ws.on('message', async (data) => {
try {
await adapter.handleWsMessage(ws, data as any);
} catch (error: any) {
console.error('[Debug] handleWsMessage error', error);
}
});
ws.on('close', () => {
adapter.removeWsClient(ws);
});
ws.on('error', () => {
adapter.removeWsClient(ws);
});
});
}
export default router;

View File

@ -640,10 +640,10 @@ export const UploadWebUIFontHandler: RequestHandler = async (req, res) => {
// 删除WebUI字体文件处理方法 // 删除WebUI字体文件处理方法
export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => { export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => {
try { try {
const fontPath = await WebUiConfig.GetWebUIFontPath(); const fontPath = WebUiConfig.GetWebUIFontPath();
const exists = await WebUiConfig.CheckWebUIFontExist(); const exists = await WebUiConfig.CheckWebUIFontExist();
if (!exists || !fontPath) { if (!exists) {
return sendSuccess(res, true); return sendSuccess(res, true);
} }

View File

@ -1,388 +0,0 @@
import { RequestHandler } from 'express';
import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response';
import * as fs from 'fs';
import * as path from 'path';
import * as https from 'https';
import compressing from 'compressing';
import { webUiPathWrapper } from '../../index';
import { NapCatPathWrapper } from '@/napcat-common/src/path';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types';
interface Release {
tag_name: string;
assets: Array<{
name: string;
browser_download_url: string;
}>;
body?: string;
}
// 更新配置文件接口
interface UpdateConfig {
version: string;
updateTime: string;
files: Array<{
sourcePath: string;
targetPath: string;
backupPath?: string;
}>;
changelog?: string;
}
// 需要跳过更新的文件
const SKIP_UPDATE_FILES = [
'NapCatWinBootMain.exe',
'NapCatWinBootHook.dll'
];
/**
*
*/
function scanFilesRecursively (dirPath: string, basePath: string = dirPath): Array<{
sourcePath: string;
relativePath: string;
}> {
const files: Array<{
sourcePath: string;
relativePath: string;
}> = [];
const items = fs.readdirSync(dirPath);
for (const item of items) {
const fullPath = path.join(dirPath, item);
const relativePath = path.relative(basePath, fullPath);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
// 递归扫描子目录
files.push(...scanFilesRecursively(fullPath, basePath));
} else if (stat.isFile()) {
files.push({
sourcePath: fullPath,
relativePath: relativePath
});
}
}
return files;
}
// 镜像源列表参考ffmpeg下载实现
const mirrorUrls = [
'https://j.1win.ggff.net/',
'https://git.yylx.win/',
'https://ghfile.geekertao.top/',
'https://gh-proxy.net/',
'https://ghm.078465.xyz/',
'https://gitproxy.127731.xyz/',
'https://jiashu.1win.eu.org/',
'', // 原始URL
];
/**
* URL是否可用
*/
async function testUrl (url: string): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const req = https.get(url, { timeout: 5000 }, (res) => {
const statusCode = res.statusCode || 0;
if (statusCode >= 200 && statusCode < 300) {
req.destroy();
resolve(true);
} else {
req.destroy();
resolve(false);
}
});
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
}
/**
* URL
*/
function buildMirrorUrl (originalUrl: string, mirror: string): string {
if (!mirror) return originalUrl;
return mirror + originalUrl;
}
/**
* URL
*/
async function findAvailableUrl (originalUrl: string): Promise<string> {
console.log('Testing download URLs...');
// 先测试原始URL
if (await testUrl(originalUrl)) {
console.log('Using original URL:', originalUrl);
return originalUrl;
}
// 测试镜像源
for (const mirror of mirrorUrls) {
const mirrorUrl = buildMirrorUrl(originalUrl, mirror);
console.log('Testing mirror:', mirrorUrl);
if (await testUrl(mirrorUrl)) {
console.log('Using mirror URL:', mirrorUrl);
return mirrorUrl;
}
}
throw new Error('所有下载源都不可用');
}
/**
*
*/
async function downloadFile (url: string, dest: string): Promise<void> {
console.log('Starting download from:', url);
const file = fs.createWriteStream(dest);
return new Promise((resolve, reject) => {
const request = https.get(url, {
headers: { 'User-Agent': 'NapCat-WebUI' }
}, (res) => {
console.log('Response status:', res.statusCode);
console.log('Content-Type:', res.headers['content-type']);
if (res.statusCode === 302 || res.statusCode === 301) {
console.log('Following redirect to:', res.headers.location);
file.close();
fs.unlinkSync(dest);
downloadFile(res.headers.location!, dest).then(resolve).catch(reject);
return;
}
if (res.statusCode !== 200) {
file.close();
fs.unlinkSync(dest);
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
return;
}
res.pipe(file);
file.on('finish', () => {
file.close();
console.log('Download completed');
resolve();
});
});
request.on('error', (err) => {
console.error('Download error:', err);
file.close();
fs.unlink(dest, () => { });
reject(err);
});
});
}
export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
try {
// 获取最新release信息
const latestRelease = await getLatestRelease() as Release;
const ReleaseName = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip';
const shellZipAsset = latestRelease.assets.find(asset => asset.name === ReleaseName);
if (!shellZipAsset) {
throw new Error(`未找到${ReleaseName}文件`);
}
// 创建临时目录
const tempDir = path.join(webUiPathWrapper.binaryPath, './temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
// 查找可用的下载URL
const downloadUrl = await findAvailableUrl(shellZipAsset.browser_download_url);
// 下载zip
const zipPath = path.join(tempDir, 'napcat-latest.zip');
console.log('[NapCat Update] Saving to:', zipPath);
await downloadFile(downloadUrl, zipPath);
// 检查文件大小
const stats = fs.statSync(zipPath);
console.log('[NapCat Update] Downloaded file size:', stats.size, 'bytes');
// 解压到临时目录
const extractPath = path.join(tempDir, 'napcat-extract');
console.log('[NapCat Update] Extracting to:', extractPath);
await compressing.zip.uncompress(zipPath, extractPath);
// 获取解压后的实际内容目录NapCat.Shell.zip直接包含文件无额外根目录
const sourcePath = extractPath;
// 执行更新操作
try {
// 扫描需要更新的文件
const allFiles = scanFilesRecursively(sourcePath);
const failedFiles: Array<{
sourcePath: string;
targetPath: string;
}> = [];
// 先尝试直接替换文件
for (const fileInfo of allFiles) {
const targetFilePath = path.join(webUiPathWrapper.binaryPath, fileInfo.relativePath);
// 跳过指定的文件
if (SKIP_UPDATE_FILES.includes(path.basename(fileInfo.relativePath))) {
console.log(`[NapCat Update] Skipping update for ${fileInfo.relativePath}`);
continue;
}
try {
// 确保目标目录存在
const targetDir = path.dirname(targetFilePath);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// 尝试直接替换文件
if (fs.existsSync(targetFilePath)) {
fs.unlinkSync(targetFilePath); // 删除旧文件
}
fs.copyFileSync(fileInfo.sourcePath, targetFilePath);
} catch (error) {
// 如果替换失败,添加到失败列表
console.log(`[NapCat Update] Failed to update ${targetFilePath}, will retry on next startup:`, error);
failedFiles.push({
sourcePath: fileInfo.sourcePath,
targetPath: targetFilePath
});
}
}
// 如果有替换失败的文件,创建更新配置文件
if (failedFiles.length > 0) {
const updateConfig: UpdateConfig = {
version: latestRelease.tag_name,
updateTime: new Date().toISOString(),
files: failedFiles,
changelog: latestRelease.body || ''
};
// 保存更新配置文件
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
fs.writeFileSync(configPath, JSON.stringify(updateConfig, null, 2));
console.log(`[NapCat Update] Update config saved for ${failedFiles.length} failed files: ${configPath}`);
}
// 发送成功响应
const message = failedFiles.length > 0
? `更新完成,重启应用以应用剩余${failedFiles.length}个文件的更新`
: '更新完成';
sendSuccess(res, {
status: 'completed',
message,
newVersion: latestRelease.tag_name,
failedFilesCount: failedFiles.length
});
} catch (error) {
console.error('更新失败:', error);
sendError(res, '更新失败: ' + (error instanceof Error ? error.message : '未知错误'));
}
} catch (error: any) {
console.error('更新失败:', error);
sendError(res, '更新失败: ' + error.message);
}
};
async function getLatestRelease (): Promise<Release> {
return new Promise((resolve, reject) => {
https.get('https://api.github.com/repos/NapNeko/NapCatQQ/releases/latest', {
headers: { 'User-Agent': 'NapCat-WebUI' }
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const release = JSON.parse(data) as Release;
console.log('Release info:', {
tag_name: release.tag_name,
assets: release.assets?.map(a => ({ name: a.name, url: a.browser_download_url }))
});
resolve(release);
} catch (e) {
reject(e);
}
});
}).on('error', reject);
});
}
/**
*
*/
export async function applyPendingUpdates (webUiPathWrapper: NapCatPathWrapper): Promise<void> {
const configPath = path.join(webUiPathWrapper.configPath, 'napcat-update.json');
if (!fs.existsSync(configPath)) {
console.log('No pending updates found');
return;
}
try {
console.log('[NapCat Update] Applying pending updates...');
const updateConfig: UpdateConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const remainingFiles: Array<{
sourcePath: string;
targetPath: string;
}> = [];
for (const file of updateConfig.files) {
try {
// 检查源文件是否存在
if (!fs.existsSync(file.sourcePath)) {
console.warn(`[NapCat Update] Source file not found: ${file.sourcePath}`);
continue;
}
// 确保目标目录存在
const targetDir = path.dirname(file.targetPath);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// 尝试替换文件
if (fs.existsSync(file.targetPath)) {
fs.unlinkSync(file.targetPath); // 删除旧文件
}
fs.copyFileSync(file.sourcePath, file.targetPath);
console.log(`[NapCat Update] Updated ${path.basename(file.targetPath)} on startup`);
} catch (error) {
console.error(`[NapCat Update] Failed to update ${file.targetPath} on startup:`, error);
// 如果仍然失败,保留在列表中
remainingFiles.push(file);
}
}
// 如果还有失败的文件,更新配置文件
if (remainingFiles.length > 0) {
const updatedConfig: UpdateConfig = {
...updateConfig,
files: remainingFiles
};
fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2));
console.log(`${remainingFiles.length} files still pending update`);
} else {
// 所有文件都成功更新,删除配置文件
fs.unlinkSync(configPath);
console.log('[NapCat Update] All pending updates applied successfully');
}
} catch (error) {
console.error('[NapCat Update] Failed to apply pending updates:', error);
}
}

View File

@ -1,132 +0,0 @@
const CACHE_NAME = 'napcat-webui-v{{VERSION}}';
const ASSETS_TO_CACHE = [
'/webui/'
];
// 安装阶段:预缓存核心文件
self.addEventListener('install', (event) => {
self.skipWaiting(); // 强制立即接管
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
// 这里的资源如果加载失败不应该阻断 SW 安装
return cache.addAll(ASSETS_TO_CACHE).catch(err => console.warn('Failed to cache core assets', err));
})
);
});
// 激活阶段:清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName.startsWith('napcat-webui-') && cacheName !== CACHE_NAME) {
console.log('[SW] Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
self.clients.claim(); // 立即控制所有客户端
});
// 拦截请求
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// 1. API 请求:仅网络 (Network Only)
if (url.pathname.startsWith('/api/') || url.pathname.includes('/socket')) {
return;
}
// 2. 强缓存策略 (Cache First)
// - 外部 QQ 头像 (q1.qlogo.cn)
// - 静态资源 (assets, fonts)
// - 常见静态文件后缀
const isQLogo = url.hostname === 'q1.qlogo.cn';
const isCustomFont = url.pathname.includes('CustomFont.woff'); // 用户自定义字体,不强缓存
const isThemeCss = url.pathname.includes('files/theme.css'); // 主题 CSS不强缓存
const isStaticAsset = url.pathname.includes('/webui/assets/') ||
url.pathname.includes('/webui/fonts/');
const isStaticFile = /\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|ico)$/i.test(url.pathname);
if (!isCustomFont && !isThemeCss && (isQLogo || isStaticAsset || isStaticFile)) {
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
return response;
}
// 跨域请求 (qlogo) 需要 mode: 'no-cors' 才能缓存 opaque response
// 但 fetch(event.request) 默认会继承 request 的 mode。
// 如果是 img标签发起的请求通常 mode 是 no-cors 或 cors。
// 对于 opaque response (status 0), cache API 允许缓存。
return fetch(event.request).then((response) => {
// 对 qlogo 允许 status 0 (opaque)
// 对其他资源要求 status 200
const isValidResponse = response && (
response.status === 200 ||
response.type === 'basic' ||
(isQLogo && response.type === 'opaque')
);
if (!isValidResponse) {
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
})
);
return;
}
// 3. HTML 页面 / 导航请求 -> 网络优先 (Network First)
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.then((response) => {
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
return;
}
// 4. 其他 Same-Origin 请求 -> Stale-While-Revalidate
// 优先返回缓存,同时后台更新缓存,保证下次访问是新的
if (url.origin === self.location.origin) {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
const fetchPromise = fetch(event.request).then((networkResponse) => {
if (networkResponse && networkResponse.status === 200) {
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
}
return networkResponse;
});
// 如果有缓存,返回缓存;否则等待网络
return cachedResponse || fetchPromise;
})
);
return;
}
// 默认:网络优先
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
});

View File

@ -1,9 +1,8 @@
import store from 'napcat-common/src/store'; import store from 'napcat-common/src/store';
import { napCatVersion } from 'napcat-common/src/version'; import { napCatVersion } from 'napcat-common/src/version';
import { NapCatCoreWorkingEnv, type LoginRuntimeType } from '../types'; import type { LoginRuntimeType } from '../types';
const LoginRuntime: LoginRuntimeType = { const LoginRuntime: LoginRuntimeType = {
workingEnv: NapCatCoreWorkingEnv.Unknown,
LoginCurrentTime: Date.now(), LoginCurrentTime: Date.now(),
LoginCurrentRate: 0, LoginCurrentRate: 0,
QQLoginStatus: false, // 已实现 但太傻了 得去那边注册个回调刷新 QQLoginStatus: false, // 已实现 但太傻了 得去那边注册个回调刷新
@ -15,7 +14,6 @@ const LoginRuntime: LoginRuntimeType = {
nick: '', nick: '',
}, },
QQVersion: 'unknown', QQVersion: 'unknown',
OneBotContext: null,
onQQLoginStatusChange: async (status: boolean) => { onQQLoginStatusChange: async (status: boolean) => {
LoginRuntime.QQLoginStatus = status; LoginRuntime.QQLoginStatus = status;
}, },
@ -38,12 +36,6 @@ const LoginRuntime: LoginRuntimeType = {
}, },
}; };
export const WebUiDataRuntime = { export const WebUiDataRuntime = {
setWorkingEnv (env: NapCatCoreWorkingEnv): void {
LoginRuntime.workingEnv = env;
},
getWorkingEnv (): NapCatCoreWorkingEnv {
return LoginRuntime.workingEnv;
},
setWebUiTokenChangeCallback (func: (token: string) => Promise<void>): void { setWebUiTokenChangeCallback (func: (token: string) => Promise<void>): void {
LoginRuntime.onWebUiTokenChange = func; LoginRuntime.onWebUiTokenChange = func;
}, },
@ -155,12 +147,4 @@ export const WebUiDataRuntime = {
runWebUiConfigQuickFunction: async function () { runWebUiConfigQuickFunction: async function () {
await LoginRuntime.WebUiConfigQuickFunction(); await LoginRuntime.WebUiConfigQuickFunction();
}, },
setOneBotContext (context: any): void {
LoginRuntime.OneBotContext = context;
},
getOneBotContext (): any | null {
return LoginRuntime.OneBotContext;
},
}; };

View File

@ -1,206 +0,0 @@
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
type AuthenticatorTransportFuture,
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { promises as fs } from 'fs';
import path from 'path';
import { webUiPathWrapper } from '../../index';
interface PasskeyCredential {
id: string;
publicKey: string;
counter: number;
transports?: AuthenticatorTransportFuture[];
}
const RP_NAME = 'NapCat WebUI';
export class PasskeyHelper {
private static getPasskeyFilePath (): string {
return path.join(webUiPathWrapper.configPath, 'passkey.json');
}
// 内存中存储临时挑战数据
private static challenges: Map<string, string> = new Map();
private static async ensurePasskeyFile (): Promise<void> {
try {
// 确保配置文件目录存在
const passkeyFile = this.getPasskeyFilePath();
await fs.mkdir(path.dirname(passkeyFile), { recursive: true });
// 检查文件是否存在,如果不存在创建空文件
try {
await fs.access(passkeyFile);
} catch {
await fs.writeFile(passkeyFile, JSON.stringify({}, null, 2));
}
} catch (error) {
// Directory or file already exists or other error
}
}
private static async getAllPasskeys (): Promise<Record<string, PasskeyCredential[]>> {
await this.ensurePasskeyFile();
try {
const passkeyFile = this.getPasskeyFilePath();
const data = await fs.readFile(passkeyFile, 'utf-8');
const passkeys = JSON.parse(data);
return typeof passkeys === 'object' && passkeys !== null ? passkeys : {};
} catch (error) {
return {};
}
}
private static async saveAllPasskeys (allPasskeys: Record<string, PasskeyCredential[]>): Promise<void> {
await this.ensurePasskeyFile();
const passkeyFile = this.getPasskeyFilePath();
await fs.writeFile(passkeyFile, JSON.stringify(allPasskeys, null, 2));
}
private static async getUserPasskeys (userId: string): Promise<PasskeyCredential[]> {
const allPasskeys = await this.getAllPasskeys();
return allPasskeys[userId] || [];
}
// 持久性存储用户的passkey到统一配置文件
private static async setUserPasskeys (userId: string, passkeys: PasskeyCredential[]): Promise<void> {
const allPasskeys = await this.getAllPasskeys();
if (passkeys.length > 0) {
allPasskeys[userId] = passkeys;
} else {
delete allPasskeys[userId];
}
await this.saveAllPasskeys(allPasskeys);
}
static async generateRegistrationOptions (userId: string, userName: string, rpId: string) {
const userPasskeys = await this.getUserPasskeys(userId);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: rpId,
userID: new TextEncoder().encode(userId),
userName: userName,
attestationType: 'none',
excludeCredentials: userPasskeys.map(passkey => ({
id: passkey.id,
type: 'public-key' as const,
transports: passkey.transports,
})),
// Temporarily simplify authenticatorSelection - remove residentKey to avoid conflicts
authenticatorSelection: {
userVerification: 'preferred',
},
});
// Store challenge temporarily in memory
this.challenges.set(`reg_${userId}`, options.challenge);
// Auto cleanup after 5 minutes
setTimeout(() => {
this.challenges.delete(`reg_${userId}`);
}, 300000);
return options;
}
static async verifyRegistration (userId: string, response: any, origin: string, rpId: string) {
const expectedChallenge = this.challenges.get(`reg_${userId}`);
if (!expectedChallenge) {
throw new Error('Challenge not found or expired');
}
const verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpId,
});
if (verification.verified && verification.registrationInfo) {
const { registrationInfo } = verification;
const newPasskey: PasskeyCredential = {
id: registrationInfo.credential.id,
publicKey: isoBase64URL.fromBuffer(registrationInfo.credential.publicKey),
counter: registrationInfo.credential.counter || 0,
transports: response.response.transports,
};
const userPasskeys = await this.getUserPasskeys(userId);
userPasskeys.push(newPasskey);
await this.setUserPasskeys(userId, userPasskeys);
// Clean up challenge
this.challenges.delete(`reg_${userId}`);
}
return verification;
}
static async generateAuthenticationOptions (userId: string, rpId: string) {
const userPasskeys = await this.getUserPasskeys(userId);
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: userPasskeys.map(passkey => ({
id: passkey.id,
type: 'public-key' as const,
transports: passkey.transports,
})),
userVerification: 'preferred',
});
// Store challenge temporarily in memory
this.challenges.set(`auth_${userId}`, options.challenge);
// Auto cleanup after 5 minutes
setTimeout(() => {
this.challenges.delete(`auth_${userId}`);
}, 300000);
return options;
}
static async verifyAuthentication (userId: string, response: any, origin: string, rpId: string) {
const expectedChallenge = this.challenges.get(`auth_${userId}`);
if (!expectedChallenge) {
throw new Error('Challenge not found or expired');
}
const userPasskeys = await this.getUserPasskeys(userId);
const passkey = userPasskeys.find(p => p.id === response.id);
if (!passkey) {
throw new Error('Passkey not found');
}
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpId,
credential: {
id: passkey.id,
publicKey: isoBase64URL.toBuffer(passkey.publicKey),
counter: passkey.counter,
},
});
if (verification.verified && verification.authenticationInfo) {
// Update counter
passkey.counter = verification.authenticationInfo.newCounter;
await this.setUserPasskeys(userId, userPasskeys);
// Clean up challenge
this.challenges.delete(`auth_${userId}`);
}
return verification;
}
static async hasPasskeys (userId: string): Promise<boolean> {
const userPasskeys = await this.getUserPasskeys(userId);
return userPasskeys.length > 0;
}
}

View File

@ -176,35 +176,17 @@ export class WebUiConfigWrapper {
return []; return [];
} }
// 判断字体是否存在(支持多种格式 // 判断字体是否存在(webui.woff
async CheckWebUIFontExist (): Promise<boolean> { async CheckWebUIFontExist (): Promise<boolean> {
const fontPath = await this.GetWebUIFontPath(); const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
if (!fontPath) return false;
return await fs return await fs
.access(fontPath, constants.F_OK) .access(resolve(fontsPath, './webui.woff'), constants.F_OK)
.then(() => true) .then(() => true)
.catch(() => false); .catch(() => false);
} }
// 获取webui字体文件路径支持多种格式 // 获取webui字体文件路径
async GetWebUIFontPath (): Promise<string | null> { GetWebUIFontPath (): string {
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
const extensions = ['.woff', '.woff2', '.ttf', '.otf'];
for (const ext of extensions) {
const fontPath = resolve(fontsPath, `webui${ext}`);
const exists = await fs
.access(fontPath, constants.F_OK)
.then(() => true)
.catch(() => false);
if (exists) {
return fontPath;
}
}
return null;
}
// 同步版本,用于 multer 配置
GetWebUIFontPathSync (): string {
return resolve(webUiPathWrapper.configPath, './fonts/webui.woff'); return resolve(webUiPathWrapper.configPath, './fonts/webui.woff');
} }

View File

@ -12,12 +12,6 @@ export async function auth (req: Request, res: Response, next: NextFunction) {
if (req.url === '/auth/login') { if (req.url === '/auth/login') {
return next(); return next();
} }
if (req.url === '/auth/passkey/generate-authentication-options' ||
req.url === '/auth/passkey/verify-authentication') {
return next();
}
// 判断是否有Authorization头 // 判断是否有Authorization头
if (req.headers?.authorization) { if (req.headers?.authorization) {

View File

@ -1,5 +1,5 @@
import { Router } from 'express'; import { Router } from 'express';
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler } from '../api/BaseInfo'; import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler } from '../api/BaseInfo';
import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status'; import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status';
import { GetProxyHandler } from '../api/Proxy'; import { GetProxyHandler } from '../api/Proxy';
@ -7,7 +7,6 @@ const router = Router();
// router: 获取nc的package.json信息 // router: 获取nc的package.json信息
router.get('/QQVersion', QQVersionHandler); router.get('/QQVersion', QQVersionHandler);
router.get('/GetNapCatVersion', GetNapCatVersion); router.get('/GetNapCatVersion', GetNapCatVersion);
router.get('/getLatestTag', getLatestTagHandler);
router.get('/GetSysStatusRealTime', StatusRealTimeHandler); router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
router.get('/proxy', GetProxyHandler); router.get('/proxy', GetProxyHandler);
router.get('/Theme', GetThemeConfigHandler); router.get('/Theme', GetThemeConfigHandler);

View File

@ -1,13 +0,0 @@
/**
* @file UpdateNapCat路由
*/
import { Router } from 'express';
import { UpdateNapCatHandler } from '@/napcat-webui-backend/src/api/UpdateNapCat';
const router = Router();
// POST /api/UpdateNapCat/update - 更新NapCat
router.post('/update', UpdateNapCatHandler);
export { router as UpdateNapCatRouter };

View File

@ -5,10 +5,6 @@ import {
LoginHandler, LoginHandler,
LogoutHandler, LogoutHandler,
UpdateTokenHandler, UpdateTokenHandler,
GeneratePasskeyRegistrationOptionsHandler,
VerifyPasskeyRegistrationHandler,
GeneratePasskeyAuthenticationOptionsHandler,
VerifyPasskeyAuthenticationHandler,
} from '@/napcat-webui-backend/src/api/Auth'; } from '@/napcat-webui-backend/src/api/Auth';
const router = Router(); const router = Router();
@ -20,13 +16,5 @@ router.post('/check', checkHandler);
router.post('/logout', LogoutHandler); router.post('/logout', LogoutHandler);
// router:更新token // router:更新token
router.post('/update_token', UpdateTokenHandler); router.post('/update_token', UpdateTokenHandler);
// router:生成Passkey注册选项
router.post('/passkey/generate-registration-options', GeneratePasskeyRegistrationOptionsHandler);
// router:验证Passkey注册
router.post('/passkey/verify-registration', VerifyPasskeyRegistrationHandler);
// router:生成Passkey认证选项
router.post('/passkey/generate-authentication-options', GeneratePasskeyAuthenticationOptionsHandler);
// router:验证Passkey认证
router.post('/passkey/verify-authentication', VerifyPasskeyAuthenticationHandler);
export { router as AuthRouter }; export { router as AuthRouter };

View File

@ -14,8 +14,6 @@ import { LogRouter } from '@/napcat-webui-backend/src/router/Log';
import { BaseRouter } from '@/napcat-webui-backend/src/router/Base'; import { BaseRouter } from '@/napcat-webui-backend/src/router/Base';
import { FileRouter } from './File'; import { FileRouter } from './File';
import { WebUIConfigRouter } from './WebUIConfig'; import { WebUIConfigRouter } from './WebUIConfig';
import { UpdateNapCatRouter } from './UpdateNapCat';
import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
const router = Router(); const router = Router();
@ -40,9 +38,5 @@ router.use('/Log', LogRouter);
router.use('/File', FileRouter); router.use('/File', FileRouter);
// router:WebUI配置相关路由 // router:WebUI配置相关路由
router.use('/WebUIConfig', WebUIConfigRouter); router.use('/WebUIConfig', WebUIConfigRouter);
// router:更新NapCat相关路由
router.use('/UpdateNapCat', UpdateNapCatRouter);
// router:调试相关路由
router.use('/Debug', DebugRouter);
export { router as ALLRouter }; export { router as ALLRouter };

View File

@ -30,13 +30,8 @@ export interface WebUiCredentialJson {
Data: WebUiCredentialInnerJson; Data: WebUiCredentialInnerJson;
Hmac: string; Hmac: string;
} }
export enum NapCatCoreWorkingEnv {
Unknown = 0,
Shell = 1,
Framework = 2,
}
export interface LoginRuntimeType { export interface LoginRuntimeType {
workingEnv: NapCatCoreWorkingEnv;
LoginCurrentTime: number; LoginCurrentTime: number;
LoginCurrentRate: number; LoginCurrentRate: number;
QQLoginStatus: boolean; QQLoginStatus: boolean;
@ -47,7 +42,6 @@ export interface LoginRuntimeType {
onQQLoginStatusChange: (status: boolean) => Promise<void>; onQQLoginStatusChange: (status: boolean) => Promise<void>;
onWebUiTokenChange: (token: string) => Promise<void>; onWebUiTokenChange: (token: string) => Promise<void>;
WebUiConfigQuickFunction: () => Promise<void>; WebUiConfigQuickFunction: () => Promise<void>;
OneBotContext: any | null; // OneBot 上下文,用于调试功能
NapCatHelper: { NapCatHelper: {
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>; onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>; onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;

View File

@ -4,11 +4,9 @@ export const themeType = Type.Object(
{ {
dark: Type.Record(Type.String(), Type.String()), dark: Type.Record(Type.String(), Type.String()),
light: Type.Record(Type.String(), Type.String()), light: Type.Record(Type.String(), Type.String()),
fontMode: Type.String({ default: 'system' }),
}, },
{ {
default: { default: {
fontMode: 'system',
dark: { dark: {
'--heroui-background': '0 0% 0%', '--heroui-background': '0 0% 0%',
'--heroui-foreground-50': '240 5.88% 10%', '--heroui-foreground-50': '240 5.88% 10%',
@ -126,11 +124,11 @@ export const themeType = Type.Object(
'--heroui-border-width-medium': '2px', '--heroui-border-width-medium': '2px',
'--heroui-border-width-large': '3px', '--heroui-border-width-large': '3px',
'--heroui-box-shadow-small': '--heroui-box-shadow-small':
'0px 0px 5px 0px rgba(0, 0, 0, .05), 0px 2px 10px 0px rgba(0, 0, 0, .2), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)', '0px 0px 5px 0px rgba(0, 0, 0, .05), 0px 2px 10px 0px rgba(0, 0, 0, .2), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'--heroui-box-shadow-medium': '--heroui-box-shadow-medium':
'0px 0px 15px 0px rgba(0, 0, 0, .06), 0px 2px 30px 0px rgba(0, 0, 0, .22), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)', '0px 0px 15px 0px rgba(0, 0, 0, .06), 0px 2px 30px 0px rgba(0, 0, 0, .22), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'--heroui-box-shadow-large': '--heroui-box-shadow-large':
'0px 0px 30px 0px rgba(0, 0, 0, .07), 0px 30px 60px 0px rgba(0, 0, 0, .26), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)', '0px 0px 30px 0px rgba(0, 0, 0, .07), 0px 30px 60px 0px rgba(0, 0, 0, .26), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'--heroui-hover-opacity': '.9', '--heroui-hover-opacity': '.9',
}, },
light: { light: {
@ -250,11 +248,11 @@ export const themeType = Type.Object(
'--heroui-border-width-medium': '2px', '--heroui-border-width-medium': '2px',
'--heroui-border-width-large': '3px', '--heroui-border-width-large': '3px',
'--heroui-box-shadow-small': '--heroui-box-shadow-small':
'0px 0px 5px 0px rgba(0, 0, 0, .02), 0px 2px 10px 0px rgba(0, 0, 0, .06), 0px 0px 1px 0px rgba(0, 0, 0, .3)', '0px 0px 5px 0px rgba(0, 0, 0, .02), 0px 2px 10px 0px rgba(0, 0, 0, .06), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'--heroui-box-shadow-medium': '--heroui-box-shadow-medium':
'0px 0px 15px 0px rgba(0, 0, 0, .03), 0px 2px 30px 0px rgba(0, 0, 0, .08), 0px 0px 1px 0px rgba(0, 0, 0, .3)', '0px 0px 15px 0px rgba(0, 0, 0, .03), 0px 2px 30px 0px rgba(0, 0, 0, .08), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'--heroui-box-shadow-large': '--heroui-box-shadow-large':
'0px 0px 30px 0px rgba(0, 0, 0, .04), 0px 30px 60px 0px rgba(0, 0, 0, .12), 0px 0px 1px 0px rgba(0, 0, 0, .3)', '0px 0px 30px 0px rgba(0, 0, 0, .04), 0px 30px 60px 0px rgba(0, 0, 0, .12), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'--heroui-hover-opacity': '.8', '--heroui-hover-opacity': '.8',
}, },
}, },

View File

@ -4,51 +4,30 @@ import fs from 'fs';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { WebUiConfig } from '@/napcat-webui-backend/index'; import { WebUiConfig } from '@/napcat-webui-backend/index';
// 支持的字体格式
const SUPPORTED_FONT_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf'];
// 清理旧的字体文件
const cleanOldFontFiles = (fontsPath: string) => {
for (const ext of SUPPORTED_FONT_EXTENSIONS) {
const fontPath = path.join(fontsPath, `webui${ext}`);
try {
if (fs.existsSync(fontPath)) {
fs.unlinkSync(fontPath);
}
} catch {
// 忽略删除失败
}
}
};
export const webUIFontStorage = multer.diskStorage({ export const webUIFontStorage = multer.diskStorage({
destination: (_, __, cb) => { destination: (_, __, cb) => {
try { try {
const fontsPath = path.dirname(WebUiConfig.GetWebUIFontPathSync()); const fontsPath = path.dirname(WebUiConfig.GetWebUIFontPath());
// 确保字体目录存在 // 确保字体目录存在
fs.mkdirSync(fontsPath, { recursive: true }); fs.mkdirSync(fontsPath, { recursive: true });
// 清理旧的字体文件
cleanOldFontFiles(fontsPath);
cb(null, fontsPath); cb(null, fontsPath);
} catch (error) { } catch (error) {
// 确保错误信息被正确传递 // 确保错误信息被正确传递
cb(new Error(`创建字体目录失败:${(error as Error).message}`), ''); cb(new Error(`创建字体目录失败:${(error as Error).message}`), '');
} }
}, },
filename: (_, file, cb) => { filename: (_, __, cb) => {
// 保留原始扩展名,统一文件名为 webui // 统一保存为webui.woff
const ext = path.extname(file.originalname).toLowerCase(); cb(null, 'webui.woff');
cb(null, `webui${ext}`);
}, },
}); });
export const webUIFontUpload = multer({ export const webUIFontUpload = multer({
storage: webUIFontStorage, storage: webUIFontStorage,
fileFilter: (_, file, cb) => { fileFilter: (_, file, cb) => {
// 验证文件类型 // 再次验证文件类型
const ext = path.extname(file.originalname).toLowerCase(); if (!file.originalname.toLowerCase().endsWith('.woff')) {
if (!SUPPORTED_FONT_EXTENSIONS.includes(ext)) { cb(new Error('只支持WOFF格式的字体文件'));
cb(new Error('只支持 WOFF/WOFF2/TTF/OTF 格式的字体文件'));
return; return;
} }
cb(null, true); cb(null, true);
@ -62,6 +41,8 @@ const webUIFontUploader = (req: Request, res: Response) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
webUIFontUpload(req, res, (error) => { webUIFontUpload(req, res, (error) => {
if (error) { if (error) {
// 错误处理
// sendError(res, error.message, true);
return reject(error); return reject(error);
} }
return resolve(true); return resolve(true);

View File

@ -26,5 +26,7 @@ dist-ssr
# NPM LOCK files # NPM LOCK files
package-lock.json package-lock.json
yarn.lock yarn.lock
pnpm-lock.yaml
dist.zip dist.zip

View File

@ -5,19 +5,12 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host=0.0.0.0", "dev": "vite --host=0.0.0.0",
"build": "vite build", "build": "tsc && vite build",
"build:full": "tsc && vite build",
"fontmin": "node scripts/fontmin.cjs",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix", "lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.6",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@ -29,7 +22,6 @@
"@heroui/checkbox": "2.3.9", "@heroui/checkbox": "2.3.9",
"@heroui/chip": "2.2.7", "@heroui/chip": "2.2.7",
"@heroui/code": "2.2.7", "@heroui/code": "2.2.7",
"@heroui/divider": "^2.2.21",
"@heroui/dropdown": "2.3.10", "@heroui/dropdown": "2.3.10",
"@heroui/form": "2.1.9", "@heroui/form": "2.1.9",
"@heroui/image": "2.2.6", "@heroui/image": "2.2.6",
@ -53,10 +45,10 @@
"@heroui/theme": "2.4.6", "@heroui/theme": "2.4.6",
"@heroui/tooltip": "2.2.8", "@heroui/tooltip": "2.2.8",
"@monaco-editor/loader": "^1.4.0", "@monaco-editor/loader": "^1.4.0",
"@monaco-editor/react": "4.7.0-rc.0",
"@react-aria/visually-hidden": "^3.8.19", "@react-aria/visually-hidden": "^3.8.19",
"@reduxjs/toolkit": "^2.5.1", "@reduxjs/toolkit": "^2.5.1",
"@uidotdev/usehooks": "^2.4.1", "@uidotdev/usehooks": "^2.4.1",
"@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
@ -65,7 +57,10 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"echarts": "^5.5.1",
"event-source-polyfill": "^1.0.31", "event-source-polyfill": "^1.0.31",
"framer-motion": "^12.0.6",
"monaco-editor": "^0.52.2",
"motion": "^12.0.6", "motion": "^12.0.6",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"qface": "^1.4.1", "qface": "^1.4.1",
@ -82,6 +77,7 @@
"react-markdown": "^9.0.3", "react-markdown": "^9.0.3",
"react-photo-view": "^1.2.7", "react-photo-view": "^1.2.7",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-responsive": "^10.0.0",
"react-router-dom": "^7.1.4", "react-router-dom": "^7.1.4",
"react-use-websocket": "^4.11.1", "react-use-websocket": "^4.11.1",
"react-window": "^1.8.11", "react-window": "^1.8.11",
@ -109,15 +105,10 @@
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "5.2.3", "eslint-plugin-prettier": "5.2.3",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"fontmin": "^0.9.9",
"glob": "^10.3.10",
"postcss": "^8.5.1", "postcss": "^8.5.1",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"sharp": "^0.34.5",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.0.5", "vite": "^6.0.5",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-image-optimizer": "^2.0.3",
"vite-plugin-static-copy": "^2.2.0", "vite-plugin-static-copy": "^2.2.0",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },

View File

@ -1,137 +0,0 @@
/**
* Fontmin Script - 动态裁剪字体
* 扫描 src 目录中所有中文字符生成字体子集
*/
const Fontmin = require('fontmin');
const fs = require('fs');
const path = require('path');
const glob = require('glob');
// 配置
const SOURCE_FONT = path.resolve(__dirname, '../src/assets/fonts/AaCute-full.ttf');
const SOURCE_TTF_ORIGINAL = path.resolve(__dirname, '../src/assets/fonts/AaCute.ttf');
const OUTPUT_DIR = path.resolve(__dirname, '../public/fonts');
const OUTPUT_NAME = 'AaCute.woff';
const SRC_DIR = path.resolve(__dirname, '../src');
// 基础字符集(常用汉字 + 标点 + 数字 + 字母)
const BASE_CHARS = `
0123456789
abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ
""''·
,.:;!?'"()[]<>-_+=*/\\|@#$%^&~\`
基础信息系统版本网络配置服务器客户端终端日志调试关于设置主题
登录退出确定取消保存删除编辑新建刷新加载更新下载上传
成功失败错误警告提示信息状态在线离线连接断开
用户名密码账号验证码记住自动
文件管理打开关闭复制粘贴剪切重命名移动
发送消息输入内容搜索查找筛选排序
帮助文档教程反馈问题建议
开启关闭启用禁用显示隐藏展开收起
返回前进上一步下一步完成跳过
今天昨天明天时间日期年月日时分秒
总量使用占用剩余内存内核主频型号
有新版本可用当前最新立即稍后
`;
/**
* 从源码文件中提取所有中文字符
*/
function extractCharsFromSource () {
const chars = new Set(BASE_CHARS.replace(/\s/g, ''));
// 匹配所有 .tsx, .ts, .jsx, .js, .css 文件
const files = glob.sync(`${SRC_DIR}/**/*.{tsx,ts,jsx,js,css}`, {
ignore: ['**/node_modules/**']
});
// 中文字符正则
const chineseRegex = /[\u4e00-\u9fa5]/g;
files.forEach(file => {
try {
const content = fs.readFileSync(file, 'utf-8');
const matches = content.match(chineseRegex);
if (matches) {
matches.forEach(char => chars.add(char));
}
} catch (e) {
console.warn(`Warning: Could not read file ${file}`);
}
});
return Array.from(chars).join('');
}
/**
* 运行 fontmin
*/
async function run () {
console.log('🔍 Scanning source files for Chinese characters...');
const text = extractCharsFromSource();
console.log(`📝 Found ${text.length} unique characters`);
// 检查源字体是否存在
let sourceFont = SOURCE_FONT;
if (!fs.existsSync(SOURCE_FONT)) {
// 尝试查找原始 TTF 并复制(不重命名,保留原始)
if (fs.existsSync(SOURCE_TTF_ORIGINAL)) {
console.log('📦 Copying original font to AaCute-full.ttf...');
fs.copyFileSync(SOURCE_TTF_ORIGINAL, SOURCE_FONT);
} else {
console.error(`❌ Source font not found: ${SOURCE_FONT}`);
console.log('💡 Please ensure AaCute.ttf exists in src/assets/fonts/');
process.exit(1);
}
}
console.log('✂️ Subsetting font...');
// 确保输出目录存在
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
const fontmin = new Fontmin()
.src(sourceFont)
.use(Fontmin.glyph({ text }))
.use(Fontmin.ttf2woff())
.dest(OUTPUT_DIR);
return new Promise((resolve, reject) => {
fontmin.run((err, files) => {
if (err) {
console.error('❌ Fontmin error:', err);
reject(err);
} else {
// 重命名输出文件
const generatedWoff = path.join(OUTPUT_DIR, 'AaCute-full.woff');
const targetFile = path.join(OUTPUT_DIR, OUTPUT_NAME);
if (fs.existsSync(generatedWoff)) {
// 如果目标文件存在,先删除
if (fs.existsSync(targetFile)) {
fs.unlinkSync(targetFile);
}
fs.renameSync(generatedWoff, targetFile);
}
// 清理生成的 TTF 文件
const generatedTtf = path.join(OUTPUT_DIR, 'AaCute-full.ttf');
if (fs.existsSync(generatedTtf)) {
fs.unlinkSync(generatedTtf);
}
if (fs.existsSync(targetFile)) {
const stats = fs.statSync(targetFile);
const sizeKB = (stats.size / 1024).toFixed(2);
console.log(`✅ Font subset created: ${targetFile} (${sizeKB} KB)`);
}
resolve();
}
});
});
}
run().catch(console.error);

View File

@ -7,6 +7,7 @@ import PageLoading from '@/components/page_loading';
import Toaster from '@/components/toaster'; import Toaster from '@/components/toaster';
import DialogProvider from '@/contexts/dialog'; import DialogProvider from '@/contexts/dialog';
import AudioProvider from '@/contexts/songs';
import useAuth from '@/hooks/auth'; import useAuth from '@/hooks/auth';
@ -32,11 +33,13 @@ function App () {
<Provider store={store}> <Provider store={store}>
<PageBackground /> <PageBackground />
<Toaster /> <Toaster />
<Suspense fallback={<PageLoading />}> <AudioProvider>
<AuthChecker> <Suspense fallback={<PageLoading />}>
<AppRoutes /> <AuthChecker>
</AuthChecker> <AppRoutes />
</Suspense> </AuthChecker>
</Suspense>
</AudioProvider>
</Provider> </Provider>
</DialogProvider> </DialogProvider>
); );

View File

@ -0,0 +1,425 @@
import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card';
import { Image } from '@heroui/image';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
import { Slider } from '@heroui/slider';
import { Tooltip } from '@heroui/tooltip';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react';
import {
BiSolidSkipNextCircle,
BiSolidSkipPreviousCircle,
} from 'react-icons/bi';
import {
FaPause,
FaPlay,
FaRegHandPointRight,
FaRepeat,
FaShuffle,
} from 'react-icons/fa6';
import { TbRepeatOnce } from 'react-icons/tb';
import { useMediaQuery } from 'react-responsive';
import { PlayMode } from '@/const/enum';
import key from '@/const/key';
import { VolumeHighIcon, VolumeLowIcon } from './icons';
export interface AudioPlayerProps
extends React.AudioHTMLAttributes<HTMLAudioElement> {
src: string
title?: string
artist?: string
cover?: string
pressNext?: () => void
pressPrevious?: () => void
onPlayEnd?: () => void
onChangeMode?: (mode: PlayMode) => void
mode?: PlayMode
}
export default function AudioPlayer (props: AudioPlayerProps) {
const {
src,
pressNext,
pressPrevious,
cover = 'https://nextui.org/images/album-cover.png',
title = '未知',
artist = '未知',
onTimeUpdate,
onLoadedData,
onPlay,
onPause,
onPlayEnd,
onChangeMode,
autoPlay,
mode = PlayMode.Loop,
...rest
} = props;
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(100);
const [isCollapsed, setIsCollapsed] = useLocalStorage(
key.isCollapsedMusicPlayer,
false
);
const audioRef = useRef<HTMLAudioElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const startY = useRef(0);
const startX = useRef(0);
const [translateY, setTranslateY] = useState(0);
const [translateX, setTranslateX] = useState(0);
const isSmallScreen = useMediaQuery({ maxWidth: 767 });
const isMediumUp = useMediaQuery({ minWidth: 768 });
const shouldAdd = useRef(false);
const currentProgress = (currentTime / duration) * 100;
const [storageAutoPlay, setStorageAutoPlay] = useLocalStorage(
key.autoPlay,
true
);
const handleTimeUpdate = (event: React.SyntheticEvent<HTMLAudioElement>) => {
const audio = event.target as HTMLAudioElement;
setCurrentTime(audio.currentTime);
onTimeUpdate?.(event);
};
const handleLoadedData = (event: React.SyntheticEvent<HTMLAudioElement>) => {
const audio = event.target as HTMLAudioElement;
setDuration(audio.duration);
onLoadedData?.(event);
};
const handlePlay = (e: React.SyntheticEvent<HTMLAudioElement>) => {
setIsPlaying(true);
setStorageAutoPlay(true);
onPlay?.(e);
};
const handlePause = (e: React.SyntheticEvent<HTMLAudioElement>) => {
setIsPlaying(false);
onPause?.(e);
};
const changeMode = () => {
const modes = [PlayMode.Loop, PlayMode.Random, PlayMode.Single];
const currentIndex = modes.findIndex((_mode) => _mode === mode);
const nextIndex = currentIndex + 1;
const nextMode = modes[nextIndex] || modes[0];
onChangeMode?.(nextMode);
};
const volumeChange = (value: number) => {
setVolume(value);
};
useEffect(() => {
const audio = audioRef.current;
if (audio) {
audio.volume = volume / 100;
}
}, [volume]);
const handleTouchStart = (e: React.TouchEvent) => {
startY.current = e.touches[0].clientY;
startX.current = e.touches[0].clientX;
};
const handleTouchMove = (e: React.TouchEvent) => {
const deltaY = e.touches[0].clientY - startY.current;
const deltaX = e.touches[0].clientX - startX.current;
const container = cardRef.current;
const header = cardRef.current?.querySelector('[data-header]');
const headerHeight = header?.clientHeight || 20;
const addHeight = (container?.clientHeight || headerHeight) - headerHeight;
const _shouldAdd = isCollapsed && deltaY < 0;
if (isSmallScreen) {
shouldAdd.current = _shouldAdd;
setTranslateY(_shouldAdd ? deltaY + addHeight : deltaY);
} else {
setTranslateX(deltaX);
}
};
const handleTouchEnd = () => {
if (isSmallScreen) {
const container = cardRef.current;
const header = cardRef.current?.querySelector('[data-header]');
const headerHeight = header?.clientHeight || 20;
const addHeight = (container?.clientHeight || headerHeight) - headerHeight;
const _translateY = translateY - (shouldAdd.current ? addHeight : 0);
if (_translateY > 100) {
setIsCollapsed(true);
} else if (_translateY < -100) {
setIsCollapsed(false);
}
setTranslateY(0);
} else {
if (translateX > 100) {
setIsCollapsed(true);
} else if (translateX < -100) {
setIsCollapsed(false);
}
setTranslateX(0);
}
};
const dragTranslate = isSmallScreen
? translateY
? `translateY(${translateY}px)`
: ''
: translateX
? `translateX(${translateX}px)`
: '';
const collapsedTranslate = isCollapsed
? isSmallScreen
? 'translateY(90%)'
: 'translateX(96%)'
: '';
const translateStyle = dragTranslate || collapsedTranslate;
if (!src) return null;
return (
<div
className={clsx(
'fixed right-0 bottom-0 z-[52] w-full md:w-96',
!translateX && !translateY && 'transition-transform',
isCollapsed && 'md:hover:!translate-x-80'
)}
style={{
transform: translateStyle,
}}
>
<audio
src={src}
onLoadedData={handleLoadedData}
onTimeUpdate={handleTimeUpdate}
onPlay={handlePlay}
onPause={handlePause}
onEnded={onPlayEnd}
autoPlay={autoPlay ?? storageAutoPlay}
{...rest}
controls={false}
hidden
ref={audioRef}
/>
<Card
ref={cardRef}
className={clsx(
'border-none bg-background/60 dark:bg-default-300/50 w-full max-w-full transform transition-transform backdrop-blur-md duration-300 overflow-visible',
isSmallScreen ? 'rounded-t-3xl' : 'md:rounded-l-xl'
)}
classNames={{
body: 'p-0',
}}
shadow='sm'
radius='none'
>
{isMediumUp && (
<Button
isIconOnly
className={clsx(
'absolute data-[hover]:bg-foreground/10 text-lg z-50',
isCollapsed
? 'top-0 left-0 w-full h-full rounded-xl bg-opacity-0 hover:bg-opacity-30'
: 'top-3 -left-8 rounded-l-full bg-opacity-50 backdrop-blur-md'
)}
variant='solid'
color='primary'
size='sm'
onPress={() => setIsCollapsed(!isCollapsed)}
>
<FaRegHandPointRight />
</Button>
)}
{isSmallScreen && (
<CardHeader
data-header
className='flex-row justify-center pt-4'
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onClick={() => setIsCollapsed(!isCollapsed)}
>
<div className='w-24 h-2 rounded-full bg-content2-foreground shadow-sm' />
</CardHeader>
)}
<CardBody>
<div className='grid grid-cols-6 md:grid-cols-12 gap-6 md:gap-4 items-center justify-center overflow-hidden p-6 md:p-2 m-0'>
<div className='relative col-span-6 md:col-span-4 flex justify-center'>
<Image
alt='Album cover'
className='object-cover'
classNames={{
wrapper: 'w-36 aspect-square md:w-24 flex',
img: 'block w-full h-full',
}}
shadow='md'
src={cover}
width='100%'
/>
</div>
<div className='flex flex-col col-span-6 md:col-span-8'>
<div className='flex flex-col gap-0'>
<h1 className='font-medium truncate'>{title}</h1>
<p className='text-xs text-foreground/80 truncate'>{artist}</p>
</div>
<div className='flex flex-col'>
<Slider
aria-label='Music progress'
classNames={{
track: 'bg-default-500/30 border-none',
thumb: 'w-2 h-2 after:w-1.5 after:h-1.5',
filler: 'rounded-full',
}}
color='foreground'
value={currentProgress || 0}
defaultValue={0}
size='sm'
onChange={(value) => {
value = Array.isArray(value) ? value[0] : value;
const audio = audioRef.current;
if (audio) {
audio.currentTime = (value / 100) * duration;
}
}}
/>
<div className='flex justify-between h-3'>
<p className='text-xs'>
{Math.floor(currentTime / 60)}:
{Math.floor(currentTime % 60)
.toString()
.padStart(2, '0')}
</p>
<p className='text-xs text-foreground/50'>
{Math.floor(duration / 60)}:
{Math.floor(duration % 60)
.toString()
.padStart(2, '0')}
</p>
</div>
</div>
<div className='flex w-full items-center justify-center'>
<Tooltip
content={
mode === PlayMode.Loop
? '列表循环'
: mode === PlayMode.Random
? '随机播放'
: '单曲循环'
}
>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-lg md:text-medium'
radius='full'
variant='light'
size='md'
onPress={changeMode}
>
{mode === PlayMode.Loop && (
<FaRepeat className='text-foreground/80' />
)}
{mode === PlayMode.Random && (
<FaShuffle className='text-foreground/80' />
)}
{mode === PlayMode.Single && (
<TbRepeatOnce className='text-foreground/80 text-xl' />
)}
</Button>
</Tooltip>
<Tooltip content='上一首'>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
radius='full'
variant='light'
size='md'
onPress={pressPrevious}
>
<BiSolidSkipPreviousCircle />
</Button>
</Tooltip>
<Tooltip content={isPlaying ? '暂停' : '播放'}>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-3xl md:text-3xl'
radius='full'
variant='light'
size='lg'
onPress={() => {
if (isPlaying) {
audioRef.current?.pause();
setStorageAutoPlay(false);
} else {
audioRef.current?.play();
}
}}
>
{isPlaying ? <FaPause /> : <FaPlay className='ml-1' />}
</Button>
</Tooltip>
<Tooltip content='下一首'>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
radius='full'
variant='light'
size='md'
onPress={pressNext}
>
<BiSolidSkipNextCircle />
</Button>
</Tooltip>
<Popover
placement='top'
classNames={{
content: 'bg-opacity-30 backdrop-blur-md',
}}
>
<PopoverTrigger>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-xl md:text-xl'
radius='full'
variant='light'
size='md'
>
<VolumeHighIcon />
</Button>
</PopoverTrigger>
<PopoverContent>
<Slider
orientation='vertical'
showTooltip
aria-label='Volume'
className='h-40'
color='primary'
defaultValue={volume}
onChange={(value) => {
value = Array.isArray(value) ? value[0] : value;
volumeChange(value);
}}
startContent={<VolumeHighIcon className='text-2xl' />}
size='sm'
endContent={<VolumeLowIcon className='text-2xl' />}
/>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</CardBody>
</Card>
</div>
);
}

View File

@ -18,7 +18,7 @@ import {
} from '../icons'; } from '../icons';
export interface AddButtonProps { export interface AddButtonProps {
onOpen: (key: keyof OneBotConfig['network']) => void; onOpen: (key: keyof OneBotConfig['network']) => void
} }
const AddButton: React.FC<AddButtonProps> = (props) => { const AddButton: React.FC<AddButtonProps> = (props) => {
@ -33,7 +33,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
> >
<DropdownTrigger> <DropdownTrigger>
<Button <Button
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md" color='primary'
startContent={<IoAddCircleOutline className='text-2xl' />} startContent={<IoAddCircleOutline className='text-2xl' />}
> >
@ -41,7 +41,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu <DropdownMenu
aria-label='Create Network Config' aria-label='Create Network Config'
color='default' color='primary'
variant='flat' variant='flat'
onAction={(key) => { onAction={(key) => {
onOpen(key as keyof OneBotConfig['network']); onOpen(key as keyof OneBotConfig['network']);

View File

@ -4,11 +4,11 @@ import toast from 'react-hot-toast';
import { IoMdRefresh } from 'react-icons/io'; import { IoMdRefresh } from 'react-icons/io';
export interface SaveButtonsProps { export interface SaveButtonsProps {
onSubmit: () => void; onSubmit: () => void
reset: () => void; reset: () => void
refresh?: () => void; refresh?: () => void
isSubmitting: boolean; isSubmitting: boolean
className?: string; className?: string
} }
const SaveButtons: React.FC<SaveButtonsProps> = ({ const SaveButtons: React.FC<SaveButtonsProps> = ({
@ -20,15 +20,13 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
}) => ( }) => (
<div <div
className={clsx( className={clsx(
'w-full flex flex-col justify-center gap-3', 'max-w-full mx-3 w-96 flex flex-col justify-center gap-3',
className className
)} )}
> >
<div className='flex items-center justify-center gap-2 mt-5'> <div className='flex items-center justify-center gap-2 mt-5'>
<Button <Button
radius="full" color='default'
variant="flat"
className="font-medium bg-default-100 text-default-600 dark:bg-default-50/50"
onPress={() => { onPress={() => {
reset(); reset();
toast.success('重置成功'); toast.success('重置成功');
@ -38,8 +36,6 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
</Button> </Button>
<Button <Button
color='primary' color='primary'
radius="full"
className="font-medium shadow-md shadow-primary/20"
isLoading={isSubmitting} isLoading={isSubmitting}
onPress={() => onSubmit()} onPress={() => onSubmit()}
> >
@ -48,12 +44,12 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
{refresh && ( {refresh && (
<Button <Button
isIconOnly isIconOnly
color='secondary'
radius='full' radius='full'
variant='flat' variant='flat'
className="text-default-500 bg-default-100 dark:bg-default-50/50"
onPress={() => refresh()} onPress={() => refresh()}
> >
<IoMdRefresh size={20} /> <IoMdRefresh size={24} />
</Button> </Button>
)} )}
</div> </div>

View File

@ -10,27 +10,14 @@ import {
import ChatInput from '.'; import ChatInput from '.';
interface ChatInputModalProps { export default function ChatInputModal () {
children?: (onOpen: () => void) => React.ReactNode;
}
export default function ChatInputModal ({ children }: ChatInputModalProps) {
const { isOpen, onOpen, onOpenChange } = useDisclosure(); const { isOpen, onOpen, onOpenChange } = useDisclosure();
return ( return (
<> <>
{children ? children(onOpen) : ( <Button onPress={onOpen} color='primary' radius='full' variant='flat'>
<Button
onPress={onOpen} </Button>
color='primary'
radius='full'
variant='flat'
size='sm'
className="bg-primary/10 text-primary"
>
</Button>
)}
<Modal <Modal
size='4xl' size='4xl'
scrollBehavior='inside' scrollBehavior='inside'

View File

@ -1,126 +1,55 @@
import React, { useImperativeHandle, useEffect, useState } from 'react'; import Editor, { OnMount, loader } from '@monaco-editor/react';
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { json } from '@codemirror/lang-json'; import React from 'react';
import { oneDark } from '@codemirror/theme-one-dark';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/hooks/use-theme';
import { EditorView } from '@codemirror/view';
import clsx from 'clsx';
const getLanguageExtension = (lang?: string) => { import monaco from '@/monaco';
switch (lang) {
case 'json': return json();
default: return [];
}
};
export interface CodeEditorProps { loader.config({
value?: string; monaco,
defaultValue?: string; paths: {
language?: string; vs: '/webui/monaco-editor/min/vs',
defaultLanguage?: string; },
onChange?: (value: string | undefined) => void;
height?: string;
options?: any;
onMount?: any;
}
export interface CodeEditorRef {
getValue: () => string;
}
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>((props, ref) => {
const { isDark } = useTheme();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [val, setVal] = useState(props.value || props.defaultValue || '');
const internalRef = React.useRef<ReactCodeMirrorRef>(null);
useEffect(() => {
if (props.value !== undefined) {
setVal(props.value);
}
}, [props.value]);
useImperativeHandle(ref, () => ({
getValue: () => {
// Prefer getting dynamic value from view, fallback to state
return internalRef.current?.view?.state.doc.toString() || val;
}
}));
const customTheme = EditorView.theme({
"&": {
fontSize: "14px",
height: "100% !important",
},
".cm-scroller": {
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
lineHeight: "1.6",
overflow: "auto !important",
height: "100% !important",
},
".cm-gutters": {
backgroundColor: "transparent",
borderRight: "none",
color: isDark ? "#ffffff50" : "#00000040",
},
".cm-gutterElement": {
paddingLeft: "12px",
paddingRight: "12px",
},
".cm-activeLineGutter": {
backgroundColor: "transparent",
color: isDark ? "#fff" : "#000",
},
".cm-content": {
caretColor: isDark ? "#fff" : "#000",
paddingTop: "12px",
paddingBottom: "12px",
},
".cm-activeLine": {
backgroundColor: isDark ? "#ffffff10" : "#00000008",
},
".cm-selectionMatch": {
backgroundColor: isDark ? "#ffffff20" : "#00000010",
},
});
const extensions = [
customTheme,
getLanguageExtension(props.language || props.defaultLanguage),
props.options?.wordWrap === 'on' ? EditorView.lineWrapping : [],
props.options?.readOnly ? EditorView.editable.of(false) : [],
].flat();
return (
<div
style={{ fontSize: props.options?.fontSize || 14, height: props.height || '100%', display: 'flex', flexDirection: 'column' }}
className={clsx(
'rounded-xl border overflow-hidden transition-colors',
isDark
? 'border-white/10 bg-[#282c34]'
: 'border-default-200 bg-white'
)}
>
<CodeMirror
ref={internalRef}
value={props.value ?? props.defaultValue}
height="100%"
className="h-full w-full"
theme={isDark ? oneDark : 'light'}
extensions={extensions}
onChange={(value) => {
setVal(value);
props.onChange?.(value);
}}
readOnly={props.options?.readOnly}
basicSetup={{
lineNumbers: props.options?.lineNumbers !== 'off',
foldGutter: props.options?.folding !== false,
highlightActiveLine: props.options?.renderLineHighlight !== 'none',
}}
/>
</div>
);
}); });
loader.config({
'vs/nls': {
availableLanguages: { '*': 'zh-cn' },
},
});
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
test?: string
}
export type CodeEditorRef = monaco.editor.IStandaloneCodeEditor;
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>(
(props, ref) => {
const { isDark } = useTheme();
const handleEditorDidMount: OnMount = (editor, monaco) => {
if (ref) {
if (typeof ref === 'function') {
ref(editor);
} else {
(ref as React.RefObject<CodeEditorRef>).current = editor;
}
}
if (props.onMount) {
props.onMount(editor, monaco);
}
};
return (
<Editor
{...props}
onMount={handleEditorDidMount}
theme={isDark ? 'vs-dark' : 'light'}
/>
);
}
);
export default CodeEditor; export default CodeEditor;

View File

@ -1,6 +1,5 @@
import { Button } from '@heroui/button'; import { Button, ButtonGroup } from '@heroui/button';
import { Switch } from '@heroui/switch'; import { Switch } from '@heroui/switch';
import clsx from 'clsx';
import { useState } from 'react'; import { useState } from 'react';
import { CgDebug } from 'react-icons/cg'; import { CgDebug } from 'react-icons/cg';
import { FiEdit3 } from 'react-icons/fi'; import { FiEdit3 } from 'react-icons/fi';
@ -11,26 +10,27 @@ import DisplayCardContainer from './container';
type NetworkType = OneBotConfig['network']; type NetworkType = OneBotConfig['network'];
export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{ export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{
label: string; label: string
value: NetworkType[T][0][keyof NetworkType[T][0]]; value: NetworkType[T][0][keyof NetworkType[T][0]]
render?: ( render?: (
value: NetworkType[T][0][keyof NetworkType[T][0]] value: NetworkType[T][0][keyof NetworkType[T][0]]
) => React.ReactNode; ) => React.ReactNode
}>; }>;
export interface NetworkDisplayCardProps<T extends keyof NetworkType> { export interface NetworkDisplayCardProps<T extends keyof NetworkType> {
data: NetworkType[T][0]; data: NetworkType[T][0]
typeLabel: string; showType?: boolean
fields: NetworkDisplayCardFields<T>; typeLabel: string
onEdit: () => void; fields: NetworkDisplayCardFields<T>
onEnable: () => Promise<void>; onEdit: () => void
onDelete: () => Promise<void>; onEnable: () => Promise<void>
onEnableDebug: () => Promise<void>; onDelete: () => Promise<void>
showType?: boolean; onEnableDebug: () => Promise<void>
} }
const NetworkDisplayCard = <T extends keyof NetworkType> ({ const NetworkDisplayCard = <T extends keyof NetworkType>({
data, data,
showType,
typeLabel, typeLabel,
fields, fields,
onEdit, onEdit,
@ -56,146 +56,79 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
onEnableDebug().finally(() => setEditing(false)); onEnableDebug().finally(() => setEditing(false));
}; };
const isFullWidthField = (label: string) => ['URL', 'Token', 'AccessToken'].includes(label);
return ( return (
<DisplayCardContainer <DisplayCardContainer
className="w-full max-w-[420px]"
action={ action={
<div className="flex gap-2 w-full"> <ButtonGroup
fullWidth
isDisabled={editing}
radius='sm'
size='sm'
variant='flat'
>
<Button <Button
fullWidth color='warning'
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"
startContent={<FiEdit3 size={16} />} startContent={<FiEdit3 size={16} />}
onPress={onEdit} onPress={onEdit}
isDisabled={editing}
> >
</Button> </Button>
<Button <Button
fullWidth color={debug ? 'secondary' : 'success'}
radius='full'
size='sm'
variant='flat' variant='flat'
className={clsx( startContent={
"flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium transition-colors", <CgDebug
debug style={{
? "hover:bg-secondary/20 hover:text-secondary data-[hover=true]:text-secondary" width: '16px',
: "hover:bg-success/20 hover:text-success data-[hover=true]:text-success" height: '16px',
)} minWidth: '16px',
startContent={<CgDebug size={16} />} minHeight: '16px',
}}
/>
}
onPress={handleEnableDebug} onPress={handleEnableDebug}
isDisabled={editing}
> >
{debug ? '关闭调试' : '开启调试'} {debug ? '关闭调试' : '开启调试'}
</Button> </Button>
<Button <Button
fullWidth className='bg-danger/20 text-danger hover:bg-danger/30 transition-colors'
radius='full'
size='sm'
variant='flat' variant='flat'
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-danger/20 hover:text-danger transition-colors'
startContent={<MdDeleteForever size={16} />} startContent={<MdDeleteForever size={16} />}
onPress={handleDelete} onPress={handleDelete}
isDisabled={editing}
> >
</Button> </Button>
</div> </ButtonGroup>
} }
enableSwitch={ enableSwitch={
<Switch <Switch
isDisabled={editing} isDisabled={editing}
isSelected={enable} isSelected={enable}
onChange={handleEnable} onChange={handleEnable}
classNames={{
wrapper: "group-data-[selected=true]:bg-primary-400",
}}
/> />
} }
title={typeLabel} tag={showType && typeLabel}
title={name}
> >
<div className='grid grid-cols-2 gap-3'> <div className='grid grid-cols-2 gap-1'>
{(() => { {fields.map((field, index) => (
const targetFullField = fields.find(f => isFullWidthField(f.label)); <div
key={index}
if (targetFullField) { className={`flex items-center gap-2 ${
// 模式1存在全宽字段如URL布局为 field.label === 'URL' ? 'col-span-2' : ''
// Row 1: 名称 (全宽) }`}
// Row 2: 全宽字段 (全宽) >
return ( <span className='text-default-400'>{field.label}</span>
<> {field.render
<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' field.render(field.value)
> )
<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"> <span>{field.value}</span>
{name} )}
</div> </div>
</div> ))}
<div
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors col-span-2'
>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>{targetFullField.label}</span>
<div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
{targetFullField.render
? targetFullField.render(targetFullField.value)
: (
<span className={clsx(
typeof targetFullField.value === 'string' && (targetFullField.value.startsWith('http') || targetFullField.value.includes('.') || targetFullField.value.includes(':')) ? 'font-mono' : ''
)}>
{String(targetFullField.value)}
</span>
)}
</div>
</div>
</>
);
} else {
// 模式2无全宽字段布局为 4 个小块 (2行 x 2列)
// Row 1: 名称 | Field 0
// Row 2: Field 1 | Field 2
const displayFields = fields.slice(0, 3);
return (
<>
<div
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'></span>
<div className="text-sm font-medium text-default-700 dark:text-white/90 truncate">
{name}
</div>
</div>
{displayFields.map((field, index) => (
<div
key={index}
className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'
>
<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">
{field.render
? (
field.render(field.value)
)
: (
<span className={clsx(
typeof field.value === 'string' && (field.value.startsWith('http') || field.value.includes('.') || field.value.includes(':')) ? 'font-mono' : ''
)}>
{String(field.value)}
</span>
)}
</div>
</div>
))}
{/* 如果字段不足3个可以补充空白块占位吗或者是让它空着用户说要高度一致。只要是grid通常高度会被撑开。目前这样应该能保证最多2行。 */}
</>
);
}
})()}
</div> </div>
</DisplayCardContainer> </DisplayCardContainer>
); );

View File

@ -1,24 +1,22 @@
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card'; import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import key from '@/const/key';
import { title } from '../primitives';
export interface ContainerProps { export interface ContainerProps {
title: string; title: string
tag?: React.ReactNode; tag?: React.ReactNode
action: React.ReactNode; action: React.ReactNode
enableSwitch: React.ReactNode; enableSwitch: React.ReactNode
children: React.ReactNode; children: React.ReactNode
className?: string; // Add className prop
} }
export interface DisplayCardProps { export interface DisplayCardProps {
showType?: boolean; showType?: boolean
onEdit: () => void; onEdit: () => void
onEnable: () => Promise<void>; onEnable: () => Promise<void>
onDelete: () => Promise<void>; onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>; onEnableDebug: () => Promise<void>
} }
const DisplayCardContainer: React.FC<ContainerProps> = ({ const DisplayCardContainer: React.FC<ContainerProps> = ({
@ -27,35 +25,31 @@ const DisplayCardContainer: React.FC<ContainerProps> = ({
tag, tag,
enableSwitch, enableSwitch,
children, children,
className,
}) => { }) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card className={clsx( <Card className='bg-opacity-50 backdrop-blur-sm'>
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm rounded-2xl overflow-hidden transition-all', <CardHeader className='pb-0 flex items-center'>
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40',
className
)}
>
<CardHeader className='p-4 pb-2 flex items-center justify-between gap-3'>
{tag && ( {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'> <div className='text-center text-default-400 mb-1 absolute top-0 left-1/2 -translate-x-1/2 text-sm pointer-events-none bg-warning-100 dark:bg-warning-50 px-2 rounded-b'>
{tag} {tag}
</div> </div>
)} )}
<div className='flex-1 min-w-0 mr-2'> <h2
<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'> className={clsx(
<span className='font-bold text-default-600 dark:text-white/90 text-sm truncate select-text'> title({
{_title} color: 'foreground',
</span> size: 'xs',
</div> shadow: true,
</div> }),
<div className='flex-shrink-0'>{enableSwitch}</div> 'truncate'
)}
>
{_title}
</h2>
<div className='ml-auto'>{enableSwitch}</div>
</CardHeader> </CardHeader>
<CardBody className='px-4 py-2 text-sm text-default-600'>{children}</CardBody> <CardBody className='text-sm'>{children}</CardBody>
<CardFooter className='px-4 pb-4 pt-2'>{action}</CardFooter> <CardFooter>{action}</CardFooter>
</Card> </Card>
); );
}; };

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card'; import type { NetworkDisplayCardFields } from './common_card';
interface HTTPClientDisplayCardProps { interface HTTPClientDisplayCardProps {
data: OneBotConfig['network']['httpClients'][0]; data: OneBotConfig['network']['httpClients'][0]
showType?: boolean; showType?: boolean
onEdit: () => void; onEdit: () => void
onEnable: () => Promise<void>; onEnable: () => Promise<void>
onDelete: () => Promise<void>; onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>; onEnableDebug: () => Promise<void>
} }
const HTTPClientDisplayCard: React.FC<HTTPClientDisplayCardProps> = (props) => { const HTTPClientDisplayCard: React.FC<HTTPClientDisplayCardProps> = (props) => {

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card'; import type { NetworkDisplayCardFields } from './common_card';
interface HTTPServerDisplayCardProps { interface HTTPServerDisplayCardProps {
data: OneBotConfig['network']['httpServers'][0]; data: OneBotConfig['network']['httpServers'][0]
showType?: boolean; showType?: boolean
onEdit: () => void; onEdit: () => void
onEnable: () => Promise<void>; onEnable: () => Promise<void>
onDelete: () => Promise<void>; onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>; onEnableDebug: () => Promise<void>
} }
const HTTPServerDisplayCard: React.FC<HTTPServerDisplayCardProps> = (props) => { const HTTPServerDisplayCard: React.FC<HTTPServerDisplayCardProps> = (props) => {

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card'; import type { NetworkDisplayCardFields } from './common_card';
interface HTTPSSEServerDisplayCardProps { interface HTTPSSEServerDisplayCardProps {
data: OneBotConfig['network']['httpSseServers'][0]; data: OneBotConfig['network']['httpSseServers'][0]
showType?: boolean; showType?: boolean
onEdit: () => void; onEdit: () => void
onEnable: () => Promise<void>; onEnable: () => Promise<void>
onDelete: () => Promise<void>; onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>; onEnableDebug: () => Promise<void>
} }
const HTTPSSEServerDisplayCard: React.FC<HTTPSSEServerDisplayCardProps> = ( const HTTPSSEServerDisplayCard: React.FC<HTTPSSEServerDisplayCardProps> = (

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card'; import type { NetworkDisplayCardFields } from './common_card';
interface WebsocketClientDisplayCardProps { interface WebsocketClientDisplayCardProps {
data: OneBotConfig['network']['websocketClients'][0]; data: OneBotConfig['network']['websocketClients'][0]
showType?: boolean; showType?: boolean
onEdit: () => void; onEdit: () => void
onEnable: () => Promise<void>; onEnable: () => Promise<void>
onDelete: () => Promise<void>; onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>; onEnableDebug: () => Promise<void>
} }
const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = ( const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = (

View File

@ -4,12 +4,12 @@ import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card'; import type { NetworkDisplayCardFields } from './common_card';
interface WebsocketServerDisplayCardProps { interface WebsocketServerDisplayCardProps {
data: OneBotConfig['network']['websocketServers'][0]; data: OneBotConfig['network']['websocketServers'][0]
showType?: boolean; showType?: boolean
onEdit: () => void; onEdit: () => void
onEnable: () => Promise<void>; onEnable: () => Promise<void>
onDelete: () => Promise<void>; onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>; onEnableDebug: () => Promise<void>
} }
const WebsocketServerDisplayCard: React.FC<WebsocketServerDisplayCardProps> = ( const WebsocketServerDisplayCard: React.FC<WebsocketServerDisplayCardProps> = (

View File

@ -1,14 +1,12 @@
import { Card, CardBody } from '@heroui/card'; import { Card, CardBody } from '@heroui/card';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import key from '@/const/key';
import { title } from '@/components/primitives';
export interface NetworkItemDisplayProps { export interface NetworkItemDisplayProps {
count: number; count: number
label: string; label: string
size?: 'sm' | 'md'; size?: 'sm' | 'md'
} }
const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
@ -16,37 +14,38 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
label, label,
size = 'md', size = 'md',
}) => { }) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card <Card
className={clsx( className={clsx(
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all', 'bg-opacity-60 shadow-sm md:rounded-3xl',
hasBackground
? 'bg-white/10 dark:bg-black/10 hover:bg-white/20 dark:hover:bg-black/20'
: 'bg-white/60 dark:bg-black/40 hover:bg-white/70 dark:hover:bg-black/30',
size === 'md' size === 'md'
? 'col-span-8 md:col-span-2' ? 'col-span-8 md:col-span-2 bg-primary-50 shadow-primary-100'
: 'col-span-2 md:col-span-1' : 'col-span-2 md:col-span-1 bg-warning-100 shadow-warning-200'
)} )}
shadow='none' shadow='sm'
> >
<CardBody className='items-center md:gap-1 p-1 md:p-2'> <CardBody className='items-center md:gap-1 p-1 md:p-2'>
<div <div
className={clsx( className={clsx(
'flex-1 font-mono font-bold', 'flex-1',
size === 'md' ? 'text-4xl md:text-5xl' : 'text-2xl md:text-3xl', size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200' title({
color: size === 'md' ? 'pink' : 'yellow',
size,
})
)} )}
> >
{count} {count}
</div> </div>
<div <div
className={clsx( className={clsx(
'whitespace-nowrap text-nowrap flex-shrink-0 font-medium', 'whitespace-nowrap text-nowrap flex-shrink-0',
size === 'md' ? 'text-sm' : 'text-xs', size === 'md' ? 'text-sm md:text-base' : 'text-xs md:text-sm',
hasBackground ? 'text-white/80' : 'text-default-500' title({
color: size === 'md' ? 'pink' : 'yellow',
shadow: true,
size: 'xxs',
})
)} )}
> >
{label} {label}

View File

@ -94,7 +94,7 @@ const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
ref={lightRef} ref={lightRef}
className={clsx( className={clsx(
isShowLight ? 'opacity-100' : 'opacity-0', isShowLight ? 'opacity-100' : 'opacity-0',
'absolute rounded-full blur-[100px] filter transition-opacity duration-300 bg-gradient-to-r from-primary-400 to-secondary-400 w-[150px] h-[150px]', 'absolute rounded-full blur-[150px] filter transition-opacity duration-300 dark:bg-[#2850ff] bg-[#ff4132] w-[100px] h-[100px]',
lightClassName lightClassName
)} )}
style={{ style={{

View File

@ -11,11 +11,11 @@ import {
import CodeEditor from '@/components/code_editor'; import CodeEditor from '@/components/code_editor';
interface FileEditModalProps { interface FileEditModalProps {
isOpen: boolean; isOpen: boolean
file: { path: string; content: string; } | null; file: { path: string; content: string } | null
onClose: () => void; onClose: () => void
onSave: () => void; onSave: () => void
onContentChange: (newContent?: string) => void; onContentChange: (newContent?: string) => void
} }
export default function FileEditModal ({ export default function FileEditModal ({
@ -65,20 +65,12 @@ export default function FileEditModal ({
return ( return (
<Modal size='full' isOpen={isOpen} onClose={onClose}> <Modal size='full' isOpen={isOpen} onClose={onClose}>
<ModalContent> <ModalContent>
<ModalHeader className='flex items-center gap-2 border-b border-default-200/50'> <ModalHeader className='flex items-center gap-2 bg-content2 bg-opacity-50'>
<span></span> <span></span>
<Code className='text-xs'>{file?.path}</Code> <Code className='text-xs'>{file?.path}</Code>
<div className="ml-auto text-xs text-default-400 font-normal px-2">
<span className="px-1 py-0.5 rounded border border-default-300 bg-default-100">Ctrl/Cmd + S</span>
</div>
</ModalHeader> </ModalHeader>
<ModalBody className='p-4 bg-content2/50'> <ModalBody className='p-0'>
<div className='h-full' onKeyDown={(e) => { <div className='h-full'>
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
onSave();
}
}}>
<CodeEditor <CodeEditor
height='100%' height='100%'
value={file?.content || ''} value={file?.content || ''}
@ -88,7 +80,7 @@ export default function FileEditModal ({
/> />
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter className="border-t border-default-200/50"> <ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}> <Button color='primary' variant='flat' onPress={onClose}>
</Button> </Button>

View File

@ -25,21 +25,21 @@ import { supportedPreviewExts } from './file_preview_modal';
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button'; import ImageNameButton, { PreviewImage, imageExts } from './image_name_button';
export interface FileTableProps { export interface FileTableProps {
files: FileInfo[]; files: FileInfo[]
currentPath: string; currentPath: string
loading: boolean; loading: boolean
sortDescriptor: SortDescriptor; sortDescriptor: SortDescriptor
onSortChange: (descriptor: SortDescriptor) => void; onSortChange: (descriptor: SortDescriptor) => void
selectedFiles: Selection; selectedFiles: Selection
onSelectionChange: (selected: Selection) => void; onSelectionChange: (selected: Selection) => void
onDirectoryClick: (dirPath: string) => void; onDirectoryClick: (dirPath: string) => void
onEdit: (filePath: string) => void; onEdit: (filePath: string) => void
onPreview: (filePath: string) => void; onPreview: (filePath: string) => void
onRenameRequest: (name: string) => void; onRenameRequest: (name: string) => void
onMoveRequest: (name: string) => void; onMoveRequest: (name: string) => void
onCopyPath: (fileName: string) => void; onCopyPath: (fileName: string) => void
onDelete: (filePath: string) => void; onDelete: (filePath: string) => void
onDownload: (filePath: string) => void; onDownload: (filePath: string) => void
} }
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@ -112,7 +112,7 @@ export default function FileTable ({
selectedKeys={selectedFiles} selectedKeys={selectedFiles}
selectionMode='multiple' selectionMode='multiple'
bottomContent={ bottomContent={
<div className='flex w-full justify-center p-2 border-t border-white/10'> <div className='flex w-full justify-center'>
<Pagination <Pagination
isCompact isCompact
showControls showControls
@ -121,29 +121,21 @@ export default function FileTable ({
page={page} page={page}
total={pages} total={pages}
onChange={(page) => setPage(page)} onChange={(page) => setPage(page)}
classNames={{
cursor: 'bg-primary shadow-lg',
}}
/> />
</div> </div>
} }
classNames={{
wrapper: 'bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm p-0',
th: 'bg-white/40 dark:bg-white/5 backdrop-blur-md text-default-600',
td: 'group-data-[first=true]:first:before:rounded-none group-data-[first=true]:last:before:rounded-none',
}}
> >
<TableHeader> <TableHeader>
<TableColumn key='name' allowsSorting> <TableColumn key='name' allowsSorting>
</TableColumn> </TableColumn>
<TableColumn key='type' allowsSorting className='hidden md:table-cell'> <TableColumn key='type' allowsSorting>
</TableColumn> </TableColumn>
<TableColumn key='size' allowsSorting className='hidden md:table-cell'> <TableColumn key='size' allowsSorting>
</TableColumn> </TableColumn>
<TableColumn key='mtime' allowsSorting className='hidden md:table-cell'> <TableColumn key='mtime' allowsSorting>
</TableColumn> </TableColumn>
<TableColumn key='actions'></TableColumn> <TableColumn key='actions'></TableColumn>
@ -188,57 +180,57 @@ export default function FileTable ({
name={file.name} name={file.name}
isDirectory={file.isDirectory} isDirectory={file.isDirectory}
/> />
} }
> >
{file.name} {file.name}
</Button> </Button>
)} )}
</TableCell> </TableCell>
<TableCell className='hidden md:table-cell'>{file.isDirectory ? '目录' : '文件'}</TableCell> <TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
<TableCell className='hidden md:table-cell'> <TableCell>
{isNaN(file.size) || file.isDirectory {isNaN(file.size) || file.isDirectory
? '-' ? '-'
: `${file.size} 字节`} : `${file.size} 字节`}
</TableCell> </TableCell>
<TableCell className='hidden md:table-cell'>{new Date(file.mtime).toLocaleString()}</TableCell> <TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
<TableCell> <TableCell>
<ButtonGroup size='sm' variant='light'> <ButtonGroup size='sm'>
<Button <Button
isIconOnly isIconOnly
color='default' color='primary'
className='text-default-500 hover:text-primary' variant='flat'
onPress={() => onRenameRequest(file.name)} onPress={() => onRenameRequest(file.name)}
> >
<BiRename /> <BiRename />
</Button> </Button>
<Button <Button
isIconOnly isIconOnly
color='default' color='primary'
className='text-default-500 hover:text-primary' variant='flat'
onPress={() => onMoveRequest(file.name)} onPress={() => onMoveRequest(file.name)}
> >
<FiMove /> <FiMove />
</Button> </Button>
<Button <Button
isIconOnly isIconOnly
color='default' color='primary'
className='text-default-500 hover:text-primary' variant='flat'
onPress={() => onCopyPath(file.name)} onPress={() => onCopyPath(file.name)}
> >
<FiCopy /> <FiCopy />
</Button> </Button>
<Button <Button
isIconOnly isIconOnly
color='default' color='primary'
className='text-default-500 hover:text-primary' variant='flat'
onPress={() => onDownload(filePath)} onPress={() => onDownload(filePath)}
> >
<FiDownload /> <FiDownload />
</Button> </Button>
<Button <Button
isIconOnly isIconOnly
color='danger' color='primary'
className='text-danger hover:bg-danger/10' variant='flat'
onPress={() => onDelete(filePath)} onPress={() => onDelete(filePath)}
> >
<FiTrash2 /> <FiTrash2 />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,13 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card';
import { Input } from '@heroui/input'; import { Input } from '@heroui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'; import { Snippet } from '@heroui/snippet';
import { Tooltip } from '@heroui/tooltip';
import { Tab, Tabs } from '@heroui/tabs';
import { Chip } from '@heroui/chip';
import { useLocalStorage } from '@uidotdev/usehooks'; import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import { motion } from 'motion/react';
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useRef, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { IoChevronDown, IoSend, IoSettingsSharp, IoCopy } from 'react-icons/io5'; import { IoLink, IoSend } from 'react-icons/io5';
import { TbCode, TbMessageCode } from 'react-icons/tb'; import { PiCatDuotone } from 'react-icons/pi';
import key from '@/const/key'; import key from '@/const/key';
import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api'; import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api';
@ -19,7 +17,7 @@ import CodeEditor from '@/components/code_editor';
import PageLoading from '@/components/page_loading'; import PageLoading from '@/components/page_loading';
import { request } from '@/utils/request'; import { request } from '@/utils/request';
import { parseAxiosResponse } from '@/utils/url';
import { generateDefaultJson, parse } from '@/utils/zod'; import { generateDefaultJson, parse } from '@/utils/zod';
import DisplayStruct from './display_struct'; import DisplayStruct from './display_struct';
@ -27,11 +25,10 @@ import DisplayStruct from './display_struct';
export interface OneBotApiDebugProps { export interface OneBotApiDebugProps {
path: OneBotHttpApiPath; path: OneBotHttpApiPath;
data: OneBotHttpApiContent; data: OneBotHttpApiContent;
adapterName?: string;
} }
const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => { const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
const { path, data, adapterName } = props; const { path, data } = props;
const currentURL = new URL(window.location.origin); const currentURL = new URL(window.location.origin);
currentURL.port = '3000'; currentURL.port = '3000';
const defaultHttpUrl = currentURL.href; const defaultHttpUrl = currentURL.href;
@ -39,61 +36,21 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
url: defaultHttpUrl, url: defaultHttpUrl,
token: '', token: '',
}); });
const [requestBody, setRequestBody] = useState('{}'); const [requestBody, setRequestBody] = useState('{}');
const [responseContent, setResponseContent] = useState(''); const [responseContent, setResponseContent] = useState('');
const [isCodeEditorOpen, setIsCodeEditorOpen] = useState(false);
const [isResponseOpen, setIsResponseOpen] = useState(false);
const [isFetching, setIsFetching] = useState(false); const [isFetching, setIsFetching] = useState(false);
const [activeTab, setActiveTab] = useState<any>('request'); const responseRef = useRef<HTMLDivElement>(null);
const [responseExpanded, setResponseExpanded] = useState(true);
const [responseStatus, setResponseStatus] = useState<{ code: number; text: string; } | null>(null);
const [responseHeight, setResponseHeight] = useLocalStorage('napcat_debug_response_height', 240); // 默认高度
const parsedRequest = parse(data.request); const parsedRequest = parse(data.request);
const parsedResponse = parse(data.response); const parsedResponse = parse(data.response);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const sendRequest = async () => { const sendRequest = async () => {
if (isFetching) return; if (isFetching) return;
setIsFetching(true); setIsFetching(true);
setResponseStatus(null);
const r = toast.loading('正在发送请求...'); const r = toast.loading('正在发送请求...');
try { try {
const parsedRequestBody = JSON.parse(requestBody); const parsedRequestBody = JSON.parse(requestBody);
// 如果有 adapterName走后端转发
if (adapterName) {
request.post(`/api/Debug/call/${adapterName}`, {
action: path.replace(/^\//, ''), // 去掉开头的 /
params: parsedRequestBody
}, {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
}).then((res) => {
if (res.data.code === 0) {
setResponseContent(JSON.stringify(res.data.data, null, 2));
setResponseStatus({ code: 200, text: 'OK' });
} else {
setResponseContent(JSON.stringify(res.data, null, 2));
setResponseStatus({ code: 500, text: res.data.message });
}
setResponseExpanded(true);
toast.success('请求成功');
}).catch((err) => {
toast.error('请求失败:' + err.message);
setResponseContent(JSON.stringify({ error: err.message }, null, 2));
setResponseStatus({ code: 500, text: 'Error' });
setResponseExpanded(true);
}).finally(() => {
setIsFetching(false);
toast.dismiss(r);
});
return;
}
// 回退到旧逻辑 (直接请求)
const requestURL = new URL(httpConfig.url); const requestURL = new URL(httpConfig.url);
requestURL.pathname = path; requestURL.pathname = path;
request request
@ -101,23 +58,23 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
headers: { headers: {
Authorization: `Bearer ${httpConfig.token}`, Authorization: `Bearer ${httpConfig.token}`,
}, },
}) // 移除 responseType: 'text',以便 axios 自动解析 JSON responseType: 'text',
})
.then((res) => { .then((res) => {
setResponseContent(JSON.stringify(res.data, null, 2)); setResponseContent(parseAxiosResponse(res));
setResponseStatus({ code: res.status, text: res.statusText }); toast.success('请求发送完成,请查看响应');
setResponseExpanded(true);
toast.success('请求成功');
}) })
.catch((err) => { .catch((err) => {
toast.error('请求失败:' + err.message); toast.error('请求发送失败:' + err.message);
setResponseContent(JSON.stringify(err.response?.data || { error: err.message }, null, 2)); setResponseContent(parseAxiosResponse(err.response));
if (err.response) {
setResponseStatus({ code: err.response.status, text: err.response.statusText });
}
setResponseExpanded(true);
}) })
.finally(() => { .finally(() => {
setIsFetching(false); setIsFetching(false);
setIsResponseOpen(true);
responseRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
toast.dismiss(r); toast.dismiss(r);
}); });
} catch (_error) { } catch (_error) {
@ -130,249 +87,150 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
useEffect(() => { useEffect(() => {
setRequestBody(generateDefaultJson(data.request)); setRequestBody(generateDefaultJson(data.request));
setResponseContent(''); setResponseContent('');
setResponseStatus(null);
}, [path]); }, [path]);
// Height Resizing Logic
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
const startY = e.clientY;
const startHeight = responseHeight;
const handleMouseMove = (mv: MouseEvent) => {
const delta = startY - mv.clientY;
// 向上拖动 -> 增加高度
setResponseHeight(Math.max(100, Math.min(window.innerHeight - 200, startHeight + delta)));
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [responseHeight, setResponseHeight]);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
// 阻止默认滚动行为可能需要谨慎,这里尽量只阻止 handle 上的
// e.preventDefault();
const touch = e.touches[0];
const startY = touch.clientY;
const startHeight = responseHeight;
const handleTouchMove = (mv: TouchEvent) => {
const mvTouch = mv.touches[0];
const delta = startY - mvTouch.clientY;
setResponseHeight(Math.max(100, Math.min(window.innerHeight - 200, startHeight + delta)));
};
const handleTouchEnd = () => {
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
document.addEventListener('touchmove', handleTouchMove);
document.addEventListener('touchend', handleTouchEnd);
}, [responseHeight, setResponseHeight]);
return ( return (
<section className='h-full flex flex-col overflow-hidden bg-transparent'> <section className='p-4 pt-14 rounded-lg shadow-md'>
{/* URL Bar */} <h1 className='text-2xl font-bold mb-4 flex items-center gap-1 text-primary-400'>
<div className='flex flex-wrap md:flex-nowrap items-center gap-2 p-2 md:p-4 pb-2 flex-shrink-0'> <PiCatDuotone />
<div className={clsx( {data.description}
'flex-grow flex items-center gap-2 px-3 md:px-4 h-10 rounded-xl transition-all w-full md:w-auto', </h1>
hasBackground ? 'bg-white/5' : 'bg-black/5 dark:bg-white/5' <h1 className='text-lg font-bold mb-4'>
)}> <Snippet
<Chip size="sm" variant="shadow" color="primary" className="font-bold text-[10px] h-5 min-w-[40px]">POST</Chip> className='bg-default-50 bg-opacity-50 backdrop-blur-md'
<span className={clsx( symbol={<IoLink size={18} className='inline-block mr-1' />}
'text-xs font-mono truncate select-all flex-1 opacity-50', tooltipProps={{
hasBackground ? 'text-white' : 'text-default-600' content: '点击复制地址',
)}>{path}</span> }}
</div> >
{path}
<div className='flex items-center gap-2 flex-shrink-0 ml-auto'> </Snippet>
<Popover placement='bottom-end' backdrop='blur'> </h1>
<PopoverTrigger> <div className='flex gap-2 items-center'>
<Button size='sm' variant='light' radius='full' isIconOnly className='h-10 w-10 opacity-40 hover:opacity-100'> <Input
<IoSettingsSharp className="text-lg" /> label='HTTP URL'
</Button> placeholder='输入 HTTP URL'
</PopoverTrigger> value={httpConfig.url}
<PopoverContent className='w-[260px] p-3 rounded-xl border border-white/10 shadow-2xl bg-white/80 dark:bg-black/80 backdrop-blur-xl'> onChange={(e) =>
<div className='flex flex-col gap-2'> setHttpConfig({ ...httpConfig, url: e.target.value })}
<p className='text-[10px] font-bold opacity-30 uppercase tracking-widest'>Debug Setup</p> />
<Input label='Base URL' value={httpConfig.url} onChange={(e) => setHttpConfig({ ...httpConfig, url: e.target.value })} size='sm' variant='flat' /> <Input
<Input label='Token' value={httpConfig.token} onChange={(e) => setHttpConfig({ ...httpConfig, token: e.target.value })} size='sm' variant='flat' /> label='Token'
</div> placeholder='输入 Token'
</PopoverContent> value={httpConfig.token}
</Popover> onChange={(e) =>
setHttpConfig({ ...httpConfig, token: e.target.value })}
<Button />
onPress={sendRequest} <Button
color='primary' onPress={sendRequest}
radius='full' color='primary'
size='sm' size='lg'
className='h-10 px-6 font-bold shadow-md shadow-primary/20 hover:scale-[1.02] active:scale-[0.98]' radius='full'
isLoading={isFetching} isIconOnly
startContent={!isFetching && <IoSend className="text-xs" />} isDisabled={isFetching}
> >
<IoSend />
</Button> </Button>
</div>
</div> </div>
<Card
<div className='flex-1 flex flex-col min-h-0 bg-transparent'> shadow='sm'
<div className='px-4 flex flex-wrap items-center justify-between flex-shrink-0 min-h-[36px] gap-2 py-1'> className='my-4 bg-opacity-50 backdrop-blur-md overflow-visible'
<Tabs >
size="sm" <CardHeader className='font-bold text-lg gap-1 pb-0'>
variant="underlined" <span className='mr-2'></span>
selectedKey={activeTab} <Button
onSelectionChange={setActiveTab} color='warning'
classNames={{ variant='flat'
cursor: 'bg-primary h-0.5', onPress={() => setIsCodeEditorOpen(!isCodeEditorOpen)}
tab: 'px-0 mr-5 h-8', size='sm'
tabList: 'p-0 border-none', radius='full'
tabContent: 'text-[11px] font-bold opacity-30 group-data-[selected=true]:opacity-80 transition-opacity' >
{isCodeEditorOpen ? '收起' : '展开'}
</Button>
</CardHeader>
<CardBody>
<motion.div
ref={responseRef}
initial={{ opacity: 0, height: 0 }}
animate={{
opacity: isCodeEditorOpen ? 1 : 0,
height: isCodeEditorOpen ? 'auto' : 0,
}} }}
> >
<Tab key="request" title="请求参数" /> <CodeEditor
<Tab key="docs" title="接口定义" /> value={requestBody}
</Tabs> onChange={(value) => setRequestBody(value ?? '')}
<div className='flex items-center gap-1 ml-auto'> language='json'
<ChatInputModal> height='400px'
{(onOpen) => ( />
<Tooltip content="构造消息 (CQ码)" closeDelay={0}>
<Button
isIconOnly
size='sm'
variant='light'
radius='full'
className='h-7 w-7 text-primary/80 bg-primary/10 hover:bg-primary/20'
onPress={onOpen}
>
<TbMessageCode size={16} />
</Button>
</Tooltip>
)}
</ChatInputModal>
<Tooltip content="生成示例参数" closeDelay={0}> <div className='flex justify-end gap-1'>
<ChatInputModal />
<Button <Button
isIconOnly color='primary'
size='sm' variant='flat'
variant='light' onPress={() =>
radius='full' setRequestBody(generateDefaultJson(data.request))}
className='h-7 w-7 text-default-400 hover:text-primary hover:bg-default-100/50'
onPress={() => setRequestBody(generateDefaultJson(data.request))}
> >
<TbCode size={16} />
</Button> </Button>
</Tooltip> </div>
</div> </motion.div>
</div> </CardBody>
</Card>
<div className='flex-1 min-h-0 relative px-3 pb-2 mt-1'> <Card
<div className={clsx( shadow='sm'
'h-full transition-all', className='my-4 relative bg-opacity-50 backdrop-blur-md'
activeTab !== 'request' && 'rounded-xl overflow-y-auto no-scrollbar', >
hasBackground ? 'bg-transparent' : (activeTab !== 'request' && 'bg-white/10 dark:bg-black/10') <PageLoading loading={isFetching} />
)}> <CardHeader className='font-bold text-lg gap-1 pb-0'>
{activeTab === 'request' ? ( <span className='mr-2'></span>
<CodeEditor <Button
value={requestBody} color='warning'
onChange={(value) => setRequestBody(value ?? '')} variant='flat'
language='json' onPress={() => setIsResponseOpen(!isResponseOpen)}
options={{ size='sm'
minimap: { enabled: false }, radius='full'
fontSize: 12,
scrollBeyondLastLine: false,
wordWrap: 'on',
padding: { top: 12 },
lineNumbersMinChars: 3
}}
/>
) : (
<div className='p-6 space-y-10'>
<section>
<h3 className='text-[10px] font-bold opacity-20 uppercase tracking-[0.2em] mb-4'>Request - </h3>
<DisplayStruct schema={parsedRequest} />
</section>
<div className='h-px bg-white/5 w-full' />
<section>
<h3 className='text-[10px] font-bold opacity-20 uppercase tracking-[0.2em] mb-4'>Response - </h3>
<DisplayStruct schema={parsedResponse} />
</section>
</div>
)}
</div>
</div>
</div>
{/* Response Area */}
<div className='flex-shrink-0 px-3 pb-3'>
<div
className={clsx(
'rounded-xl transition-all overflow-hidden border border-white/5 flex flex-col',
hasBackground ? 'bg-white/5' : 'bg-white/5 dark:bg-black/5'
)}
>
{/* Header & Resize Handle */}
<div
className='flex items-center justify-between px-4 py-2 cursor-pointer hover:bg-white/5 transition-all select-none relative group'
onClick={() => setResponseExpanded(!responseExpanded)}
> >
{/* Invisble Resize Area that becomes visible/active */} {isResponseOpen ? '收起' : '展开'}
{responseExpanded && ( </Button>
<div <Button
className="absolute -top-1 left-0 w-full h-3 cursor-ns-resize z-50 flex items-center justify-center opacity-0 hover:opacity-100 group-hover:opacity-100 transition-opacity" color='success'
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e); }} variant='flat'
onTouchStart={(e) => { e.stopPropagation(); handleTouchStart(e); }} onPress={() => {
onClick={(e) => e.stopPropagation()} navigator.clipboard.writeText(responseContent);
> toast.success('响应内容已复制到剪贴板');
<div className="w-12 h-1 bg-white/20 rounded-full" /> }}
</div> size='sm'
)} radius='full'
>
<div className='flex items-center gap-2'>
<IoChevronDown className={clsx('text-[10px] transition-transform duration-300 opacity-20', !responseExpanded && '-rotate-90')} /> </Button>
<span className='text-[10px] font-semibold tracking-wide opacity-30 uppercase'>Response</span> </CardHeader>
</div> <CardBody>
<div className='flex items-center gap-2'> <motion.div
{responseStatus && ( className='overflow-y-auto text-sm'
<Chip size="sm" variant="flat" color={responseStatus.code >= 200 && responseStatus.code < 300 ? 'success' : 'danger'} className="h-4 text-[9px] font-mono px-1.5 opacity-50"> initial={{ opacity: 0, height: 0 }}
{responseStatus.code} animate={{
</Chip> opacity: isResponseOpen ? 1 : 0,
)} height: isResponseOpen ? 300 : 0,
<Button size='sm' variant='light' isIconOnly radius='full' className='h-6 w-6 opacity-20 hover:opacity-80 transition-opacity' onClick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(responseContent); toast.success('已复制'); }}> }}
<IoCopy size={10} /> >
</Button> <pre>
</div> <code>
</div> {responseContent || (
<div className='text-gray-400'></div>
{/* Response Content - Code Editor */} )}
{responseExpanded && ( </code>
<div style={{ height: responseHeight }} className="relative bg-transparent"> </pre>
<PageLoading loading={isFetching} /> </motion.div>
<CodeEditor </CardBody>
value={responseContent || '// Waiting for response...'} </Card>
language='json' <div className='p-2 md:p-4 border border-default-50 dark:border-default-200 rounded-lg backdrop-blur-sm'>
options={{ <h2 className='text-xl font-semibold mb-2'></h2>
minimap: { enabled: false }, <DisplayStruct schema={parsedRequest} />
fontSize: 11, <h2 className='text-xl font-semibold mt-4 mb-2'></h2>
lineNumbers: 'off', <DisplayStruct schema={parsedResponse} />
scrollBeyondLastLine: false,
wordWrap: 'on',
readOnly: true,
folding: true,
padding: { top: 8, bottom: 8 },
renderLineHighlight: 'none',
automaticLayout: true
}}
/>
</div>
)}
</div>
</div> </div>
</section> </section>
); );

View File

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

View File

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

View File

@ -30,14 +30,14 @@ const itemVariants = {
}, },
}; };
function RequestComponent ({ data: _ }: { data: OB11Request; }) { function RequestComponent ({ data: _ }: { data: OB11Request }) {
return <div>Request消息</div>; return <div>Request消息</div>;
} }
export interface OneBotItemRenderProps { export interface OneBotItemRenderProps {
data: AllOB11WsResponse[]; data: AllOB11WsResponse[]
index: number; index: number
style: React.CSSProperties; style: React.CSSProperties
} }
export const getItemSize = (event: OB11AllEvent['post_type']) => { export const getItemSize = (event: OB11AllEvent['post_type']) => {
@ -90,7 +90,7 @@ const OneBotItemRender = ({ data, index, style }: OneBotItemRenderProps) => {
animate='visible' animate='visible'
className='h-full px-2' className='h-full px-2'
> >
<Card className='w-full h-full py-2 bg-white/60 dark:bg-black/40 backdrop-blur-xl border border-white/40 dark:border-white/10 shadow-sm'> <Card className='w-full h-full py-2 bg-opacity-50 backdrop-blur-sm'>
<CardHeader className='py-0 text-default-500 flex-row gap-2'> <CardHeader className='py-0 text-default-500 flex-row gap-2'>
<div className='font-bold'> <div className='font-bold'>
{isEvent ? getEventName(msg.post_type) : '请求响应'} {isEvent ? getEventName(msg.post_type) : '请求响应'}

View File

@ -3,8 +3,8 @@ import { SharedSelection } from '@heroui/system';
import type { Selection } from '@react-types/shared'; import type { Selection } from '@react-types/shared';
export interface FilterMessageTypeProps { export interface FilterMessageTypeProps {
filterTypes: Selection; filterTypes: Selection
onSelectionChange: (keys: SharedSelection) => void; onSelectionChange: (keys: SharedSelection) => void
} }
const items = [ const items = [
{ label: '元事件', value: 'meta_event' }, { label: '元事件', value: 'meta_event' },
@ -26,7 +26,6 @@ const FilterMessageType: React.FC<FilterMessageTypeProps> = (props) => {
}} }}
label='筛选消息类型' label='筛选消息类型'
selectionMode='multiple' selectionMode='multiple'
className='w-full'
items={items} items={items}
renderValue={(value) => { renderValue={(value) => {
if (value.length === items.length) { if (value.length === items.length) {

View File

@ -43,7 +43,7 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
return ( return (
<> <>
<Button onPress={onOpen} color='primary' radius='full' variant='flat' size='sm' className="font-medium"> <Button onPress={onOpen} color='primary' radius='full' variant='flat'>
</Button> </Button>
<Modal <Modal
@ -61,7 +61,7 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<div className='h-96'> <div className='h-96 dark:bg-[rgb(30,30,30)] p-2 rounded-md border border-default-100'>
<CodeEditor <CodeEditor
height='100%' height='100%'
defaultLanguage='json' defaultLanguage='json'

View File

@ -1,18 +1,23 @@
import { Image } from '@heroui/image';
import bkg_color from '@/assets/images/bkg-color.png';
const PageBackground = () => { const PageBackground = () => {
return ( return (
<div className='fixed inset-0 w-full h-full -z-10 overflow-hidden bg-gradient-to-br from-indigo-50 via-white to-pink-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900'> <>
{/* 静态光斑 - ACG风格 */} <div className='fixed w-full h-full -z-[0] flex justify-end opacity-80'>
<div <Image
className='absolute top-[-10%] left-[-10%] w-[500px] h-[500px] rounded-full bg-primary-200/40 blur-[100px]' className='overflow-hidden object-contain -top-42 h-[160%] -right-[30%] -rotate-45 pointer-events-none select-none -z-10 relative'
/> src={bkg_color}
<div />
className='absolute top-[20%] right-[-10%] w-[400px] h-[400px] rounded-full bg-secondary-200/40 blur-[90px]' </div>
/> <div className='fixed w-full h-full overflow-hidden -z-[0] hue-rotate-90 flex justify-start opacity-80'>
<div <Image
className='absolute bottom-[-10%] left-[20%] w-[600px] h-[600px] rounded-full bg-pink-200/30 blur-[110px]' className='relative -top-92 h-[180%] object-contain pointer-events-none rotate-90 select-none -z-10 top-44'
/> src={bkg_color}
</div> />
</div>
</>
); );
}; };

View File

@ -2,13 +2,13 @@ import { Spinner } from '@heroui/spinner';
import clsx from 'clsx'; import clsx from 'clsx';
export interface PageLoadingProps { export interface PageLoadingProps {
loading?: boolean; loading?: boolean
} }
const PageLoading: React.FC<PageLoadingProps> = ({ loading }) => { const PageLoading: React.FC<PageLoadingProps> = ({ loading }) => {
return ( return (
<div <div
className={clsx( className={clsx(
'absolute top-0 left-0 w-full h-full bg-zinc-500 bg-opacity-10 z-30 flex justify-center items-center backdrop-blur', 'absolute top-0 left-0 w-full h-full bg-zinc-500 bg-opacity-10 z-50 flex justify-center items-center backdrop-blur',
{ {
hidden: !loading, hidden: !loading,
} }

View File

@ -1,29 +1,22 @@
import { Card, CardBody } from '@heroui/card'; import { Card, CardBody } from '@heroui/card';
import { Image } from '@heroui/image'; import { Image } from '@heroui/image';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { BsTencentQq } from 'react-icons/bs'; import { BsTencentQq } from 'react-icons/bs';
import key from '@/const/key';
import { SelfInfo } from '@/types/user'; import { SelfInfo } from '@/types/user';
import PageLoading from './page_loading'; import PageLoading from './page_loading';
export interface QQInfoCardProps { export interface QQInfoCardProps {
data?: SelfInfo; data?: SelfInfo
error?: Error; error?: Error
loading?: boolean; loading?: boolean
} }
const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => { const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card <Card
className={clsx( className='relative bg-primary-100 bg-opacity-60 overflow-hidden flex-shrink-0 shadow-md shadow-primary-300 dark:shadow-primary-50'
'relative backdrop-blur-sm border border-white/40 dark:border-white/10 overflow-hidden flex-shrink-0 shadow-sm',
hasBackground ? 'bg-white/10 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
)}
shadow='none' shadow='none'
radius='lg' radius='lg'
> >
@ -38,40 +31,28 @@ const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
</CardBody> </CardBody>
) )
: ( : (
<CardBody className='flex-row items-center gap-4 overflow-hidden relative p-4'> <CardBody className='flex-row items-center gap-2 overflow-hidden relative'>
{!hasBackground && ( <div className='absolute right-0 bottom-0 text-5xl text-primary-400'>
<div className='absolute right-[-10px] bottom-[-10px] text-7xl text-default-400/10 rotate-12 pointer-events-none dark:hidden'> <BsTencentQq />
<BsTencentQq /> </div>
</div>
)}
<div className='relative flex-shrink-0 z-10'> <div className='relative flex-shrink-0 z-10'>
<Image <Image
src={ src={
data?.avatarUrl ?? 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=1`
} }
className='shadow-sm rounded-full w-14 aspect-square ring-2 ring-white/50 dark:ring-white/10' className='shadow-md rounded-full w-12 aspect-square'
/> />
<div <div
className={clsx( className={clsx(
'w-3.5 h-3.5 rounded-full absolute right-0.5 bottom-0.5 border-2 border-white dark:border-zinc-900 z-10', 'w-4 h-4 rounded-full absolute right-0.5 bottom-0 border-2 border-primary-100 z-10',
data?.online ? 'bg-success-500' : 'bg-default-400' data?.online ? 'bg-green-500' : 'bg-gray-500'
)} )}
/> />
</div> </div>
<div className='flex-col justify-center z-10'> <div className='flex-col justify-center'>
<div className={clsx( <div className='text-lg truncate'>{data?.nick}</div>
'text-xl font-bold truncate mb-0.5', <div className='text-primary-500 text-sm'>{data?.uin}</div>
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> </div>
</CardBody> </CardBody>
)} )}

View File

@ -1,33 +1,30 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { useLocalStorage } from '@uidotdev/usehooks'; import { Image } from '@heroui/image';
import clsx from 'clsx'; import clsx from 'clsx';
import { AnimatePresence, motion } from 'motion/react'; import { motion } from 'motion/react';
import React from 'react'; import React from 'react';
import { IoMdLogOut } from 'react-icons/io'; import { IoMdLogOut } from 'react-icons/io';
import { MdDarkMode, MdLightMode } from 'react-icons/md'; import { MdDarkMode, MdLightMode } from 'react-icons/md';
import key from '@/const/key';
import useAuth from '@/hooks/auth'; import useAuth from '@/hooks/auth';
import useDialog from '@/hooks/use-dialog'; import useDialog from '@/hooks/use-dialog';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/hooks/use-theme';
import logo from '@/assets/images/logo.png';
import type { MenuItem } from '@/config/site'; import type { MenuItem } from '@/config/site';
import Menus from './menus'; import Menus from './menus';
interface SideBarProps { interface SideBarProps {
open: boolean; open: boolean
items: MenuItem[]; items: MenuItem[]
onClose?: () => void;
} }
const SideBar: React.FC<SideBarProps> = (props) => { const SideBar: React.FC<SideBarProps> = (props) => {
const { open, items, onClose } = props; const { open, items } = props;
const { toggleTheme, isDark } = useTheme(); const { toggleTheme, isDark } = useTheme();
const { revokeAuth } = useAuth(); const { revokeAuth } = useAuth();
const dialog = useDialog(); const dialog = useDialog();
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const onRevokeAuth = () => { const onRevokeAuth = () => {
dialog.confirm({ dialog.confirm({
title: '退出登录', title: '退出登录',
@ -36,72 +33,60 @@ const SideBar: React.FC<SideBarProps> = (props) => {
}); });
}; };
return ( return (
<> <motion.div
<AnimatePresence initial={false}> className={clsx(
{open && ( 'overflow-hidden fixed top-0 left-0 h-full z-50 bg-background md:bg-transparent md:static shadow-md md:shadow-none rounded-r-md md:rounded-none'
<motion.div )}
className='fixed inset-y-0 left-64 right-0 bg-black/20 backdrop-blur-[1px] z-40 md:hidden' initial={{ width: 0 }}
aria-hidden='true' animate={{ width: open ? '16rem' : 0 }}
onClick={onClose} transition={{
initial={{ opacity: 0 }} type: open ? 'spring' : 'tween',
animate={{ opacity: 1 }} stiffness: 150,
exit={{ opacity: 0, transition: { duration: 0.15 } }} damping: open ? 15 : 10,
transition={{ duration: 0.2, delay: 0.15 }} }}
/> style={{ overflow: 'hidden' }}
)} >
</AnimatePresence> <motion.div className='w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right'>
<motion.div <div className='flex justify-center items-center my-2 gap-2'>
className={clsx( <Image radius='none' height={40} src={logo} className='mb-2' />
'overflow-hidden fixed top-0 left-0 h-full z-50 md:static md:shadow-none rounded-r-2xl md:rounded-none', <div
hasBackground className={clsx(
? 'bg-transparent backdrop-blur-md' 'flex items-center font-bold',
: 'bg-content1/70 backdrop-blur-xl backdrop-saturate-150 shadow-xl', '!text-2xl shiny-text'
'md:bg-transparent md:backdrop-blur-none md:backdrop-saturate-100 md:shadow-none' )}
)} >
initial={{ width: 0 }} NapCat
animate={{ width: open ? '16rem' : 0 }}
transition={{
type: open ? 'spring' : 'tween',
stiffness: 150,
damping: open ? 15 : 10,
}}
style={{ overflow: 'hidden' }}
>
<motion.div className='w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right p-4'>
<div className='flex items-center justify-start gap-3 px-2 my-8 ml-2'>
<div className="h-5 w-1 bg-primary rounded-full shadow-sm" />
<div className="text-xl font-bold text-default-900 dark:text-white tracking-wide select-none">
NapCat
</div>
</div> </div>
<div className='overflow-y-auto flex flex-col flex-1 px-2'> </div>
<Menus items={items} /> <div className='overflow-y-auto flex flex-col flex-1 px-4'>
<div className='mt-auto mb-10 md:mb-0 space-y-3 px-2'> <Menus items={items} />
<Button <div className='mt-auto mb-10 md:mb-0'>
className='w-full bg-primary-50/50 hover:bg-primary-100/80 text-primary-600 font-medium shadow-sm hover:shadow-md transition-all duration-300 backdrop-blur-sm' <Button
radius='full' className='w-full'
variant='flat' color='primary'
onPress={toggleTheme} radius='full'
startContent={ variant='light'
!isDark ? <MdLightMode size={18} /> : <MdDarkMode size={18} /> onPress={toggleTheme}
} startContent={
> !isDark ? <MdLightMode size={16} /> : <MdDarkMode size={16} />
}
</Button> >
<Button
className='w-full mb-2 bg-danger-50/50 hover:bg-danger-100/80 text-danger-500 font-medium shadow-sm hover:shadow-md transition-all duration-300 backdrop-blur-sm' </Button>
radius='full' <Button
variant='flat' className='w-full mb-2'
onPress={onRevokeAuth} color='primary'
startContent={<IoMdLogOut size={18} />} radius='full'
> variant='light'
退 onPress={onRevokeAuth}
</Button> startContent={<IoMdLogOut size={16} />}
</div> >
退
</Button>
</div> </div>
</motion.div> </div>
</motion.div> </motion.div>
</> </motion.div>
); );
}; };

View File

@ -50,13 +50,12 @@ const renderItems = (items: MenuItem[], children = false) => {
<div key={item.href + item.label}> <div key={item.href + item.label}>
<Button <Button
className={clsx( className={clsx(
'flex items-center w-full text-left justify-start dark:text-white transition-all duration-300', 'flex items-center w-full text-left justify-start dark:text-white',
isActive // children && 'rounded-l-lg',
? 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-400 shadow-none font-semibold translate-x-1' isActive && 'bg-opacity-60',
: 'hover:bg-default-100 hover:translate-x-1',
b64img && 'backdrop-blur-md text-white' b64img && 'backdrop-blur-md text-white'
)} )}
color={isActive ? 'primary' : 'default'} color='primary'
endContent={ endContent={
canOpen canOpen
? ( ? (
@ -97,15 +96,15 @@ const renderItems = (items: MenuItem[], children = false) => {
: ( : (
<div <div
className={clsx( className={clsx(
'w-3 h-1.5 rounded-full ml-auto', 'w-3 h-1.5 rounded-full ml-auto shadow-lg',
isActive isActive
? 'bg-primary-500 animate-nav-spin' ? 'bg-primary-500 animate-spinner-ease-spin'
: 'bg-primary-200 dark:bg-white shadow-lg' : 'bg-primary-200 dark:bg-white'
)} )}
aria-hidden="true"
/> />
) )
} }
radius='full'
startContent={ startContent={
customIcons[item.label] customIcons[item.label]
? ( ? (
@ -148,7 +147,7 @@ const renderItems = (items: MenuItem[], children = false) => {
}; };
interface MenusProps { interface MenusProps {
items: MenuItem[]; items: MenuItem[]
} }
const Menus: React.FC<MenusProps> = (props) => { const Menus: React.FC<MenusProps> = (props) => {
const { items } = props; const { items } = props;

View File

@ -3,14 +3,14 @@ import clsx from 'clsx';
import React, { forwardRef } from 'react'; import React, { forwardRef } from 'react';
export interface SwitchCardProps { export interface SwitchCardProps {
label?: string; label?: string
description?: string; description?: string
value?: boolean; value?: boolean
onValueChange?: (value: boolean) => void; onValueChange?: (value: boolean) => void
name?: string; name?: string
onBlur?: React.FocusEventHandler; onBlur?: React.FocusEventHandler
disabled?: boolean; disabled?: boolean
onChange?: React.ChangeEventHandler<HTMLInputElement>; onChange?: React.ChangeEventHandler<HTMLInputElement>
} }
const SwitchCard = forwardRef<HTMLInputElement, SwitchCardProps>( const SwitchCard = forwardRef<HTMLInputElement, SwitchCardProps>(
@ -22,9 +22,9 @@ const SwitchCard = forwardRef<HTMLInputElement, SwitchCardProps>(
<Switch <Switch
classNames={{ classNames={{
base: clsx( base: clsx(
'inline-flex flex-row-reverse w-full max-w-full bg-default-100/50 dark:bg-white/5 hover:bg-default-200/50 dark:hover:bg-white/10 items-center', 'inline-flex flex-row-reverse w-full max-w-md bg-content1 hover:bg-content2 items-center',
'justify-between cursor-pointer rounded-xl gap-2 p-4 border border-transparent transition-all duration-200', 'justify-between cursor-pointer rounded-lg gap-2 p-3 border-2 border-transparent',
'data-[selected=true]:border-primary/50 data-[selected=true]:bg-primary/5 backdrop-blur-md' 'data-[selected=true]:border-primary bg-opacity-50 backdrop-blur-sm'
), ),
}} }}
{...props} {...props}

View File

@ -1,27 +1,30 @@
import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card'; import { Card, CardBody, CardHeader } from '@heroui/card';
import { Chip } from '@heroui/chip'; import { Chip } from '@heroui/chip';
import { Spinner } from '@heroui/spinner'; import { Spinner } from '@heroui/spinner';
import { Tooltip } from '@heroui/tooltip'; import { Tooltip } from '@heroui/tooltip';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import clsx from 'clsx'; import { useEffect } from 'react';
import { FaCircleInfo, FaQq } from 'react-icons/fa6'; import { BsStars } from 'react-icons/bs';
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6';
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io'; import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io';
import { RiMacFill } from 'react-icons/ri'; import { RiMacFill } from 'react-icons/ri';
import { useState } from 'react';
import key from '@/const/key';
import WebUIManager from '@/controllers/webui_manager';
import useDialog from '@/hooks/use-dialog'; import useDialog from '@/hooks/use-dialog';
import { request } from '@/utils/request';
import { compareVersion } from '@/utils/version';
import WebUIManager from '@/controllers/webui_manager';
import { GithubRelease } from '@/types/github';
import TailwindMarkdown from './tailwind_markdown';
export interface SystemInfoItemProps { export interface SystemInfoItemProps {
title: string; title: string
icon?: React.ReactNode; icon?: React.ReactNode
value?: React.ReactNode; value?: React.ReactNode
endContent?: React.ReactNode; endContent?: React.ReactNode
hasBackground?: boolean;
} }
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
@ -29,371 +32,176 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
value = '--', value = '--',
icon, icon,
endContent, endContent,
hasBackground = false,
}) => { }) => {
return ( return (
<div className={clsx( <div className='flex text-sm gap-1 p-2 items-center shadow-sm shadow-primary-100 dark:shadow-primary-100 rounded text-primary-400'>
'flex text-sm gap-3 py-2 items-center transition-colors', {icon}
hasBackground <div className='w-24'>{title}</div>
? 'text-white/90' <div className='text-primary-200'>{value}</div>
: 'text-default-600 dark:text-gray-300' <div className='ml-auto'>{endContent}</div>
)}>
<div className="text-lg opacity-70">{icon}</div>
<div className='w-24 font-medium'>{title}</div>
<div className={clsx(
'text-xs font-mono flex-1',
hasBackground ? 'text-white/80' : 'text-default-500'
)}>{value}</div>
<div>{endContent}</div>
</div> </div>
); );
}; };
export interface NewVersionTipProps { export interface NewVersionTipProps {
currentVersion?: string; currentVersion?: string
} }
// const NewVersionTip = (props: NewVersionTipProps) => {
// const { currentVersion } = props;
// const dialog = useDialog();
// const { data: releaseData, error } = useRequest(() =>
// request.get<GithubRelease[]>(
// 'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
// )
// );
// if (error) {
// return (
// <Tooltip content='检查新版本失败'>
// <Button
// isIconOnly
// radius='full'
// color='primary'
// variant='shadow'
// className='!w-5 !h-5 !min-w-0 text-small shadow-md'
// onPress={() => {
// dialog.alert({
// title: '检查新版本失败',
// content: error.message,
// });
// }}
// >
// <FaInfo />
// </Button>
// </Tooltip>
// );
// }
// const latestVersion = releaseData?.data?.[0]?.tag_name;
// if (!latestVersion || !currentVersion) {
// return null;
// }
// if (compareVersion(latestVersion, currentVersion) <= 0) {
// return null;
// }
// const middleVersions: GithubRelease[] = [];
// for (let i = 0; i < releaseData.data.length; i++) {
// const versionInfo = releaseData.data[i];
// if (compareVersion(versionInfo.tag_name, currentVersion) > 0) {
// middleVersions.push(versionInfo);
// } else {
// break;
// }
// }
// const AISummaryComponent = () => {
// const {
// data: aiSummaryData,
// loading: aiSummaryLoading,
// error: aiSummaryError,
// run: runAiSummary,
// } = useRequest(
// (version) =>
// request.get<ServerResponse<string | null>>(
// `https://release.nc.152710.xyz/?version=${version}`,
// {
// timeout: 30000,
// }
// ),
// {
// manual: true,
// }
// );
// useEffect(() => {
// runAiSummary(currentVersion);
// }, [currentVersion, runAiSummary]);
// if (aiSummaryLoading) {
// return (
// <div className='flex justify-center py-1'>
// <Spinner size='sm' />
// </div>
// );
// }
// if (aiSummaryError) {
// return <div className='text-center text-primary-500'>AI 摘要获取失败</div>;
// }
// return <span className='text-default-700'>{aiSummaryData?.data.data}</span>;
// };
// return (
// <Tooltip content='有新版本可用'>
// <Button
// isIconOnly
// radius='full'
// color='primary'
// variant='shadow'
// className='!w-5 !h-5 !min-w-0 text-small shadow-md'
// onPress={() => {
// dialog.confirm({
// title: '有新版本可用',
// content: (
// <div className='space-y-2'>
// <div className='text-sm space-x-2'>
// <span>当前版本</span>
// <Chip color='primary' variant='flat'>
// v{currentVersion}
// </Chip>
// </div>
// <div className='text-sm space-x-2'>
// <span>最新版本</span>
// <Chip color='primary'>{latestVersion}</Chip>
// </div>
// <div className='p-2 rounded-md bg-content2 text-sm'>
// <div className='text-primary-400 font-bold flex items-center gap-1 mb-1'>
// <BsStars />
// <span>AI总结</span>
// </div>
// <AISummaryComponent />
// </div>
// <div className='text-sm space-y-2 !mt-4'>
// {middleVersions.map((versionInfo) => (
// <div
// key={versionInfo.tag_name}
// className='p-4 bg-content1 rounded-md shadow-small'
// >
// <TailwindMarkdown content={versionInfo.body} />
// </div>
// ))}
// </div>
// </div>
// ),
// scrollBehavior: 'inside',
// size: '3xl',
// confirmText: '前往下载',
// onConfirm () {
// window.open(
// 'https://github.com/NapNeko/NapCatQQ/releases',
// '_blank',
// 'noopener'
// );
// },
// });
// }}
// >
// <FaInfo />
// </Button>
// </Tooltip>
// );
// };
// 更新状态类型
type UpdateStatus = 'idle' | 'updating' | 'success' | 'error';
// 更新对话框内容组件
const UpdateDialogContent: React.FC<{
currentVersion: string;
latestVersion: string;
status: UpdateStatus;
errorMessage?: string;
}> = ({ currentVersion, latestVersion, status, errorMessage }) => {
return (
<div className='space-y-4'>
{/* 版本信息 */}
<div className='space-y-2'>
<div className='text-sm space-x-2'>
<span></span>
<Chip color='primary' variant='flat'>
v{currentVersion}
</Chip>
</div>
<div className='text-sm space-x-2'>
<span></span>
<Chip color='primary'>v{latestVersion}</Chip>
</div>
</div>
{/* 更新状态显示 */}
{status === 'updating' && (
<div className='flex flex-col items-center justify-center gap-3 py-4 px-4 rounded-lg bg-primary-50/50 dark:bg-primary-900/20 border border-primary-200/50 dark:border-primary-700/30'>
<Spinner size='md' color='primary' />
<div className='text-center'>
<p className='text-sm font-medium text-primary-600 dark:text-primary-400'>
...
</p>
<p className='text-xs text-default-500 mt-1'>
</p>
</div>
</div>
)}
{status === 'success' && (
<div className='flex flex-col items-center justify-center gap-3 py-4 px-4 rounded-lg bg-success-50/50 dark:bg-success-900/20 border border-success-200/50 dark:border-success-700/30'>
<div className='w-12 h-12 rounded-full bg-success-100 dark:bg-success-900/40 flex items-center justify-center'>
<svg className='w-6 h-6 text-success-600 dark:text-success-400' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
</svg>
</div>
<div className='text-center'>
<p className='text-sm font-medium text-success-600 dark:text-success-400'>
</p>
<p className='text-xs text-default-500 mt-1'>
NapCat
</p>
</div>
<div className='mt-2 p-3 rounded-lg bg-warning-50/50 dark:bg-warning-900/20 border border-warning-200/50 dark:border-warning-700/30'>
<p className='text-xs text-warning-700 dark:text-warning-400 flex items-center gap-1'>
<svg className='w-4 h-4' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' />
</svg>
<span> NapCat</span>
</p>
</div>
</div>
)}
{status === 'error' && (
<div className='flex flex-col items-center justify-center gap-3 py-4 px-4 rounded-lg bg-danger-50/50 dark:bg-danger-900/20 border border-danger-200/50 dark:border-danger-700/30'>
<div className='w-12 h-12 rounded-full bg-danger-100 dark:bg-danger-900/40 flex items-center justify-center'>
<svg className='w-6 h-6 text-danger-600 dark:text-danger-400' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</div>
<div className='text-center'>
<p className='text-sm font-medium text-danger-600 dark:text-danger-400'>
</p>
<p className='text-xs text-default-500 mt-1'>
{errorMessage || '请稍后重试或手动更新'}
</p>
</div>
</div>
)}
</div>
);
};
const NewVersionTip = (props: NewVersionTipProps) => { const NewVersionTip = (props: NewVersionTipProps) => {
const { currentVersion } = props; const { currentVersion } = props;
const dialog = useDialog(); const dialog = useDialog();
const { data: latestVersion, error } = useRequest(WebUIManager.getLatestTag, { const { data: releaseData, error } = useRequest(() =>
cacheKey: 'napcat-latest-tag', request.get<GithubRelease[]>(
staleTime: 10 * 60 * 1000, 'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
cacheTime: 30 * 60 * 1000, )
}); );
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
if (error || !latestVersion || !currentVersion || latestVersion === currentVersion) { if (error) {
return (
<Tooltip content='检查新版本失败'>
<Button
isIconOnly
radius='full'
color='primary'
variant='shadow'
className='!w-5 !h-5 !min-w-0 text-small shadow-md'
onPress={() => {
dialog.alert({
title: '检查新版本失败',
content: error.message,
});
}}
>
<FaInfo />
</Button>
</Tooltip>
);
}
const latestVersion = releaseData?.data?.[0]?.tag_name;
if (!latestVersion || !currentVersion) {
return null; return null;
} }
const handleUpdate = async () => { if (compareVersion(latestVersion, currentVersion) <= 0) {
setUpdateStatus('updating'); return null;
}
try { const middleVersions: GithubRelease[] = [];
await WebUIManager.UpdateNapCat();
setUpdateStatus('success'); for (let i = 0; i < releaseData.data.length; i++) {
// 显示更新成功对话框 const versionInfo = releaseData.data[i];
dialog.alert({ if (compareVersion(versionInfo.tag_name, currentVersion) > 0) {
title: '更新完成', middleVersions.push(versionInfo);
content: ( } else {
<UpdateDialogContent break;
currentVersion={currentVersion}
latestVersion={latestVersion}
status='success'
/>
),
confirmText: '我知道了',
size: 'md',
});
} catch (err) {
console.error('Update failed:', err);
const errMessage = err instanceof Error ? err.message : '未知错误';
setUpdateStatus('error');
// 显示更新失败对话框
dialog.alert({
title: '更新失败',
content: (
<UpdateDialogContent
currentVersion={currentVersion}
latestVersion={latestVersion}
status='error'
errorMessage={errMessage}
/>
),
confirmText: '确定',
size: 'md',
});
} }
}; }
const showUpdateDialog = () => { const AISummaryComponent = () => {
dialog.confirm({ const {
title: '发现新版本', data: aiSummaryData,
content: ( loading: aiSummaryLoading,
<UpdateDialogContent error: aiSummaryError,
currentVersion={currentVersion} run: runAiSummary,
latestVersion={latestVersion} } = useRequest(
status='idle' (version) =>
/> request.get<ServerResponse<string | null>>(
), `https://release.nc.152710.xyz/?version=${version}`,
confirmText: '立即更新', {
cancelText: '稍后更新', timeout: 30000,
size: 'md', }
onConfirm: handleUpdate, ),
}); {
manual: true,
}
);
useEffect(() => {
runAiSummary(currentVersion);
}, [currentVersion, runAiSummary]);
if (aiSummaryLoading) {
return (
<div className='flex justify-center py-1'>
<Spinner size='sm' />
</div>
);
}
if (aiSummaryError) {
return <div className='text-center text-primary-500'>AI </div>;
}
return <span className='text-default-700'>{aiSummaryData?.data.data}</span>;
}; };
return ( return (
<Tooltip content='有新版本可用'> <Tooltip content='有新版本可用'>
<div className="cursor-pointer" onClick={updateStatus === 'updating' ? undefined : showUpdateDialog}> <Button
<Chip isIconOnly
size="sm" radius='full'
color="danger" color='primary'
variant="flat" variant='shadow'
classNames={{ className='!w-5 !h-5 !min-w-0 text-small shadow-md'
content: "font-bold text-[10px] px-1", onPress={() => {
base: "h-5 min-h-5" dialog.confirm({
}} title: '有新版本可用',
> content: (
{updateStatus === 'updating' ? <Spinner size="sm" color="danger" /> : 'New'} <div className='space-y-2'>
</Chip> <div className='text-sm space-x-2'>
</div> <span></span>
<Chip color='primary' variant='flat'>
v{currentVersion}
</Chip>
</div>
<div className='text-sm space-x-2'>
<span></span>
<Chip color='primary'>{latestVersion}</Chip>
</div>
<div className='p-2 rounded-md bg-content2 text-sm'>
<div className='text-primary-400 font-bold flex items-center gap-1 mb-1'>
<BsStars />
<span>AI总结</span>
</div>
<AISummaryComponent />
</div>
<div className='text-sm space-y-2 !mt-4'>
{middleVersions.map((versionInfo) => (
<div
key={versionInfo.tag_name}
className='p-4 bg-content1 rounded-md shadow-small'
>
<TailwindMarkdown content={versionInfo.body} />
</div>
))}
</div>
</div>
),
scrollBehavior: 'inside',
size: '3xl',
confirmText: '前往下载',
onConfirm () {
window.open(
'https://github.com/NapNeko/NapCatQQ/releases',
'_blank',
'noopener'
);
},
});
}}
>
<FaInfo />
</Button>
</Tooltip> </Tooltip>
); );
}; };
interface NapCatVersionProps { const NapCatVersion = () => {
hasBackground?: boolean;
}
const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false }) => {
const { const {
data: packageData, data: packageData,
loading: packageLoading, loading: packageLoading,
error: packageError, error: packageError,
} = useRequest(WebUIManager.GetNapCatVersion, { } = useRequest(WebUIManager.GetNapCatVersion);
cacheKey: 'napcat-version',
staleTime: 60 * 60 * 1000,
cacheTime: 24 * 60 * 60 * 1000,
});
const currentVersion = packageData?.version; const currentVersion = packageData?.version;
@ -401,11 +209,10 @@ const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false })
<SystemInfoItem <SystemInfoItem
title='NapCat 版本' title='NapCat 版本'
icon={<IoLogoOctocat className='text-xl' />} icon={<IoLogoOctocat className='text-xl' />}
hasBackground={hasBackground}
value={ value={
packageError packageError
? ( ? (
`错误:${packageError.message}` `错误:${packageError.message}`
) )
: packageLoading : packageLoading
? ( ? (
@ -421,7 +228,7 @@ const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false })
}; };
export interface SystemInfoProps { export interface SystemInfoProps {
archInfo?: string; archInfo?: string
} }
const SystemInfo: React.FC<SystemInfoProps> = (props) => { const SystemInfo: React.FC<SystemInfoProps> = (props) => {
const { archInfo } = props; const { archInfo } = props;
@ -429,37 +236,23 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
data: qqVersionData, data: qqVersionData,
loading: qqVersionLoading, loading: qqVersionLoading,
error: qqVersionError, error: qqVersionError,
} = useRequest(WebUIManager.getQQVersion, { } = useRequest(WebUIManager.getQQVersion);
cacheKey: 'qq-version',
staleTime: 60 * 60 * 1000,
cacheTime: 24 * 60 * 60 * 1000,
});
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card className={clsx( <Card className='bg-opacity-60 shadow-sm shadow-primary-100 dark:shadow-primary-100 overflow-visible flex-1'>
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm overflow-visible flex-1', <CardHeader className='pb-0 items-center gap-1 text-primary-500 font-extrabold'>
hasBackground ? 'bg-white/10 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40' <FaCircleInfo className='text-lg' />
)}>
<CardHeader className={clsx(
'pb-0 items-center gap-2 font-bold px-4 pt-4',
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-white'
)}>
<FaCircleInfo className='text-lg opacity-80' />
<span></span> <span></span>
</CardHeader> </CardHeader>
<CardBody className='flex-1'> <CardBody className='flex-1'>
<div className='flex flex-col gap-2 justify-between h-full'> <div className='flex flex-col justify-between h-full'>
<NapCatVersion hasBackground={hasBackground} /> <NapCatVersion />
<SystemInfoItem <SystemInfoItem
title='QQ 版本' title='QQ 版本'
icon={<FaQq className='text-lg' />} icon={<FaQq className='text-lg' />}
hasBackground={hasBackground}
value={ value={
qqVersionError qqVersionError
? ( ? (
`错误:${qqVersionError.message}` `错误:${qqVersionError.message}`
) )
: qqVersionLoading : qqVersionLoading
? ( ? (
@ -474,13 +267,11 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
title='WebUI 版本' title='WebUI 版本'
icon={<IoLogoChrome className='text-xl' />} icon={<IoLogoChrome className='text-xl' />}
value='Next' value='Next'
hasBackground={hasBackground}
/> />
<SystemInfoItem <SystemInfoItem
title='系统版本' title='系统版本'
icon={<RiMacFill className='text-xl' />} icon={<RiMacFill className='text-xl' />}
value={archInfo} value={archInfo}
hasBackground={hasBackground}
/> />
</div> </div>
</CardBody> </CardBody>

View File

@ -1,21 +1,18 @@
import { Card, CardBody } from '@heroui/card'; import { Card, CardBody } from '@heroui/card';
import { Image } from '@heroui/image'; import { Image } from '@heroui/image';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { BiSolidMemoryCard } from 'react-icons/bi'; import { BiSolidMemoryCard } from 'react-icons/bi';
import { GiCpu } from 'react-icons/gi'; import { GiCpu } from 'react-icons/gi';
import bkg from '@/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png'; import bkg from '@/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png';
import key from '@/const/key';
import UsagePie from './usage_pie'; import UsagePie from './usage_pie';
export interface SystemStatusItemProps { export interface SystemStatusItemProps {
title: string; title: string
value?: string | number; value?: string | number
size?: 'md' | 'lg'; size?: 'md' | 'lg'
unit?: string; unit?: string
hasBackground?: boolean;
} }
const SystemStatusItem: React.FC<SystemStatusItemProps> = ({ const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
@ -23,32 +20,25 @@ const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
value = '-', value = '-',
size = 'md', size = 'md',
unit, unit,
hasBackground = false,
}) => { }) => {
return ( return (
<div <div
className={clsx( className={clsx(
'py-1.5 text-sm transition-colors', 'shadow-sm shadow-primary-100 p-2 rounded-md text-sm bg-content1 bg-opacity-30',
size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between', size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between'
)} )}
> >
<div className={clsx( <div className='w-24'>{title}</div>
'w-24 font-medium', <div className='text-default-400'>
hasBackground ? 'text-white/90' : 'text-default-600 dark:text-gray-300'
)}>{title}</div>
<div className={clsx(
'font-mono text-xs',
hasBackground ? 'text-white/80' : 'text-default-500'
)}>
{value} {value}
{unit && <span className="ml-0.5 opacity-70">{unit}</span>} {unit}
</div> </div>
</div> </div>
); );
}; };
export interface SystemStatusDisplayProps { export interface SystemStatusDisplayProps {
data?: SystemStatus; data?: SystemStatus
} }
const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => { const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
@ -63,14 +53,9 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
memoryUsage.system = (systemUsage / system) * 100; memoryUsage.system = (systemUsage / system) * 100;
memoryUsage.qq = (qqUsage / system) * 100; memoryUsage.qq = (qqUsage / system) * 100;
} }
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return ( return (
<Card className={clsx( <Card className='bg-opacity-60 shadow-sm shadow-primary-100 col-span-1 lg:col-span-2 relative overflow-hidden'>
'backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm col-span-1 lg:col-span-2 relative overflow-hidden',
hasBackground ? 'bg-white/10 dark:bg-black/10' : 'bg-white/60 dark:bg-black/40'
)}>
<div className='absolute h-full right-0 top-0'> <div className='absolute h-full right-0 top-0'>
<Image <Image
src={bkg} src={bkg}
@ -84,35 +69,27 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
</div> </div>
<CardBody className='overflow-visible md:flex-row gap-4 items-center justify-stretch z-10'> <CardBody className='overflow-visible md:flex-row gap-4 items-center justify-stretch z-10'>
<div className='flex-1 w-full md:max-w-96'> <div className='flex-1 w-full md:max-w-96'>
<h2 className={clsx( <h2 className='text-lg font-semibold flex items-center gap-1 text-primary-400'>
'text-lg font-semibold flex items-center gap-2 mb-2', <GiCpu className='text-xl' />
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
)}>
<GiCpu className='text-xl opacity-80' />
<span>CPU</span> <span>CPU</span>
</h2> </h2>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
<SystemStatusItem title='型号' value={data?.cpu.model} size='lg' hasBackground={hasBackground} /> <SystemStatusItem title='型号' value={data?.cpu.model} size='lg' />
<SystemStatusItem title='内核数' value={data?.cpu.core} hasBackground={hasBackground} /> <SystemStatusItem title='内核数' value={data?.cpu.core} />
<SystemStatusItem title='主频' value={data?.cpu.speed} unit='GHz' hasBackground={hasBackground} /> <SystemStatusItem title='主频' value={data?.cpu.speed} unit='GHz' />
<SystemStatusItem <SystemStatusItem
title='使用率' title='使用率'
value={data?.cpu.usage.system} value={data?.cpu.usage.system}
unit='%' unit='%'
hasBackground={hasBackground}
/> />
<SystemStatusItem <SystemStatusItem
title='QQ主线程' title='QQ主线程'
value={data?.cpu.usage.qq} value={data?.cpu.usage.qq}
unit='%' unit='%'
hasBackground={hasBackground}
/> />
</div> </div>
<h2 className={clsx( <h2 className='text-lg font-semibold flex items-center gap-1 text-primary-400 mt-2'>
'text-lg font-semibold flex items-center gap-2 mb-2 mt-4', <BiSolidMemoryCard className='text-xl' />
hasBackground ? 'text-white drop-shadow-sm' : 'text-default-700 dark:text-gray-200'
)}>
<BiSolidMemoryCard className='text-xl opacity-80' />
<span></span> <span></span>
</h2> </h2>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
@ -121,19 +98,16 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
value={data?.memory.total} value={data?.memory.total}
size='lg' size='lg'
unit='MB' unit='MB'
hasBackground={hasBackground}
/> />
<SystemStatusItem <SystemStatusItem
title='使用量' title='使用量'
value={data?.memory.usage.system} value={data?.memory.usage.system}
unit='MB' unit='MB'
hasBackground={hasBackground}
/> />
<SystemStatusItem <SystemStatusItem
title='QQ主线程' title='QQ主线程'
value={data?.memory.usage.qq} value={data?.memory.usage.qq}
unit='MB' unit='MB'
hasBackground={hasBackground}
/> />
</div> </div>
</div> </div>
@ -142,13 +116,11 @@ const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
systemUsage={Number(data?.cpu.usage.system) || 0} systemUsage={Number(data?.cpu.usage.system) || 0}
processUsage={Number(data?.cpu.usage.qq) || 0} processUsage={Number(data?.cpu.usage.qq) || 0}
title='CPU占用' title='CPU占用'
hasBackground={hasBackground}
/> />
<UsagePie <UsagePie
systemUsage={memoryUsage.system} systemUsage={memoryUsage.system}
processUsage={memoryUsage.qq} processUsage={memoryUsage.qq}
title='内存占用' title='内存占用'
hasBackground={hasBackground}
/> />
</div> </div>
</CardBody> </CardBody>

View File

@ -1,121 +1,143 @@
import React, { useMemo } from 'react'; import * as echarts from 'echarts';
import clsx from 'clsx'; import React, { useEffect, useRef } from 'react';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/hooks/use-theme';
interface UsagePieProps { interface UsagePieProps {
systemUsage: number; systemUsage: number
processUsage: number; processUsage: number
title?: string; title?: string
hasBackground?: boolean;
} }
const defaultOption: echarts.EChartsOption = {
tooltip: {
trigger: 'item',
formatter: '<center>{b}<br/><b>{d}%</b></center>',
borderRadius: 10,
extraCssText: 'backdrop-filter: blur(10px);',
},
series: [
{
name: '系统占用',
type: 'pie',
radius: ['70%', '90%'],
avoidLabelOverlap: false,
label: {
show: true,
position: 'center',
formatter: '系统占用',
fontSize: 14,
},
itemStyle: {
borderWidth: 1,
borderRadius: 10,
},
labelLine: {
show: false,
},
data: [
{
value: 100,
name: '系统总量',
},
],
},
],
};
const UsagePie: React.FC<UsagePieProps> = ({ const UsagePie: React.FC<UsagePieProps> = ({
systemUsage, systemUsage,
processUsage, processUsage,
title, title,
hasBackground,
}) => { }) => {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const { theme } = useTheme(); const { theme } = useTheme();
// Ensure values are clean useEffect(() => {
const cleanSystem = Math.min(Math.max(systemUsage || 0, 0), 100); if (chartRef.current) {
const cleanProcess = Math.min(Math.max(processUsage || 0, 0), cleanSystem); chartInstance.current = echarts.init(chartRef.current);
const option = defaultOption;
chartInstance.current.setOption(option);
const observer = new ResizeObserver(() => {
chartInstance.current?.resize();
});
observer.observe(chartRef.current);
return () => {
chartInstance.current?.dispose();
observer.disconnect();
};
}
}, []);
// SVG Config useEffect(() => {
const size = 100; if (chartInstance.current) {
const strokeWidth = 10; chartInstance.current.setOption({
const radius = (size - strokeWidth) / 2; series: [
const circumference = 2 * Math.PI * radius; {
const center = size / 2; label: {
formatter: title,
},
},
],
});
}
}, [title]);
// Colors useEffect(() => {
const colors = { if (chartInstance.current) {
qq: '#D33FF0', chartInstance.current.setOption({
other: theme === 'dark' ? '#EF8664' : '#EA7D9B', darkMode: theme === 'dark',
track: theme === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)', tooltip: {
}; backgroundColor:
theme === 'dark'
? 'rgba(0, 0, 0, 0.8)'
: 'rgba(255, 255, 255, 0.8)',
textStyle: {
color: theme === 'dark' ? '#fff' : '#333',
},
},
color:
theme === 'dark'
? ['#D33FF0', '#EF8664', '#E25180']
: ['#D33FF0', '#EA7D9B', '#FFC107'],
series: [
{
itemStyle: {
borderColor: theme === 'dark' ? '#333' : '#F0A9A7',
},
},
],
});
}
}, [theme]);
// Dash Arrays useEffect(() => {
// 1. Total System Usage (QQ + Others) if (chartInstance.current) {
const systemDash = useMemo(() => { chartInstance.current.setOption({
return `${(cleanSystem / 100) * circumference} ${circumference}`; series: [
}, [cleanSystem, circumference]); {
data: [
{
value: processUsage,
name: 'QQ占用',
},
{
value: systemUsage - processUsage,
name: '其他进程占用',
},
{
value: 100 - systemUsage,
name: '剩余系统总量',
},
],
},
],
});
}
}, [systemUsage, processUsage]);
// 2. QQ Usage (Subset of System) return <div ref={chartRef} className='w-36 h-36 flex-shrink-0' />;
const processDash = useMemo(() => {
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"
/>
{/* 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>
);
}; };
export default UsagePie; export default UsagePie;

View File

@ -12,21 +12,21 @@ import { useTheme } from '@/hooks/use-theme';
export type XTermRef = { export type XTermRef = {
write: ( write: (
...args: Parameters<Terminal['write']> ...args: Parameters<Terminal['write']>
) => ReturnType<Terminal['write']>; ) => ReturnType<Terminal['write']>
writeAsync: (data: Parameters<Terminal['write']>[0]) => Promise<void>; writeAsync: (data: Parameters<Terminal['write']>[0]) => Promise<void>
writeln: ( writeln: (
...args: Parameters<Terminal['writeln']> ...args: Parameters<Terminal['writeln']>
) => ReturnType<Terminal['writeln']>; ) => ReturnType<Terminal['writeln']>
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>; writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
clear: () => void; clear: () => void
terminalRef: React.RefObject<Terminal | null>; terminalRef: React.RefObject<Terminal | null>
}; };
export interface XTermProps export interface XTermProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput' | 'onResize'> { extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput' | 'onResize'> {
onInput?: (data: string) => void; onInput?: (data: string) => void
onKey?: (key: string, event: KeyboardEvent) => void; onKey?: (key: string, event: KeyboardEvent) => void
onResize?: (cols: number, rows: number) => void; // 新增属性 onResize?: (cols: number, rows: number) => void // 新增属性
} }
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => { const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
@ -35,27 +35,13 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
const { className, onInput, onKey, onResize, ...rest } = props; const { className, onInput, onKey, onResize, ...rest } = props;
const { theme } = useTheme(); const { theme } = useTheme();
useEffect(() => { useEffect(() => {
// 根据屏幕宽度决定字体大小,手机端使用更小的字体
const width = window.innerWidth;
// 按屏幕宽度自适应字体大小
let fontSize = 16;
if (width < 400) {
fontSize = 4;
} else if (width < 600) {
fontSize = 5;
} else if (width < 900) {
fontSize = 6;
} else if (width < 1280) {
fontSize = 12;
} // ≥1280: 16
const terminal = new Terminal({ const terminal = new Terminal({
allowTransparency: true, allowTransparency: true,
fontFamily: fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", "JetBrains Mono", monospace', '"JetBrains Mono", "Aa偷吃可爱长大的", "Noto Serif SC", monospace',
cursorInactiveStyle: 'outline', cursorInactiveStyle: 'outline',
drawBoldTextInBrightColors: false, drawBoldTextInBrightColors: false,
fontSize: fontSize, fontSize: 14,
lineHeight: 1.2, lineHeight: 1.2,
}); });
terminalRef.current = terminal; terminalRef.current = terminal;
@ -70,7 +56,6 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
terminal.loadAddon(fitAddon); terminal.loadAddon(fitAddon);
terminal.open(domRef.current!); terminal.open(domRef.current!);
// 所有端都使用 Canvas 渲染器(包括手机端)
terminal.loadAddon(new CanvasAddon()); terminal.loadAddon(new CanvasAddon());
terminal.onData((data) => { terminal.onData((data) => {
if (onInput) { if (onInput) {

View File

@ -1,72 +1,107 @@
import { import {
LuActivity, BugIcon2,
LuFileText, FileIcon,
LuFolderOpen, InfoIcon,
LuInfo, LogIcon,
LuLayoutDashboard, RouteIcon,
LuSettings, SettingsIcon,
LuSignal, SignalTowerIcon,
LuTerminal, TerminalIcon,
LuZap, } from '@/components/icons';
} from 'react-icons/lu';
export type SiteConfig = typeof siteConfig; export type SiteConfig = typeof siteConfig;
export interface MenuItem { export interface MenuItem {
label: string; label: string
icon?: React.ReactNode; icon?: React.ReactNode
autoOpen?: boolean; autoOpen?: boolean
href?: string; href?: string
items?: MenuItem[]; items?: MenuItem[]
customIcon?: string; customIcon?: string
} }
export const siteConfig = { export const siteConfig = {
name: 'NapCat', name: 'NapCat WebUI',
description: 'NapCat WebUI.', description: 'NapCat WebUI.',
navItems: [ navItems: [
{ {
label: '基础信息', label: '基础信息',
icon: <LuLayoutDashboard className='w-5 h-5' />, icon: (
<div className='w-5 h-5'>
<RouteIcon />
</div>
),
href: '/', href: '/',
}, },
{ {
label: '网络配置', label: '网络配置',
icon: <LuSignal className='w-5 h-5' />, icon: (
<div className='w-5 h-5'>
<SignalTowerIcon />
</div>
),
href: '/network', href: '/network',
}, },
{ {
label: '其他配置', label: '其他配置',
icon: <LuSettings className='w-5 h-5' />, icon: (
<div className='w-5 h-5'>
<SettingsIcon />
</div>
),
href: '/config', href: '/config',
}, },
{ {
label: '猫猫日志', label: '猫猫日志',
icon: <LuFileText className='w-5 h-5' />, icon: (
<div className='w-5 h-5'>
<LogIcon />
</div>
),
href: '/logs', href: '/logs',
}, },
{ {
label: '接口调试', label: '接口调试',
icon: <LuActivity className='w-5 h-5' />, icon: (
href: '/debug/http', <div className='w-5 h-5'>
}, <BugIcon2 />
{ </div>
label: '实时调试', ),
icon: <LuZap className='w-5 h-5' />, items: [
href: '/debug/ws', {
label: 'HTTP',
href: '/debug/http',
},
{
label: 'Websocket',
href: '/debug/ws',
},
],
}, },
{ {
label: '文件管理', label: '文件管理',
icon: <LuFolderOpen className='w-5 h-5' />, icon: (
<div className='w-5 h-5'>
<FileIcon />
</div>
),
href: '/file_manager', href: '/file_manager',
}, },
{ {
label: '系统终端', label: '系统终端',
icon: <LuTerminal className='w-5 h-5' />, icon: (
<div className='w-5 h-5'>
<TerminalIcon />
</div>
),
href: '/terminal', href: '/terminal',
}, },
{ {
label: '关于我们', label: '关于我们',
icon: <LuInfo className='w-5 h-5' />, icon: (
<div className='w-5 h-5'>
<InfoIcon />
</div>
),
href: '/about', href: '/about',
}, },
] as MenuItem[], ] as MenuItem[],

View File

@ -141,7 +141,7 @@ const oneBotHttpApiMessage = {
group_id: z.union([z.string(), z.number()]).describe('群号'), group_id: z.union([z.string(), z.number()]).describe('群号'),
message_seq: z.union([z.string(), z.number()]).describe('消息序号'), message_seq: z.union([z.string(), z.number()]).describe('消息序号'),
count: z.number().int().positive().describe('获取数量'), count: z.number().int().positive().describe('获取数量'),
reverse_order: z.boolean().describe('是否倒序'), reverseOrder: z.boolean().describe('是否倒序'),
}), }),
response: baseResponseSchema.extend({ response: baseResponseSchema.extend({
data: z.object({ data: z.object({
@ -166,7 +166,7 @@ const oneBotHttpApiMessage = {
user_id: z.union([z.string(), z.number()]).describe('用户QQ号'), user_id: z.union([z.string(), z.number()]).describe('用户QQ号'),
message_seq: z.union([z.string(), z.number()]).describe('消息序号'), message_seq: z.union([z.string(), z.number()]).describe('消息序号'),
count: z.number().int().positive().describe('获取数量'), count: z.number().int().positive().describe('获取数量'),
reverse_order: z.boolean().describe('是否倒序'), reverseOrder: z.boolean().describe('是否倒序'),
}), }),
response: baseResponseSchema.extend({ response: baseResponseSchema.extend({
data: z.object({ data: z.object({

View File

@ -15,7 +15,7 @@ const oneBotHttpApiUser = {
data: commonResponseDataSchema, data: commonResponseDataSchema,
}), }),
}, },
'/send_ark_share': { '/ArkSharePeer': {
description: '获取推荐好友/群聊卡片', description: '获取推荐好友/群聊卡片',
request: z request: z
.object({ .object({
@ -27,7 +27,7 @@ const oneBotHttpApiUser = {
.union([z.string(), z.number()]) .union([z.string(), z.number()])
.optional() .optional()
.describe('用户ID与 group_id 二选一'), .describe('用户ID与 group_id 二选一'),
phone_number: z.string().optional().describe('对方手机号码'), phoneNumber: z.string().optional().describe('对方手机号码'),
}) })
.refine( .refine(
(data) => (data) =>
@ -45,7 +45,7 @@ const oneBotHttpApiUser = {
}), }),
}), }),
}, },
'/send_group_ark_share': { '/ArkShareGroup': {
description: '获取推荐群聊卡片', description: '获取推荐群聊卡片',
request: z.object({ request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群聊ID'), group_id: z.union([z.string(), z.number()]).describe('群聊ID'),

View File

@ -6,30 +6,30 @@ import type { ModalProps } from '@/components/modal';
export interface AlertProps export interface AlertProps
extends Omit<ModalProps, 'onCancel' | 'showCancel' | 'cancelText'> { extends Omit<ModalProps, 'onCancel' | 'showCancel' | 'cancelText'> {
onConfirm?: () => void; onConfirm?: () => void
} }
export interface ConfirmProps extends ModalProps { export interface ConfirmProps extends ModalProps {
onConfirm?: () => void; onConfirm?: () => void
onCancel?: () => void; onCancel?: () => void
} }
export interface ModalItem extends ModalProps { export interface ModalItem extends ModalProps {
id: number; id: number
} }
export interface DialogContextProps { export interface DialogContextProps {
alert: (config: AlertProps) => void; alert: (config: AlertProps) => void
confirm: (config: ConfirmProps) => void; confirm: (config: ConfirmProps) => void
} }
export interface DialogProviderProps { export interface DialogProviderProps {
children: React.ReactNode; children: React.ReactNode
} }
export const DialogContext = React.createContext<DialogContextProps>({ export const DialogContext = React.createContext<DialogContextProps>({
alert: () => { }, alert: () => {},
confirm: () => { }, confirm: () => {},
}); });
const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => { const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {

View File

@ -0,0 +1,91 @@
// Songs Context
import { useLocalStorage } from '@uidotdev/usehooks';
import { createContext, useEffect, useState } from 'react';
import { PlayMode } from '@/const/enum';
import key from '@/const/key';
import AudioPlayer from '@/components/audio_player';
import { get163MusicListSongs, getNextMusic } from '@/utils/music';
import type { FinalMusic } from '@/types/music';
export interface MusicContextProps {
setListId: (id: string) => void
listId: string
onNext: () => void
onPrevious: () => void
}
export interface MusicProviderProps {
children: React.ReactNode
}
export const AudioContext = createContext<MusicContextProps>({
setListId: () => {},
listId: '5438670983',
onNext: () => {},
onPrevious: () => {},
});
const AudioProvider: React.FC<MusicProviderProps> = ({ children }) => {
const [listId, setListId] = useLocalStorage(key.musicID, '5438670983');
const [musicList, setMusicList] = useState<FinalMusic[]>([]);
const [musicId, setMusicId] = useState<number>(0);
const [playMode, setPlayMode] = useState<PlayMode>(PlayMode.Loop);
const music = musicList.find((music) => music.id === musicId);
const [token] = useLocalStorage(key.token, '');
const onNext = () => {
const nextID = getNextMusic(musicList, musicId, playMode);
setMusicId(nextID);
};
const onPrevious = () => {
const index = musicList.findIndex((music) => music.id === musicId);
if (index === 0) {
setMusicId(musicList[musicList.length - 1].id);
} else {
setMusicId(musicList[index - 1].id);
}
};
const onPlayEnd = () => {
const nextID = getNextMusic(musicList, musicId, playMode);
setMusicId(nextID);
};
const changeMode = (mode: PlayMode) => {
setPlayMode(mode);
};
const fetchMusicList = async (id: string) => {
const res = await get163MusicListSongs(id);
setMusicList(res);
setMusicId(res[0].id);
};
useEffect(() => {
if (listId && token) fetchMusicList(listId);
}, [listId, token]);
return (
<AudioContext.Provider
value={{
setListId,
listId,
onNext,
onPrevious,
}}
>
<AudioPlayer
title={music?.title}
src={music?.url || ''}
artist={music?.artist}
cover={music?.cover}
mode={playMode}
pressNext={onNext}
pressPrevious={onPrevious}
onPlayEnd={onPlayEnd}
onChangeMode={changeMode}
/>
{children}
</AudioContext.Provider>
);
};
export default AudioProvider;

View File

@ -48,21 +48,6 @@ export default class WebUIManager {
return data.data; return data.data;
} }
public static async getLatestTag () {
const { data } =
await serverRequest.get<ServerResponse<string>>('/base/getLatestTag');
return data.data;
}
public static async UpdateNapCat () {
const { data } = await serverRequest.post<ServerResponse<any>>(
'/UpdateNapCat/update',
{},
{ timeout: 60000 } // 1分钟超时
);
return data;
}
public static async getQQVersion () { public static async getQQVersion () {
const { data } = const { data } =
await serverRequest.get<ServerResponse<string>>('/base/QQVersion'); await serverRequest.get<ServerResponse<string>>('/base/QQVersion');
@ -212,35 +197,4 @@ export default class WebUIManager {
); );
return data.data; return data.data;
} }
// Passkey相关方法
public static async generatePasskeyRegistrationOptions () {
const { data } = await serverRequest.post<ServerResponse<any>>(
'/auth/passkey/generate-registration-options'
);
return data.data;
}
public static async verifyPasskeyRegistration (response: any) {
const { data } = await serverRequest.post<ServerResponse<any>>(
'/auth/passkey/verify-registration',
{ response }
);
return data.data;
}
public static async generatePasskeyAuthenticationOptions () {
const { data } = await serverRequest.post<ServerResponse<any>>(
'/auth/passkey/generate-authentication-options'
);
return data.data;
}
public static async verifyPasskeyAuthentication (response: any) {
const { data } = await serverRequest.post<ServerResponse<any>>(
'/auth/passkey/verify-authentication',
{ response }
);
return data.data;
}
} }

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