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

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

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

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

@ -95,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 });
@ -118,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) {

View File

@ -16,7 +16,6 @@
} }
}, },
"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",

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,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') { 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

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

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

@ -22,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",
@ -49,7 +48,6 @@
"@monaco-editor/react": "4.7.0-rc.0", "@monaco-editor/react": "4.7.0-rc.0",
"@react-aria/visually-hidden": "^3.8.19", "@react-aria/visually-hidden": "^3.8.19",
"@reduxjs/toolkit": "^2.5.1", "@reduxjs/toolkit": "^2.5.1",
"@simplewebauthn/browser": "^13.2.2",
"@uidotdev/usehooks": "^2.4.1", "@uidotdev/usehooks": "^2.4.1",
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.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 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

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

@ -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 = () => { 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'>
<motion.div <Image
animate={{ className='overflow-hidden object-contain -top-42 h-[160%] -right-[30%] -rotate-45 pointer-events-none select-none -z-10 relative'
scale: [1, 1.2, 1], src={bkg_color}
rotate: [0, 90, 0], />
opacity: [0.3, 0.5, 0.3] </div>
}} <div className='fixed w-full h-full overflow-hidden -z-[0] hue-rotate-90 flex justify-start opacity-80'>
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }} <Image
className='absolute top-[-10%] left-[-10%] w-[500px] h-[500px] rounded-full bg-primary-200/40 blur-[100px]' className='relative -top-92 h-[180%] object-contain pointer-events-none rotate-90 select-none -z-10 top-44'
/> src={bkg_color}
<motion.div />
animate={{ </div>
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>
); );
}; };

View File

