mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-12-22 07:10:05 +08:00
Compare commits
No commits in common. "main" and "v4.9.73" have entirely different histories.
@ -43,7 +43,7 @@ _Modern protocol-side framework implemented based on NTQQ._
|
|||||||
|
|
||||||
**首次使用**请务必查看如下文档看使用教程
|
**首次使用**请务必查看如下文档看使用教程
|
||||||
|
|
||||||
> 项目非盈利,涉及 对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
||||||
|
|
||||||
## Link
|
## Link
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ export class NodeIDependsAdapter {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMSFSsoError (_code: number, _desc: string) {
|
onMSFSsoError (_args: unknown) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
32
packages/napcat-core/external/appid.json
vendored
32
packages/napcat-core/external/appid.json
vendored
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
36
packages/napcat-core/external/napi2native.json
vendored
36
packages/napcat-core/external/napi2native.json
vendored
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
36
packages/napcat-core/external/packet.json
vendored
36
packages/napcat-core/external/packet.json
vendored
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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 {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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}不存在`);
|
||||||
// 转换序号
|
// 转换序号
|
||||||
|
|||||||
@ -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}不存在`);
|
||||||
// 转换序号
|
// 转换序号
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
2
packages/napcat-webui-frontend/.gitignore
vendored
2
packages/napcat-webui-frontend/.gitignore
vendored
@ -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
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
425
packages/napcat-webui-frontend/src/components/audio_player.tsx
Normal file
425
packages/napcat-webui-frontend/src/components/audio_player.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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={{
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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} />}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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[],
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
@ -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 }) => {
|
||||||
|
|||||||
91
packages/napcat-webui-frontend/src/contexts/songs.tsx
Normal file
91
packages/napcat-webui-frontend/src/contexts/songs.tsx
Normal 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;
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
11
packages/napcat-webui-frontend/src/hooks/use-music.ts
Normal file
11
packages/napcat-webui-frontend/src/hooks/use-music.ts
Normal 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;
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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">跳转 →</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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后,您可以更便捷地登录WebUI,无需每次输入token
|
|
||||||
</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}
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
122
packages/napcat-webui-frontend/src/utils/music.ts
Normal file
122
packages/napcat-webui-frontend/src/utils/music.ts
Normal 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;
|
||||||
|
};
|
||||||
@ -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',
|
||||||
|
|||||||
12167
pnpm-lock.yaml
12167
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user