Compare commits

..

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

47 changed files with 1163 additions and 13569 deletions

View File

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

View File

@ -2,7 +2,6 @@ import path from 'node:path';
import fs from 'fs';
import os from 'node:os';
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> {
return new Promise<ReturnType<T> | undefined>((resolve) => {
@ -212,81 +211,3 @@ export function parseAppidFromMajor (nodeMajor: string): string | 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": {
"appid": 537320237,
"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": {
"send": "5B42CF0",
"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": {
"send": "6B13038",
"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

@ -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 {
addKernelStorageCleanListener (listener: NodeIKernelStorageCleanListener): number;
addKernelStorageCleanListener(listener: NodeIKernelStorageCleanListener): number;
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;
removeKernelStorageCleanListener(listenerId: number): void;
addFilesScanedPaths (arg: unknown): unknown;
addCacheScanedPaths(arg: unknown): unknown;
scanCache (): Promise<GeneralCallResult & {
size: string[];
addFilesScanedPaths(arg: unknown): unknown;
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

@ -6,23 +6,23 @@ import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
user_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>;
export class SharePeerBase extends OneBotAction<Payload, GeneralCallResult & {
export class SharePeer extends OneBotAction<Payload, GeneralCallResult & {
arkMsg?: string;
arkJson?: string;
}> {
override actionName = ActionName.SharePeer;
override payloadSchema = SchemaData;
async _handle (payload: Payload) {
if (payload.group_id) {
return await this.core.apis.GroupApi.getGroupRecommendContactArkJson(payload.group_id.toString());
} 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');
}
@ -31,25 +31,14 @@ export class SharePeerBase extends OneBotAction<Payload, GeneralCallResult & {
const SchemaDataGroupEx = Type.Object({
group_id: Type.Union([Type.Number(), Type.String()]),
});
export class SharePeer extends SharePeerBase {
override actionName = ActionName.SharePeer;
}
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;
async _handle (payload: PayloadGroupEx) {
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(),
message_seq: Type.Optional(Type.String()),
count: Type.Number({ default: 20 }),
reverse_order: Type.Boolean({ default: false }),
reverseOrder: Type.Boolean({ default: false }),
disable_get_url: Type.Boolean({ default: false }),
parse_mult_msg: Type.Boolean({ default: true }),
quick_reply: Type.Boolean({ default: false }),
reverseOrder: Type.Boolean({ default: false }),// @deprecated 兼容旧版本
});
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 startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0';
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;
if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`);
// 转换序号

View File

@ -14,11 +14,10 @@ const SchemaData = Type.Object({
group_id: Type.String(),
message_seq: Type.Optional(Type.String()),
count: Type.Number({ default: 20 }),
reverse_order: Type.Boolean({ default: false }),
reverseOrder: Type.Boolean({ default: false }),
disable_get_url: Type.Boolean({ default: false }),
parse_mult_msg: Type.Boolean({ default: true }),
quick_reply: Type.Boolean({ default: false }),
reverseOrder: Type.Boolean({ default: false }),// @deprecated 兼容旧版本
});
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 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;
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 { TranslateEnWordToZn } from './extends/TranslateEnWordToZn';
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 { SetLongNick } from './extends/SetLongNick';
import DelEssenceMsg from './group/DelEssenceMsg';
@ -170,8 +170,6 @@ export function createActionMap (obContext: NapCatOneBot11Adapter, core: NapCatC
new SetQQProfile(obContext, core),
new ShareGroupEx(obContext, core),
new SharePeer(obContext, core),
new SendGroupArkShare(obContext, core),
new SendArkShare(obContext, core),
new CreateCollection(obContext, core),
new SetLongNick(obContext, core),
new ForwardFriendSingleMsg(obContext, core),

View File

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

View File

@ -26,7 +26,7 @@ export default function vitePluginNapcatVersion () {
const data = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
if (data?.tag) return data.tag;
}
} catch { }
} catch {}
return null;
}
@ -36,7 +36,7 @@ export default function vitePluginNapcatVersion () {
cacheFile,
JSON.stringify({ tag, time: new Date().toISOString() }, null, 2)
);
} catch { }
} catch {}
}
async function fetchLatestTag () {
@ -58,7 +58,7 @@ export default function vitePluginNapcatVersion () {
try {
const json = JSON.parse(data);
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'));
} catch (e) {
reject(e);
@ -79,7 +79,7 @@ export default function vitePluginNapcatVersion () {
return tag;
} catch (e) {
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;
ctx.server?.ws.send({ type: 'full-reload' });
}
} catch { }
} catch {}
}
},
};

View File

@ -95,13 +95,7 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
WebUiConfig = new WebUiConfigWrapper();
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) {
const randomToken = process.env['NAPCAT_WEBUI_SECRET_KEY'] || getRandomToken(8);
await WebUiConfig.UpdateWebUIConfig({ token: randomToken });
@ -118,6 +112,12 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
// 存储启动时的初始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);
webUiRuntimePort = port;
if (port === 0) {

View File

@ -16,7 +16,6 @@
}
},
"dependencies": {
"@simplewebauthn/server": "^13.2.2",
"@sinclair/typebox": "^0.34.38",
"ajv": "^8.13.0",
"compressing": "^1.10.3",

View File

@ -1,6 +1,5 @@
import { RequestHandler } from 'express';
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 { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response';
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}`);
}
};
// 生成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 { WebUiConfig } from '@/napcat-webui-backend/index';
import { getLatestTag } from 'napcat-common/src/helper';
export const GetNapCatVersion: RequestHandler = (_, res) => {
const data = WebUiDataRuntime.GetNapCatVersion();
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) => {
const data = WebUiDataRuntime.getQQVersion();
sendSuccess(res, data);

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

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

View File

@ -1,5 +1,5 @@
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 { GetProxyHandler } from '../api/Proxy';
@ -7,7 +7,6 @@ const router = Router();
// router: 获取nc的package.json信息
router.get('/QQVersion', QQVersionHandler);
router.get('/GetNapCatVersion', GetNapCatVersion);
router.get('/getLatestTag', getLatestTagHandler);
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
router.get('/proxy', GetProxyHandler);
router.get('/Theme', GetThemeConfigHandler);

View File

@ -5,10 +5,6 @@ import {
LoginHandler,
LogoutHandler,
UpdateTokenHandler,
GeneratePasskeyRegistrationOptionsHandler,
VerifyPasskeyRegistrationHandler,
GeneratePasskeyAuthenticationOptionsHandler,
VerifyPasskeyAuthenticationHandler,
} from '@/napcat-webui-backend/src/api/Auth';
const router = Router();
@ -20,13 +16,5 @@ router.post('/check', checkHandler);
router.post('/logout', LogoutHandler);
// router:更新token
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 };

View File

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

View File

@ -22,7 +22,6 @@
"@heroui/checkbox": "2.3.9",
"@heroui/chip": "2.2.7",
"@heroui/code": "2.2.7",
"@heroui/divider": "^2.2.21",
"@heroui/dropdown": "2.3.10",
"@heroui/form": "2.1.9",
"@heroui/image": "2.2.6",
@ -49,7 +48,6 @@
"@monaco-editor/react": "4.7.0-rc.0",
"@react-aria/visually-hidden": "^3.8.19",
"@reduxjs/toolkit": "^2.5.1",
"@simplewebauthn/browser": "^13.2.2",
"@uidotdev/usehooks": "^2.4.1",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0",

View File

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

@ -94,7 +94,7 @@ const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
ref={lightRef}
className={clsx(
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
)}
style={{

View File

@ -1,37 +1,23 @@
import { motion } from 'motion/react';
import { Image } from '@heroui/image';
import bkg_color from '@/assets/images/bkg-color.png';
const PageBackground = () => {
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风格 */}
<motion.div
animate={{
scale: [1, 1.2, 1],
rotate: [0, 90, 0],
opacity: [0.3, 0.5, 0.3]
}}
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
className='absolute top-[-10%] left-[-10%] w-[500px] h-[500px] rounded-full bg-primary-200/40 blur-[100px]'
/>
<motion.div
animate={{
scale: [1, 1.3, 1],
x: [0, 100, 0],
opacity: [0.3, 0.6, 0.3]
}}
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut", delay: 2 }}
className='absolute top-[20%] right-[-10%] w-[400px] h-[400px] rounded-full bg-secondary-200/40 blur-[90px]'
/>
<motion.div
animate={{
scale: [1, 1.1, 1],
y: [0, -50, 0],
opacity: [0.2, 0.4, 0.2]
}}
transition={{ duration: 12, repeat: Infinity, ease: "easeInOut", delay: 5 }}
className='absolute bottom-[-10%] left-[20%] w-[600px] h-[600px] rounded-full bg-pink-200/30 blur-[110px]'
/>
</div>
<>
<div className='fixed w-full h-full -z-[0] flex justify-end opacity-80'>
<Image
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>
<div className='fixed w-full h-full overflow-hidden -z-[0] hue-rotate-90 flex justify-start opacity-80'>
<Image
className='relative -top-92 h-[180%] object-contain pointer-events-none rotate-90 select-none -z-10 top-44'
src={bkg_color}
/>
</div>
</>
);
};

View File

@ -1,6 +1,7 @@
import { Button } from '@heroui/button';
import { Image } from '@heroui/image';
import clsx from 'clsx';
import { AnimatePresence, motion } from 'motion/react';
import { motion } from 'motion/react';
import React from 'react';
import { IoMdLogOut } from 'react-icons/io';
import { MdDarkMode, MdLightMode } from 'react-icons/md';
@ -9,17 +10,18 @@ import useAuth from '@/hooks/auth';
import useDialog from '@/hooks/use-dialog';
import { useTheme } from '@/hooks/use-theme';
import logo from '@/assets/images/logo.png';
import type { MenuItem } from '@/config/site';
import Menus from './menus';
interface SideBarProps {
open: boolean;
items: MenuItem[];
onClose?: () => void;
open: boolean
items: MenuItem[]
}
const SideBar: React.FC<SideBarProps> = (props) => {
const { open, items, onClose } = props;
const { open, items } = props;
const { toggleTheme, isDark } = useTheme();
const { revokeAuth } = useAuth();
const dialog = useDialog();
@ -31,68 +33,60 @@ const SideBar: React.FC<SideBarProps> = (props) => {
});
};
return (
<>
<AnimatePresence initial={false}>
{open && (
<motion.div
className='fixed inset-y-0 left-64 right-0 bg-black/20 backdrop-blur-[1px] z-40 md:hidden'
aria-hidden='true'
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
transition={{ duration: 0.2, delay: 0.15 }}
/>
)}
</AnimatePresence>
<motion.div
className={clsx(
'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'
)}
initial={{ width: 0 }}
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>
<motion.div
className={clsx(
'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'
)}
initial={{ width: 0 }}
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'>
<div className='flex justify-center items-center my-2 gap-2'>
<Image radius='none' height={40} src={logo} className='mb-2' />
<div
className={clsx(
'flex items-center font-bold',
'!text-2xl shiny-text'
)}
>
NapCat
</div>
<div className='overflow-y-auto flex flex-col flex-1 px-2'>
<Menus items={items} />
<div className='mt-auto mb-10 md:mb-0 space-y-3 px-2'>
<Button
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'
radius='full'
variant='flat'
onPress={toggleTheme}
startContent={
!isDark ? <MdLightMode size={18} /> : <MdDarkMode size={18} />
}
>
</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'
radius='full'
variant='flat'
onPress={onRevokeAuth}
startContent={<IoMdLogOut size={18} />}
>
退
</Button>
</div>
</div>
<div className='overflow-y-auto flex flex-col flex-1 px-4'>
<Menus items={items} />
<div className='mt-auto mb-10 md:mb-0'>
<Button
className='w-full'
color='primary'
radius='full'
variant='light'
onPress={toggleTheme}
startContent={
!isDark ? <MdLightMode size={16} /> : <MdDarkMode size={16} />
}
>
</Button>
<Button
className='w-full mb-2'
color='primary'
radius='full'
variant='light'
onPress={onRevokeAuth}
startContent={<IoMdLogOut size={16} />}
>
退
</Button>
</div>
</motion.div>
</div>
</motion.div>
</>
</motion.div>
);
};

View File

@ -50,13 +50,12 @@ const renderItems = (items: MenuItem[], children = false) => {
<div key={item.href + item.label}>
<Button
className={clsx(
'flex items-center w-full text-left justify-start dark:text-white transition-all duration-300',
isActive
? 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-400 shadow-none font-semibold translate-x-1'
: 'hover:bg-default-100 hover:translate-x-1',
'flex items-center w-full text-left justify-start dark:text-white',
// children && 'rounded-l-lg',
isActive && 'bg-opacity-60',
b64img && 'backdrop-blur-md text-white'
)}
color={isActive ? 'primary' : 'default'}
color='primary'
endContent={
canOpen
? (
@ -105,6 +104,7 @@ const renderItems = (items: MenuItem[], children = false) => {
/>
)
}
radius='full'
startContent={
customIcons[item.label]
? (
@ -147,7 +147,7 @@ const renderItems = (items: MenuItem[], children = false) => {
};
interface MenusProps {
items: MenuItem[];
items: MenuItem[]
}
const Menus: React.FC<MenusProps> = (props) => {
const { items } = props;

View File

@ -1,19 +1,13 @@
import { Card, CardBody, CardHeader } from '@heroui/card';
import { Button } from '@heroui/button';
import { Chip } from '@heroui/chip';
import { Spinner } from '@heroui/spinner';
import { Tooltip } from '@heroui/tooltip';
import { useRequest } from 'ahooks';
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6';
import { FaCircleInfo, FaQq } from 'react-icons/fa6';
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io';
import { RiMacFill } from 'react-icons/ri';
import { useState } from 'react';
import toast from 'react-hot-toast';
import WebUIManager from '@/controllers/webui_manager';
import useDialog from '@/hooks/use-dialog';
export interface SystemInfoItemProps {
@ -192,75 +186,6 @@ export interface NewVersionTipProps {
// );
// };
const NewVersionTip = (props: NewVersionTipProps) => {
const { currentVersion } = props;
const dialog = useDialog();
const { data: latestVersion, error } = useRequest(WebUIManager.getLatestTag);
const [updating, setUpdating] = useState(false);
if (error || !latestVersion || !currentVersion || latestVersion === currentVersion) {
return null;
}
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'>v{latestVersion}</Chip>
</div>
{updating && (
<div className='flex justify-center'>
<Spinner size='sm' />
</div>
)}
</div>
),
confirmText: updating ? '更新中...' : '更新',
onConfirm: async () => {
setUpdating(true);
toast('更新中,预计需要几分钟,请耐心等待', {
duration: 3000,
});
try {
await WebUIManager.UpdateNapCat();
toast.success('更新完成,重启生效', {
duration: 5000,
});
} catch (error) {
console.error('Update failed:', error);
toast.success('更新异常', {
duration: 5000,
});
} finally {
setUpdating(false);
}
},
});
}}
>
<FaInfo />
</Button>
</Tooltip>
);
};
const NapCatVersion = () => {
const {
data: packageData,
@ -287,7 +212,6 @@ const NapCatVersion = () => {
currentVersion
)
}
endContent={<NewVersionTip currentVersion={currentVersion} />}
/>
);
};

View File

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

View File

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

View File

@ -15,7 +15,7 @@ const oneBotHttpApiUser = {
data: commonResponseDataSchema,
}),
},
'/send_ark_share': {
'/ArkSharePeer': {
description: '获取推荐好友/群聊卡片',
request: z
.object({
@ -27,7 +27,7 @@ const oneBotHttpApiUser = {
.union([z.string(), z.number()])
.optional()
.describe('用户ID与 group_id 二选一'),
phone_number: z.string().optional().describe('对方手机号码'),
phoneNumber: z.string().optional().describe('对方手机号码'),
})
.refine(
(data) =>
@ -45,7 +45,7 @@ const oneBotHttpApiUser = {
}),
}),
},
'/send_group_ark_share': {
'/ArkShareGroup': {
description: '获取推荐群聊卡片',
request: z.object({
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
extends Omit<ModalProps, 'onCancel' | 'showCancel' | 'cancelText'> {
onConfirm?: () => void;
onConfirm?: () => void
}
export interface ConfirmProps extends ModalProps {
onConfirm?: () => void;
onCancel?: () => void;
onConfirm?: () => void
onCancel?: () => void
}
export interface ModalItem extends ModalProps {
id: number;
id: number
}
export interface DialogContextProps {
alert: (config: AlertProps) => void;
confirm: (config: ConfirmProps) => void;
alert: (config: AlertProps) => void
confirm: (config: ConfirmProps) => void
}
export interface DialogProviderProps {
children: React.ReactNode;
children: React.ReactNode
}
export const DialogContext = React.createContext<DialogContextProps>({
alert: () => { },
confirm: () => { },
alert: () => {},
confirm: () => {},
});
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,12 +48,6 @@ export default class WebUIManager {
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',
@ -212,35 +206,4 @@ export default class WebUIManager {
);
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;
}
}

View File

@ -0,0 +1,11 @@
import React from 'react';
import { AudioContext } from '@/contexts/songs';
const useMusic = () => {
const music = React.useContext(AudioContext);
return music;
};
export default useMusic;

View File

@ -79,31 +79,21 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
}, [location.pathname]);
return (
<div
className='h-screen relative flex items-stretch overflow-hidden'
className='h-screen relative flex bg-primary-50 dark:bg-black items-stretch'
style={{
backgroundImage: b64img ? `url(${b64img})` : undefined,
backgroundImage: `url(${b64img})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<SideBar
items={menus}
open={openSideBar}
onClose={() => setOpenSideBar(false)}
/>
<motion.div
layout
<SideBar items={menus} open={openSideBar} />
<div
ref={contentRef}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
className={clsx(
'flex-1 overflow-y-auto',
'bg-white/60 dark:bg-black/40 backdrop-blur-xl',
'shadow-[0_8px_32px_0_rgba(31,38,135,0.07)]',
'transition-all duration-300 ease-in-out',
openSideBar ? 'm-3 ml-0 rounded-3xl border border-white/40 dark:border-white/10' : 'm-0 rounded-none',
'pb-10 md:pb-0'
'overflow-y-auto flex-1 rounded-md m-1 bg-content1 pb-10 md:pb-0',
openSideBar ? 'ml-0' : 'ml-1',
!b64img && 'shadow-inner',
b64img && '!bg-opacity-50 backdrop-blur-none dark:bg-background',
'overflow-x-hidden'
)}
>
<div
@ -115,12 +105,15 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
'z-30 m-2 mb-0 sticky top-2 left-0'
)}
>
<div
<motion.div
className={clsx(
'mr-1 ease-in-out ml-0 md:relative z-50 md:z-auto',
openSideBar && 'pl-2',
'mr-1 ease-in-out ml-0 md:relative',
openSideBar && 'pl-2 absolute',
'md:!ml-0 md:pl-0'
)}
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
initial={{ marginLeft: 0 }}
animate={{ marginLeft: openSideBar ? '15rem' : 0 }}
>
<Button
isIconOnly
@ -130,7 +123,7 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
>
{openSideBar ? <MdMenuOpen size={24} /> : <MdMenu size={24} />}
</Button>
</div>
</motion.div>
<Breadcrumbs isDisabled size='lg'>
{title?.map((item, index) => (
<BreadcrumbItem key={index}>
@ -152,7 +145,7 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
<ErrorBoundary fallbackRender={errorFallbackRender}>
{children}
</ErrorBoundary>
</motion.div>
</div>
</div>
);
};

View File

@ -1,20 +1,21 @@
import { Card, CardBody, CardHeader } from '@heroui/card';
import { Chip } from '@heroui/chip';
import { Divider } from '@heroui/divider';
import { Card, CardBody } from '@heroui/card';
import { Button } from '@heroui/button';
import { Image } from '@heroui/image';
import { Link } from '@heroui/link';
import { Skeleton } from '@heroui/skeleton';
import { Spinner } from '@heroui/spinner';
import { useRequest } from 'ahooks';
import {
BsCodeSlash,
BsCpu,
BsGithub,
BsGlobe,
BsPlugin,
BsTelegram,
BsTencentQq
} from 'react-icons/bs';
import { IoDocument, IoRocketSharp } from 'react-icons/io5';
import { useMemo } from 'react';
import { BsTelegram, BsTencentQq } from 'react-icons/bs';
import { IoDocument } from 'react-icons/io5';
import toast from 'react-hot-toast';
import HoverTiltedCard from '@/components/hover_titled_card';
import NapCatRepoInfo from '@/components/napcat_repo_info';
import RotatingText from '@/components/rotating_text';
import { usePreloadImages } from '@/hooks/use-preload-images';
import { useTheme } from '@/hooks/use-theme';
import logo from '@/assets/images/logo.png';
import WebUIManager from '@/controllers/webui_manager';
@ -22,169 +23,229 @@ import WebUIManager from '@/controllers/webui_manager';
function VersionInfo () {
const { data, loading, error } = useRequest(WebUIManager.GetNapCatVersion);
// 更新NapCat
const { run: updateNapCat, loading: updating } = useRequest(
WebUIManager.UpdateNapCat,
{
manual: true,
onSuccess: (response) => {
console.log('UpdateNapCat onSuccess response:', response);
console.log('response.code:', response.code);
console.log('response.data:', response.data);
console.log('response.message:', response.message);
if (response.code === 0) {
const message = response.data?.message || '更新完成';
console.log('显示消息:', message);
toast.success(message, {
duration: 5000,
});
} else {
console.log('显示错误消息:', response.message || '更新失败');
toast.error(response.message || '更新失败');
}
},
onError: (error) => {
toast.error('更新失败: ' + error.message);
},
}
);
const handleUpdate = () => {
if (!updating) {
updateNapCat();
}
};
return (
<div className='flex items-center gap-2'>
{error ? (
<Chip color="danger" variant="flat" size="sm">{error.message}</Chip>
) : loading ? (
<Spinner size='sm' color="default" />
) : (
<div className="flex items-center gap-2">
<Chip size="sm" color="default" variant="flat" className="text-default-500">WebUI v0.0.6</Chip>
<Chip size="sm" color="primary" variant="flat">Core {data?.version}</Chip>
</div>
)}
<div className='flex items-center gap-4'>
<div className='flex items-center gap-2 text-2xl font-bold'>
<div className='text-primary-500 drop-shadow-md'>NapCat</div>
{error
? (
error.message
)
: loading
? (
<Spinner size='sm' />
)
: (
<RotatingText
texts={['WebUI', data?.version ?? '']}
mainClassName='overflow-hidden flex items-center bg-primary-500 px-2 rounded-lg text-default-50 shadow-md'
staggerFrom='last'
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '-120%' }}
staggerDuration={0.025}
splitLevelClassName='overflow-hidden'
transition={{ type: 'spring', damping: 30, stiffness: 400 }}
rotationInterval={2000}
/>
)}
</div>
<Button
color="primary"
variant="solid"
size="sm"
isLoading={updating}
onPress={handleUpdate}
isDisabled={updating}
>
{updating ? '更新中...' : '更新'}
</Button>
</div>
);
}
export default function AboutPage () {
const features = [
{
icon: <IoRocketSharp size={20} />,
title: '高性能架构',
desc: 'Node.js + Native 混合架构,资源占用低,响应速度快。',
className: 'bg-primary-50 text-primary'
},
{
icon: <BsGlobe size={20} />,
title: '全平台支持',
desc: '适配 Windows、Linux 及 Docker 环境。',
className: 'bg-success-50 text-success'
},
{
icon: <BsCodeSlash size={20} />,
title: 'OneBot 11',
desc: '深度集成标准协议,兼容现有生态。',
className: 'bg-warning-50 text-warning'
},
{
icon: <BsPlugin size={20} />,
title: '极易扩展',
desc: '提供丰富的 API 接口与 WebHook 支持。',
className: 'bg-secondary-50 text-secondary'
}
];
const { isDark } = useTheme();
const links = [
{ icon: <BsGithub />, name: 'GitHub', href: 'https://github.com/NapNeko/NapCatQQ' },
{ icon: <BsTelegram />, name: 'Telegram', href: 'https://t.me/napcatqq' },
{ icon: <BsTencentQq />, name: 'QQ 群 1', href: 'https://qm.qq.com/q/F9cgs1N3Mc' },
{ icon: <BsTencentQq />, name: 'QQ 群 2', href: 'https://qm.qq.com/q/hSt0u9PVn' },
{ icon: <IoDocument />, name: '文档', href: 'https://napcat.napneko.icu/' },
];
const imageUrls = useMemo(
() => [
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=dark',
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=dark',
],
[]
);
const cardStyle = "bg-default/40 backdrop-blur-lg border-none shadow-none";
const { loadedUrls, isLoading } = usePreloadImages(imageUrls);
const getImageUrl = useMemo(
() => (baseUrl: string) => {
const theme = isDark ? 'dark' : 'light';
const fullUrl = baseUrl.replace(
/color_scheme=(?:light|dark)/,
`color_scheme=${theme}`
);
return isLoading ? null : loadedUrls[fullUrl] ? fullUrl : null;
},
[isDark, isLoading, loadedUrls]
);
const renderImage = useMemo(
() => (baseUrl: string, alt: string) => {
const imageUrl = getImageUrl(baseUrl);
if (!imageUrl) {
return <Skeleton className='h-16 rounded-lg' />;
}
return (
<Image
className='flex-1 pointer-events-none select-none rounded-none'
src={imageUrl}
alt={alt}
/>
);
},
[getImageUrl]
);
return (
<div className='flex flex-col h-full w-full gap-6 p-2 md:p-6'>
<title> - NapCat WebUI</title>
{/* 头部标题区 */}
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold flex items-center gap-3 text-default-900">
<Image src={logo} alt="NapCat Logo" width={32} height={32} />
NapCat
</h1>
<div className="flex items-center gap-4 text-small text-default-500">
<p> QQ </p>
<Divider orientation="vertical" className="h-4" />
<VersionInfo />
</div>
</div>
<Divider className="opacity-50" />
{/* 主内容区:双栏布局 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-grow">
{/* 左侧:介绍与特性 */}
<div className="lg:col-span-2 space-y-6">
<Card shadow="sm" className={cardStyle}>
<CardHeader className="pb-0 pt-4 px-4 flex-col items-start">
<h2 className="text-lg font-bold"></h2>
</CardHeader>
<CardBody className="py-4 text-default-600 leading-relaxed space-y-2">
<p>
NapCat () QQ NTQQ
GUI Headless
<>
<title> NapCat WebUI</title>
<section className='max-w-7xl py-8 md:py-10 px-5 mx-auto space-y-10'>
<div className='w-full flex flex-col md:flex-row gap-4'>
<div className='flex flex-col md:flex-row items-center'>
<HoverTiltedCard imageSrc={logo} overlayContent='' />
</div>
<div className='flex-1 flex flex-col gap-2 py-2'>
<VersionInfo />
<div className='space-y-1'>
<p className='font-bold text-primary-400'>NapCat ?</p>
<p className='text-default-800'>
TypeScript构建的Bot框架,,QQ
Node模块提供给客户端的接口,Bot的功能.
</p>
<p>
NapCat OneBot 11
<p className='font-bold text-primary-400'></p>
<p className='text-default-800'>
QQ
便使 OneBot HTTP /
WebSocket
QQ发送接口之类的接口
</p>
</CardBody>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{features.map((item, index) => (
<Card key={index} shadow="sm" className={cardStyle}>
<CardBody className="flex flex-row items-start gap-4 p-4">
<div className={`p-3 rounded-lg ${item.className}`}>
{item.icon}
</div>
<div>
<h3 className="font-semibold text-default-900">{item.title}</h3>
<p className="text-small text-default-500 mt-1">{item.desc}</p>
</div>
</CardBody>
</Card>
))}
</div>
</div>
</div>
{/* 右侧:信息与链接 */}
<div className="space-y-6">
<Card shadow="sm" className={cardStyle}>
<CardHeader className="pb-0 pt-4 px-4">
<h2 className="text-lg font-bold"></h2>
</CardHeader>
<CardBody className="py-4">
<div className="flex flex-col gap-2">
{links.map((link, idx) => (
<Link
key={idx}
isExternal
href={link.href}
className="flex items-center justify-between p-3 rounded-xl hover:bg-default-100/50 transition-colors text-default-600"
>
<span className="flex items-center gap-3">
{link.icon}
{link.name}
</span>
<span className="text-tiny text-default-400"> &rarr;</span>
</Link>
))}
</div>
<div className='flex flex-row gap-2 flex-wrap justify-around'>
<Card
as={Link}
shadow='sm'
isPressable
isExternal
href='https://qm.qq.com/q/F9cgs1N3Mc'
>
<CardBody className='flex-row items-center gap-2'>
<span className='p-2 rounded-small bg-primary-50 text-primary-500'>
<BsTencentQq size={16} />
</span>
<span>1</span>
</CardBody>
</Card>
<Card shadow="sm" className={cardStyle}>
<CardHeader className="pb-0 pt-4 px-4">
<h2 className="text-lg font-bold flex items-center gap-2">
<BsCpu />
</h2>
</CardHeader>
<CardBody className="py-4">
<div className="flex flex-wrap gap-2">
{['TypeScript', 'React', 'Vite', 'Node.js', 'Electron', 'HeroUI'].map((tech) => (
<Chip key={tech} size="sm" variant="flat" className="bg-default-100/50 text-default-600">
{tech}
</Chip>
))}
</div>
<Card
as={Link}
shadow='sm'
isPressable
isExternal
href='https://qm.qq.com/q/hSt0u9PVn'
>
<CardBody className='flex-row items-center gap-2'>
<span className='p-2 rounded-small bg-primary-50 text-primary-500'>
<BsTencentQq size={16} />
</span>
<span>2</span>
</CardBody>
</Card>
<Card
as={Link}
shadow='sm'
isPressable
isExternal
href='https://t.me/napcatqq'
>
<CardBody className='flex-row items-center gap-2'>
<span className='p-2 rounded-small bg-primary-50 text-primary-500'>
<BsTelegram size={16} />
</span>
<span>Telegram</span>
</CardBody>
</Card>
<Card
as={Link}
shadow='sm'
isPressable
isExternal
href='https://napcat.napneko.icu/'
>
<CardBody className='flex-row items-center gap-2'>
<span className='p-2 rounded-small bg-primary-50 text-primary-500'>
<IoDocument size={16} />
</span>
<span>使</span>
</CardBody>
</Card>
</div>
</div>
<div className='flex flex-col md:flex-row md:items-start gap-4'>
<div className='w-full flex flex-col gap-4'>
{renderImage(
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
'Contributors'
)}
{renderImage(
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
'Activity Trends'
)}
</div>
{/* 底部版权 - 移出 grid 布局 */}
<div className="w-full text-center text-tiny text-default-400 py-4 mt-auto flex flex-col items-center gap-1">
<p className="flex items-center justify-center gap-1">
Made with <span className="text-danger"></span> by NapCat Team
</p>
<p>MIT License © {new Date().getFullYear()}</p>
</div>
</div>
<NapCatRepoInfo />
</div>
</section>
</>
);
}

View File

@ -1,6 +1,6 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
@ -10,26 +10,10 @@ import SaveButtons from '@/components/button/save_buttons';
import FileInput from '@/components/input/file_input';
import ImageInput from '@/components/input/image_input';
import useMusic from '@/hooks/use-music';
import { siteConfig } from '@/config/site';
import FileManager from '@/controllers/file_manager';
import WebUIManager from '@/controllers/webui_manager';
// Base64URL to Uint8Array converter
function base64UrlToUint8Array (base64Url: string): Uint8Array {
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Uint8Array to Base64URL converter
function uint8ArrayToBase64Url (uint8Array: Uint8Array): string {
const base64 = window.btoa(String.fromCharCode(...uint8Array));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
const WebUIConfigCard = () => {
const {
@ -40,6 +24,7 @@ const WebUIConfigCard = () => {
} = useForm<IConfig['webui']>({
defaultValues: {
background: '',
musicListID: '',
customIcons: {},
},
});
@ -49,33 +34,17 @@ const WebUIConfigCard = () => {
key.customIcons,
{}
);
const [registrationOptions, setRegistrationOptions] = useState<any>(null);
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
// 预先获取注册选项(可以在任何时候调用)
const preloadRegistrationOptions = async () => {
setIsLoadingOptions(true);
try {
console.log('预先获取注册选项...');
const options = await WebUIManager.generatePasskeyRegistrationOptions();
setRegistrationOptions(options);
console.log('✅ 注册选项已获取并存储');
toast.success('注册选项已准备就绪,请点击注册按钮');
} catch (error) {
console.error('❌ 获取注册选项失败:', error);
toast.error('获取注册选项失败,请重试');
} finally {
setIsLoadingOptions(false);
}
};
const { setListId, listId } = useMusic();
const reset = () => {
setWebuiValue('musicListID', listId);
setWebuiValue('customIcons', customIcons);
setWebuiValue('background', b64img);
};
const onSubmit = handleWebuiSubmit((data) => {
try {
setListId(data.musicListID);
setCustomIcons(data.customIcons);
setB64img(data.background);
toast.success('保存成功');
@ -87,7 +56,7 @@ const WebUIConfigCard = () => {
useEffect(() => {
reset();
}, [customIcons, b64img]);
}, [listId, customIcons, b64img]);
return (
<>
@ -123,6 +92,20 @@ const WebUIConfigCard = () => {
/>
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full'>WebUI音乐播放器</div>
<Controller
control={control}
name='musicListID'
render={({ field }) => (
<Input
{...field}
label='网易云音乐歌单ID网页内音乐播放器'
placeholder='请输入歌单ID'
/>
)}
/>
</div>
<div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full'></div>
<Controller
@ -142,122 +125,6 @@ const WebUIConfigCard = () => {
/>
))}
</div>
<div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full'>Passkey认证</div>
<div className='text-sm text-default-400 mb-2'>
Passkey后便WebUItoken
</div>
<div className='flex gap-2'>
<Button
color='secondary'
variant='flat'
onPress={preloadRegistrationOptions}
isLoading={isLoadingOptions}
className='w-fit'
>
{!isLoadingOptions && '📥'}
</Button>
<Button
color='primary'
variant='flat'
onPress={() => {
// 必须在用户手势的同步上下文中立即调用WebAuthn API
if (!registrationOptions) {
toast.error('请先点击"准备选项"按钮获取注册选项');
return;
}
console.log('开始Passkey注册...');
console.log('使用预先获取的选项:', registrationOptions);
// 立即调用WebAuthn API不要用async/await
navigator.credentials.create({
publicKey: {
challenge: base64UrlToUint8Array(registrationOptions.challenge) as BufferSource,
rp: {
name: registrationOptions.rp.name,
id: registrationOptions.rp.id
},
user: {
id: base64UrlToUint8Array(registrationOptions.user.id) as BufferSource,
name: registrationOptions.user.name,
displayName: registrationOptions.user.displayName,
},
pubKeyCredParams: registrationOptions.pubKeyCredParams,
timeout: 30000,
excludeCredentials: registrationOptions.excludeCredentials?.map((cred: any) => ({
id: base64UrlToUint8Array(cred.id) as BufferSource,
type: cred.type,
transports: cred.transports,
})) || [],
attestation: registrationOptions.attestation,
},
}).then(async (credential) => {
console.log('✅ 注册成功!凭据已创建');
console.log('凭据ID:', (credential as PublicKeyCredential).id);
console.log('凭据类型:', (credential as PublicKeyCredential).type);
// Prepare response for verification - convert to expected format
const cred = credential as PublicKeyCredential;
const response = {
id: cred.id, // 保持为base64url字符串
rawId: uint8ArrayToBase64Url(new Uint8Array(cred.rawId)), // 转换为base64url字符串
response: {
attestationObject: uint8ArrayToBase64Url(new Uint8Array((cred.response as AuthenticatorAttestationResponse).attestationObject)), // 转换为base64url字符串
clientDataJSON: uint8ArrayToBase64Url(new Uint8Array(cred.response.clientDataJSON)), // 转换为base64url字符串
transports: (cred.response as AuthenticatorAttestationResponse).getTransports?.() || [],
},
type: cred.type,
};
console.log('准备验证响应:', response);
try {
// Verify registration
const result = await WebUIManager.verifyPasskeyRegistration(response);
if (result.verified) {
toast.success('Passkey注册成功现在您可以使用Passkey自动登录');
setRegistrationOptions(null); // 清除已使用的选项
} else {
throw new Error('Passkey registration failed');
}
} catch (verifyError) {
console.error('❌ 验证失败:', verifyError);
const err = verifyError as Error;
toast.error(`Passkey验证失败: ${err.message}`);
}
}).catch((error) => {
console.error('❌ 注册失败:', error);
const err = error as Error;
console.error('错误名称:', err.name);
console.error('错误信息:', err.message);
// Provide more specific error messages
if (err.name === 'NotAllowedError') {
toast.error('Passkey注册被拒绝。请确保您允许了生物识别认证权限。');
} else if (err.name === 'NotSupportedError') {
toast.error('您的浏览器不支持Passkey功能。');
} else if (err.name === 'SecurityError') {
toast.error('安全错误请确保使用HTTPS或localhost环境。');
} else {
toast.error(`Passkey注册失败: ${err.message}`);
}
});
}}
disabled={!registrationOptions}
className='w-fit'
>
🔐 Passkey
</Button>
</div>
{registrationOptions && (
<div className='text-xs text-green-600'>
</div>
)}
</div>
<SaveButtons
onSubmit={onSubmit}
reset={reset}

View File

@ -6,8 +6,6 @@ import { useEffect, useRef, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useNavigate } from 'react-router-dom';
import logo from '@/assets/images/logo.png';
import HoverEffectCard from '@/components/effect_card';
import { title } from '@/components/primitives';
import QrCodeLogin from '@/components/qr_code_login';
@ -15,9 +13,9 @@ import QuickLogin from '@/components/quick_login';
import type { QQItem } from '@/components/quick_login';
import { ThemeSwitch } from '@/components/theme-switch';
import logo from '@/assets/images/logo.png';
import QQManager from '@/controllers/qq_manager';
import PureLayout from '@/layouts/pure';
import { motion } from 'motion/react';
export default function QQLoginPage () {
const navigate = useNavigate();
@ -114,12 +112,7 @@ export default function QQLoginPage () {
<>
<title>QQ登录 - NapCat WebUI</title>
<PureLayout>
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.5, type: 'spring', stiffness: 120, damping: 20 }}
className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'
>
<div className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'>
<HoverEffectCard
className='items-center gap-4 pt-0 pb-6 bg-default-50'
maxXRotation={3}
@ -176,7 +169,7 @@ export default function QQLoginPage () {
</Button>
</CardBody>
</HoverEffectCard>
</motion.div>
</div>
</PureLayout>
</>
);

View File

@ -8,17 +8,15 @@ import { toast } from 'react-hot-toast';
import { IoKeyOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router-dom';
import logo from '@/assets/images/logo.png';
import key from '@/const/key';
import HoverEffectCard from '@/components/effect_card';
import { title } from '@/components/primitives';
import { ThemeSwitch } from '@/components/theme-switch';
import logo from '@/assets/images/logo.png';
import WebUIManager from '@/controllers/webui_manager';
import PureLayout from '@/layouts/pure';
import { motion } from 'motion/react';
export default function WebLoginPage () {
const urlSearchParams = new URLSearchParams(window.location.search);
@ -26,78 +24,7 @@ export default function WebLoginPage () {
const navigate = useNavigate();
const [tokenValue, setTokenValue] = useState<string>(token || '');
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isPasskeyLoading, setIsPasskeyLoading] = useState<boolean>(true); // 初始为true表示正在检查passkey
const [, setLocalToken] = useLocalStorage<string>(key.token, '');
// Helper function to decode base64url
function base64UrlToUint8Array (base64Url: string): Uint8Array {
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
// Helper function to encode Uint8Array to base64url
function uint8ArrayToBase64Url (uint8Array: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...uint8Array));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// 自动检查并尝试passkey登录
const tryPasskeyLogin = async () => {
try {
// 检查是否有passkey
const options = await WebUIManager.generatePasskeyAuthenticationOptions();
// 如果有passkey自动进行认证
const credential = await navigator.credentials.get({
publicKey: {
challenge: base64UrlToUint8Array(options.challenge) as BufferSource,
allowCredentials: options.allowCredentials?.map((cred: any) => ({
id: base64UrlToUint8Array(cred.id) as BufferSource,
type: cred.type,
transports: cred.transports,
})),
userVerification: options.userVerification,
},
}) as PublicKeyCredential;
if (!credential) {
throw new Error('Passkey authentication cancelled');
}
// 准备响应进行验证 - 转换为base64url字符串格式
const authResponse = credential.response as AuthenticatorAssertionResponse;
const response = {
id: credential.id,
rawId: uint8ArrayToBase64Url(new Uint8Array(credential.rawId)),
response: {
authenticatorData: uint8ArrayToBase64Url(new Uint8Array(authResponse.authenticatorData)),
clientDataJSON: uint8ArrayToBase64Url(new Uint8Array(authResponse.clientDataJSON)),
signature: uint8ArrayToBase64Url(new Uint8Array(authResponse.signature)),
userHandle: authResponse.userHandle ? uint8ArrayToBase64Url(new Uint8Array(authResponse.userHandle)) : null,
},
type: credential.type,
};
// 验证认证
const data = await WebUIManager.verifyPasskeyAuthentication(response);
if (data && data.Credential) {
setLocalToken(data.Credential);
navigate('/qq_login', { replace: true });
return true; // 登录成功
}
} catch (error) {
// passkey登录失败继续显示token登录界面
console.log('Passkey login failed or not available:', error);
}
return false; // 登录失败
};
const onSubmit = async () => {
if (!tokenValue) {
toast.error('请输入token');
@ -121,7 +48,7 @@ export default function WebLoginPage () {
// 处理全局键盘事件
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !isLoading && !isPasskeyLoading) {
if (e.key === 'Enter' && !isLoading) {
onSubmit();
}
};
@ -133,31 +60,19 @@ export default function WebLoginPage () {
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [tokenValue, isLoading, isPasskeyLoading]); // 依赖项包含用于登录的状态
}, [tokenValue, isLoading]); // 依赖项包含用于登录的状态
useEffect(() => {
// 如果URL中有token直接登录
if (token) {
onSubmit();
return;
}
// 否则尝试passkey自动登录
tryPasskeyLogin().finally(() => {
setIsPasskeyLoading(false);
});
}, []);
return (
<>
<title>WebUI登录 - NapCat WebUI</title>
<PureLayout>
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.5, type: "spring", stiffness: 120, damping: 20 }}
className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'
>
<div className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'>
<HoverEffectCard
className='items-center gap-4 pt-0 pb-6 bg-default-50'
maxXRotation={3}
@ -177,11 +92,6 @@ export default function WebLoginPage () {
</CardHeader>
<CardBody className='flex gap-5 py-5 px-5 md:px-10'>
{isPasskeyLoading && (
<div className='text-center text-small text-default-600 dark:text-default-400 px-2'>
🔐 Passkey...
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
@ -225,7 +135,7 @@ export default function WebLoginPage () {
'!cursor-text',
],
}}
isDisabled={isLoading || isPasskeyLoading}
isDisabled={isLoading}
label='Token'
placeholder='请输入token'
radius='lg'
@ -264,7 +174,7 @@ export default function WebLoginPage () {
</Button>
</CardBody>
</HoverEffectCard>
</motion.div>
</div>
</PureLayout>
</>
);

View File

@ -6,45 +6,15 @@
body {
font-family:
'Quicksand',
'Nunito',
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
'PingFang SC',
'Microsoft YaHei',
'Aa偷吃可爱长大的',
PingFang SC,
Helvetica Neue,
Microsoft YaHei,
sans-serif !important;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-smooth: always;
letter-spacing: 0.02em;
}
:root {
--heroui-primary: 217.2 91.2% 59.8%; /* 自然的现代蓝 */
--heroui-primary-foreground: 210 40% 98%;
--heroui-radius: 0.75rem;
--text-primary: 222.2 47.4% 11.2%;
--text-secondary: 215.4 16.3% 46.9%;
}
h1, h2, h3, h4, h5, h6 {
color: hsl(var(--text-primary));
letter-spacing: -0.02em;
}
.dark h1, .dark h2, .dark h3, .dark h4, .dark h5, .dark h6 {
color: hsl(210 40% 98%);
}
.dark {
--heroui-primary: 217.2 91.2% 59.8%;
--heroui-primary-foreground: 210 40% 98%;
}
@layer components {
@ -64,29 +34,23 @@ h1, h2, h3, h4, h5, h6 {
}
}
::selection {
background-color: #ffcdba;
color: #fff;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background-color: transparent;
border-radius: 3px;
-webkit-border-radius: 2em;
-moz-border-radius: 2em;
border-radius: 2em;
}
::-webkit-scrollbar-thumb {
background-color: rgba(255, 182, 193, 0.4); /* 浅粉色滚动条 */
border-radius: 3px;
transition: all 0.3s;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 127, 172, 0.6);
background-color: rgb(147, 147, 153, 0.5);
-webkit-border-radius: 2em;
-moz-border-radius: 2em;
border-radius: 2em;
}
.monaco-editor {

View File

@ -0,0 +1,122 @@
import { PlayMode } from '@/const/enum';
import WebUIManager from '@/controllers/webui_manager';
import type {
FinalMusic,
Music163ListResponse,
Music163URLResponse,
} from '@/types/music';
/**
*
* @param id id
* @returns
*/
export const get163MusicList = async (id: string) => {
const res = await WebUIManager.proxy<Music163ListResponse>(
'https://wavesgame.top/playlist/track/all?id=' + id
);
// const res = await request.get<Music163ListResponse>(
// `https://wavesgame.top/playlist/track/all?id=${id}`
// )
if (res?.data?.code !== 200) {
throw new Error('获取歌曲列表失败');
}
return res.data;
};
/**
*
* @param ids id
* @returns
*/
export const getSongsURL = async (ids: number[]) => {
const _ids = ids.reduce((prev, cur, index) => {
const groupIndex = Math.floor(index / 10);
if (!prev[groupIndex]) {
prev[groupIndex] = [];
}
prev[groupIndex].push(cur);
return prev;
}, [] as number[][]);
const res = await Promise.all(
_ids.map(async (id) => {
const res = await WebUIManager.proxy<Music163URLResponse>(
`https://wavesgame.top/song/url?id=${id.join(',')}`
);
if (res?.data?.code !== 200) {
throw new Error('获取歌曲地址失败');
}
return res.data.data;
})
);
const result = res.reduce((prev, cur) => {
return prev.concat(...cur);
}, []);
return result;
};
/**
*
* @param id id
* @returns
*/
export const get163MusicListSongs = async (id: string) => {
const listRes = await get163MusicList(id);
const songs = listRes.songs.map((song) => song.id);
const songsRes = await getSongsURL(songs);
const finalMusic: FinalMusic[] = [];
for (let i = 0; i < listRes.songs.length; i++) {
const song = listRes.songs[i];
const music = songsRes.find((s) => s.id === song.id);
const songURL = music?.url;
if (songURL) {
finalMusic.push({
id: song.id,
url: songURL.replace(/http:\/\//, '//').replace(/https:\/\//, '//'),
title: song.name,
artist: song.ar.map((p) => p.name).join('/'),
cover: song.al.picUrl,
});
}
}
return finalMusic;
};
/**
*
* @param ids id
* @param currentId id
* @returns id
*/
export const getRandomMusic = (ids: number[], currentId: number): number => {
const randomIndex = Math.floor(Math.random() * ids.length);
const randomId = ids[randomIndex];
if (randomId === currentId) {
return getRandomMusic(ids, currentId);
}
return randomId;
};
/**
* id
* @param ids id
* @param currentId ID
* @param mode
*/
export const getNextMusic = (
musics: FinalMusic[],
currentId: number,
mode: PlayMode
): number => {
const ids = musics.map((music) => music.id);
if (mode === PlayMode.Loop) {
const currentIndex = ids.findIndex((id) => id === currentId);
const nextIndex = currentIndex + 1;
return ids[nextIndex] || ids[0];
}
if (mode === PlayMode.Random) {
return getRandomMusic(ids, currentId);
}
return currentId;
};

View File

@ -25,32 +25,18 @@ export default {
light: {
colors: {
primary: {
DEFAULT: '#FF7FAC', // 樱花粉
DEFAULT: '#f31260',
foreground: '#fff',
50: '#FFF0F5',
100: '#FFE4E9',
200: '#FFCDD9',
300: '#FF9EB5',
400: '#FF7FAC',
500: '#F33B7C',
600: '#C92462',
700: '#991B4B',
800: '#691233',
900: '#380A1B',
},
secondary: {
DEFAULT: '#88C0D0', // 冰霜蓝
foreground: '#fff',
50: '#F0F9FC',
100: '#D7F0F8',
200: '#AEE1F2',
300: '#88C0D0',
400: '#5E9FBF',
500: '#4C8DAE',
600: '#3A708C',
700: '#2A546A',
800: '#1A3748',
900: '#0B1B26',
50: '#fee7ef',
100: '#fdd0df',
200: '#faa0bf',
300: '#f871a0',
400: '#f54180',
500: '#f31260',
600: '#c20e4d',
700: '#920b3a',
800: '#610726',
900: '#310413',
},
danger: {
DEFAULT: '#DB3694',

File diff suppressed because it is too large Load Diff