@ -1,6 +1,7 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
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';
@ -9,17 +10,18 @@ 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();
@ -31,68 +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 bg-background md:bg-transparent md:static shadow-md md:shadow-none rounded-r-md md:rounded-none' <div
)} className={clsx(
initial={{ width: 0 }} 'flex items-center font-bold',
animate={{ width: open ? '16rem' : 0 }} '!text-2xl shiny-text'
transition={{ )}
type: open ? 'spring' : 'tween', >
stiffness: 150, NapCat
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
? ( ? (
@ -105,6 +104,7 @@ const renderItems = (items: MenuItem[], children = false) => {
/> />
) )
} }
radius='full'
startContent={ startContent={
customIcons[item.label] customIcons[item.label]
? ( ? (
@ -147,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

@ -1,19 +1,13 @@
import { Card, CardBody, CardHeader } from '@heroui/card'; import { Card, CardBody, CardHeader } from '@heroui/card';
import { Button } from '@heroui/button';
import { Chip } from '@heroui/chip';
import { Spinner } from '@heroui/spinner'; import { Spinner } from '@heroui/spinner';
import { Tooltip } from '@heroui/tooltip';
import { useRequest } from 'ahooks'; 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 { IoLogoChrome, IoLogoOctocat } from 'react-icons/io';
import { RiMacFill } from 'react-icons/ri'; import { RiMacFill } from 'react-icons/ri';
import { useState } from 'react';
import toast from 'react-hot-toast';
import WebUIManager from '@/controllers/webui_manager'; import WebUIManager from '@/controllers/webui_manager';
import useDialog from '@/hooks/use-dialog';
export interface SystemInfoItemProps { 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 NapCatVersion = () => {
const { const {
data: packageData, data: packageData,
@ -287,7 +212,6 @@ const NapCatVersion = () => {
currentVersion currentVersion
) )
} }
endContent={<NewVersionTip currentVersion={currentVersion} />}
/> />
); );
}; };

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,12 +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 () { public static async UpdateNapCat () {
const { data } = await serverRequest.post<ServerResponse<any>>( const { data } = await serverRequest.post<ServerResponse<any>>(
'/UpdateNapCat/update', '/UpdateNapCat/update',
@ -212,35 +206,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;
}
} }

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

View File

@ -1,20 +1,21 @@
import { Card, CardBody, CardHeader } from '@heroui/card'; import { Card, CardBody } from '@heroui/card';
import { Chip } from '@heroui/chip'; import { Button } from '@heroui/button';
import { Divider } from '@heroui/divider';
import { Image } from '@heroui/image'; import { Image } from '@heroui/image';
import { Link } from '@heroui/link'; import { Link } from '@heroui/link';
import { Skeleton } from '@heroui/skeleton';
import { Spinner } from '@heroui/spinner'; import { Spinner } from '@heroui/spinner';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import { import { useMemo } from 'react';
BsCodeSlash, import { BsTelegram, BsTencentQq } from 'react-icons/bs';
BsCpu, import { IoDocument } from 'react-icons/io5';
BsGithub, import toast from 'react-hot-toast';
BsGlobe,
BsPlugin, import HoverTiltedCard from '@/components/hover_titled_card';
BsTelegram, import NapCatRepoInfo from '@/components/napcat_repo_info';
BsTencentQq import RotatingText from '@/components/rotating_text';
} from 'react-icons/bs';
import { IoDocument, IoRocketSharp } from 'react-icons/io5'; import { usePreloadImages } from '@/hooks/use-preload-images';
import { useTheme } from '@/hooks/use-theme';
import logo from '@/assets/images/logo.png'; import logo from '@/assets/images/logo.png';
import WebUIManager from '@/controllers/webui_manager'; import WebUIManager from '@/controllers/webui_manager';
@ -22,169 +23,229 @@ import WebUIManager from '@/controllers/webui_manager';
function VersionInfo () { function VersionInfo () {
const { data, loading, error } = useRequest(WebUIManager.GetNapCatVersion); 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 ( return (
<div className='flex items-center gap-2'> <div className='flex items-center gap-4'>
{error ? ( <div className='flex items-center gap-2 text-2xl font-bold'>
<Chip color="danger" variant="flat" size="sm">{error.message}</Chip> <div className='text-primary-500 drop-shadow-md'>NapCat</div>
) : loading ? ( {error
<Spinner size='sm' color="default" /> ? (
) : ( error.message
<div className="flex items-center gap-2"> )
<Chip size="sm" color="default" variant="flat" className="text-default-500">WebUI v0.0.6</Chip> : loading
<Chip size="sm" color="primary" variant="flat">Core {data?.version}</Chip> ? (
</div> <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> </div>
); );
} }
export default function AboutPage () { export default function AboutPage () {
const features = [ const { isDark } = useTheme();
{
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 links = [ const imageUrls = useMemo(
{ icon: <BsGithub />, name: 'GitHub', href: 'https://github.com/NapNeko/NapCatQQ' }, () => [
{ icon: <BsTelegram />, name: 'Telegram', href: 'https://t.me/napcatqq' }, 'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
{ icon: <BsTencentQq />, name: 'QQ 群 1', href: 'https://qm.qq.com/q/F9cgs1N3Mc' }, 'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=dark',
{ icon: <BsTencentQq />, name: 'QQ 群 2', href: 'https://qm.qq.com/q/hSt0u9PVn' }, 'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
{ icon: <IoDocument />, name: '文档', href: 'https://napcat.napneko.icu/' }, '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 ( return (
<div className='flex flex-col h-full w-full gap-6 p-2 md:p-6'> <>
<title> - NapCat WebUI</title> <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 gap-2"> <div className='flex flex-col md:flex-row items-center'>
<h1 className="text-2xl font-bold flex items-center gap-3 text-default-900"> <HoverTiltedCard imageSrc={logo} overlayContent='' />
<Image src={logo} alt="NapCat Logo" width={32} height={32} /> </div>
NapCat <div className='flex-1 flex flex-col gap-2 py-2'>
</h1> <VersionInfo />
<div className="flex items-center gap-4 text-small text-default-500"> <div className='space-y-1'>
<p> QQ </p> <p className='font-bold text-primary-400'>NapCat ?</p>
<Divider orientation="vertical" className="h-4" /> <p className='text-default-800'>
<VersionInfo /> TypeScript构建的Bot框架,,QQ
</div> Node模块提供给客户端的接口,Bot的功能.
</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
</p> </p>
<p> <p className='font-bold text-primary-400'></p>
NapCat OneBot 11 <p className='text-default-800'>
QQ
便使 OneBot HTTP /
WebSocket
QQ发送接口之类的接口
</p> </p>
</CardBody> </div>
</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>
<div className='flex flex-row gap-2 flex-wrap justify-around'>
{/* 右侧:信息与链接 */} <Card
<div className="space-y-6"> as={Link}
<Card shadow="sm" className={cardStyle}> shadow='sm'
<CardHeader className="pb-0 pt-4 px-4"> isPressable
<h2 className="text-lg font-bold"></h2> isExternal
</CardHeader> href='https://qm.qq.com/q/F9cgs1N3Mc'
<CardBody className="py-4"> >
<div className="flex flex-col gap-2"> <CardBody className='flex-row items-center gap-2'>
{links.map((link, idx) => ( <span className='p-2 rounded-small bg-primary-50 text-primary-500'>
<Link <BsTencentQq size={16} />
key={idx} </span>
isExternal <span>1</span>
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>
</CardBody> </CardBody>
</Card> </Card>
<Card shadow="sm" className={cardStyle}> <Card
<CardHeader className="pb-0 pt-4 px-4"> as={Link}
<h2 className="text-lg font-bold flex items-center gap-2"> shadow='sm'
<BsCpu /> isPressable
</h2> isExternal
</CardHeader> href='https://qm.qq.com/q/hSt0u9PVn'
<CardBody className="py-4"> >
<div className="flex flex-wrap gap-2"> <CardBody className='flex-row items-center gap-2'>
{['TypeScript', 'React', 'Vite', 'Node.js', 'Electron', 'HeroUI'].map((tech) => ( <span className='p-2 rounded-small bg-primary-50 text-primary-500'>
<Chip key={tech} size="sm" variant="flat" className="bg-default-100/50 text-default-600"> <BsTencentQq size={16} />
{tech} </span>
</Chip> <span>2</span>
))} </CardBody>
</div> </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> </CardBody>
</Card> </Card>
</div> </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 布局 */} <NapCatRepoInfo />
<div className="w-full text-center text-tiny text-default-400 py-4 mt-auto flex flex-col items-center gap-1"> </div>
<p className="flex items-center justify-center gap-1"> </section>
Made with <span className="text-danger"></span> by NapCat Team </>
</p>
<p>MIT License © {new Date().getFullYear()}</p>
</div>
</div>
); );
} }

View File

@ -1,6 +1,6 @@
import { Button } from '@heroui/button'; import { Input } from '@heroui/input';
import { useLocalStorage } from '@uidotdev/usehooks'; import { useLocalStorage } from '@uidotdev/usehooks';
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast'; 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 FileInput from '@/components/input/file_input';
import ImageInput from '@/components/input/image_input'; import ImageInput from '@/components/input/image_input';
import useMusic from '@/hooks/use-music';
import { siteConfig } from '@/config/site'; import { siteConfig } from '@/config/site';
import FileManager from '@/controllers/file_manager'; 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 WebUIConfigCard = () => {
const { const {
@ -40,6 +24,7 @@ const WebUIConfigCard = () => {
} = useForm<IConfig['webui']>({ } = useForm<IConfig['webui']>({
defaultValues: { defaultValues: {
background: '', background: '',
musicListID: '',
customIcons: {}, customIcons: {},
}, },
}); });
@ -49,33 +34,17 @@ const WebUIConfigCard = () => {
key.customIcons, key.customIcons,
{} {}
); );
const [registrationOptions, setRegistrationOptions] = useState<any>(null); const { setListId, listId } = useMusic();
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 reset = () => { const reset = () => {
setWebuiValue('musicListID', listId);
setWebuiValue('customIcons', customIcons); setWebuiValue('customIcons', customIcons);
setWebuiValue('background', b64img); setWebuiValue('background', b64img);
}; };
const onSubmit = handleWebuiSubmit((data) => { const onSubmit = handleWebuiSubmit((data) => {
try { try {
setListId(data.musicListID);
setCustomIcons(data.customIcons); setCustomIcons(data.customIcons);
setB64img(data.background); setB64img(data.background);
toast.success('保存成功'); toast.success('保存成功');
@ -87,7 +56,7 @@ const WebUIConfigCard = () => {
useEffect(() => { useEffect(() => {
reset(); reset();
}, [customIcons, b64img]); }, [listId, customIcons, b64img]);
return ( return (
<> <>
@ -123,6 +92,20 @@ const WebUIConfigCard = () => {
/> />
</div> </div>
</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 flex-col gap-2'>
<div className='flex-shrink-0 w-full'></div> <div className='flex-shrink-0 w-full'></div>
<Controller <Controller
@ -142,122 +125,6 @@ const WebUIConfigCard = () => {
/> />
))} ))}
</div> </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 <SaveButtons
onSubmit={onSubmit} onSubmit={onSubmit}
reset={reset} reset={reset}

View File

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

View File

@ -8,17 +8,15 @@ import { toast } from 'react-hot-toast';
import { IoKeyOutline } from 'react-icons/io5'; import { IoKeyOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import logo from '@/assets/images/logo.png';
import key from '@/const/key'; import key from '@/const/key';
import HoverEffectCard from '@/components/effect_card'; import HoverEffectCard from '@/components/effect_card';
import { title } from '@/components/primitives'; import { title } from '@/components/primitives';
import { ThemeSwitch } from '@/components/theme-switch'; import { ThemeSwitch } from '@/components/theme-switch';
import logo from '@/assets/images/logo.png';
import WebUIManager from '@/controllers/webui_manager'; import WebUIManager from '@/controllers/webui_manager';
import PureLayout from '@/layouts/pure'; import PureLayout from '@/layouts/pure';
import { motion } from 'motion/react';
export default function WebLoginPage () { export default function WebLoginPage () {
const urlSearchParams = new URLSearchParams(window.location.search); const urlSearchParams = new URLSearchParams(window.location.search);
@ -26,78 +24,7 @@ export default function WebLoginPage () {
const navigate = useNavigate(); const navigate = useNavigate();
const [tokenValue, setTokenValue] = useState<string>(token || ''); const [tokenValue, setTokenValue] = useState<string>(token || '');
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [isPasskeyLoading, setIsPasskeyLoading] = useState<boolean>(true); // 初始为true表示正在检查passkey
const [, setLocalToken] = useLocalStorage<string>(key.token, ''); 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 () => { const onSubmit = async () => {
if (!tokenValue) { if (!tokenValue) {
toast.error('请输入token'); toast.error('请输入token');
@ -121,7 +48,7 @@ export default function WebLoginPage () {
// 处理全局键盘事件 // 处理全局键盘事件
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !isLoading && !isPasskeyLoading) { if (e.key === 'Enter' && !isLoading) {
onSubmit(); onSubmit();
} }
}; };
@ -133,31 +60,19 @@ export default function WebLoginPage () {
return () => { return () => {
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
}; };
}, [tokenValue, isLoading, isPasskeyLoading]); // 依赖项包含用于登录的状态 }, [tokenValue, isLoading]); // 依赖项包含用于登录的状态
useEffect(() => { useEffect(() => {
// 如果URL中有token直接登录
if (token) { if (token) {
onSubmit(); onSubmit();
return;
} }
// 否则尝试passkey自动登录
tryPasskeyLogin().finally(() => {
setIsPasskeyLoading(false);
});
}, []); }, []);
return ( return (
<> <>
<title>WebUI登录 - NapCat WebUI</title> <title>WebUI登录 - NapCat WebUI</title>
<PureLayout> <PureLayout>
<motion.div <div className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'>
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'
>
<HoverEffectCard <HoverEffectCard
className='items-center gap-4 pt-0 pb-6 bg-default-50' className='items-center gap-4 pt-0 pb-6 bg-default-50'
maxXRotation={3} maxXRotation={3}
@ -177,11 +92,6 @@ export default function WebLoginPage () {
</CardHeader> </CardHeader>
<CardBody className='flex gap-5 py-5 px-5 md:px-10'> <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 <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
@ -225,7 +135,7 @@ export default function WebLoginPage () {
'!cursor-text', '!cursor-text',
], ],
}} }}
isDisabled={isLoading || isPasskeyLoading} isDisabled={isLoading}
label='Token' label='Token'
placeholder='请输入token' placeholder='请输入token'
radius='lg' radius='lg'
@ -264,7 +174,7 @@ export default function WebLoginPage () {
</Button> </Button>
</CardBody> </CardBody>
</HoverEffectCard> </HoverEffectCard>
</motion.div> </div>
</PureLayout> </PureLayout>
</> </>
); );

View File

@ -6,45 +6,15 @@
body { body {
font-family: font-family:
'Quicksand', 'Aa偷吃可爱长大的',
'Nunito', PingFang SC,
'Inter', Helvetica Neue,
-apple-system, Microsoft YaHei,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
'PingFang SC',
'Microsoft YaHei',
sans-serif !important; sans-serif !important;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
font-smooth: always; 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 { @layer components {
@ -64,29 +34,23 @@ h1, h2, h3, h4, h5, h6 {
} }
} }
::selection {
background-color: #ffcdba;
color: #fff;
}
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 8px;
height: 6px; height: 8px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background-color: transparent; background-color: transparent;
border-radius: 3px; -webkit-border-radius: 2em;
-moz-border-radius: 2em;
border-radius: 2em;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background-color: rgba(255, 182, 193, 0.4); /* 浅粉色滚动条 */ background-color: rgb(147, 147, 153, 0.5);
border-radius: 3px; -webkit-border-radius: 2em;
transition: all 0.3s; -moz-border-radius: 2em;
} border-radius: 2em;
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 127, 172, 0.6);
} }
.monaco-editor { .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: { light: {
colors: { colors: {
primary: { primary: {
DEFAULT: '#FF7FAC', // 樱花粉 DEFAULT: '#f31260',
foreground: '#fff', foreground: '#fff',
50: '#FFF0F5', 50: '#fee7ef',
100: '#FFE4E9', 100: '#fdd0df',
200: '#FFCDD9', 200: '#faa0bf',
300: '#FF9EB5', 300: '#f871a0',
400: '#FF7FAC', 400: '#f54180',
500: '#F33B7C', 500: '#f31260',
600: '#C92462', 600: '#c20e4d',
700: '#991B4B', 700: '#920b3a',
800: '#691233', 800: '#610726',
900: '#380A1B', 900: '#310413',
},
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',
}, },
danger: { danger: {
DEFAULT: '#DB3694', DEFAULT: '#DB3694',

File diff suppressed because it is too large Load Diff