From bf17abacf37dc59e7f39a0c4258ee86804a1f11a Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Tue, 31 Mar 2026 12:53:19 +0800
Subject: [PATCH 01/18] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B3=A8=E5=86=8C?=
=?UTF-8?q?=E9=82=80=E8=AF=B7=E7=A0=81=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/admin/page.tsx | 527 +++++++++++++++++------------
src/app/api/admin/site/route.ts | 8 +
src/app/api/register/route.ts | 22 +-
src/app/api/server-config/route.ts | 1 +
src/app/layout.tsx | 3 +
src/app/register/page.tsx | 30 ++
src/lib/admin.types.ts | 2 +
src/lib/config.ts | 40 +++
8 files changed, 409 insertions(+), 224 deletions(-)
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 52c4af0..4721044 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -358,6 +358,8 @@ interface SiteConfig {
MagnetAcgripReverseProxy?: string;
EnableComments: boolean;
EnableRegistration?: boolean;
+ RequireRegistrationInviteCode?: boolean;
+ RegistrationInviteCode?: string;
RegistrationRequireTurnstile?: boolean;
LoginRequireTurnstile?: boolean;
TurnstileSiteKey?: string;
@@ -9054,6 +9056,8 @@ const RegistrationConfigComponent = ({
const [showEnableRegistrationModal, setShowEnableRegistrationModal] = useState(false);
const [registrationSettings, setRegistrationSettings] = useState<{
EnableRegistration: boolean;
+ RequireRegistrationInviteCode: boolean;
+ RegistrationInviteCode: string;
RegistrationRequireTurnstile: boolean;
LoginRequireTurnstile: boolean;
TurnstileSiteKey: string;
@@ -9071,6 +9075,8 @@ const RegistrationConfigComponent = ({
OIDCMinTrustLevel: number;
}>({
EnableRegistration: false,
+ RequireRegistrationInviteCode: false,
+ RegistrationInviteCode: '',
RegistrationRequireTurnstile: false,
LoginRequireTurnstile: false,
TurnstileSiteKey: '',
@@ -9092,6 +9098,8 @@ const RegistrationConfigComponent = ({
if (config?.SiteConfig) {
setRegistrationSettings({
EnableRegistration: config.SiteConfig.EnableRegistration || false,
+ RequireRegistrationInviteCode: config.SiteConfig.RequireRegistrationInviteCode || false,
+ RegistrationInviteCode: config.SiteConfig.RegistrationInviteCode || '',
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
@@ -9140,10 +9148,18 @@ const RegistrationConfigComponent = ({
throw new Error('配置未加载');
}
+ if (
+ registrationSettings.RequireRegistrationInviteCode &&
+ !registrationSettings.RegistrationInviteCode.trim()
+ ) {
+ throw new Error('已开启注册邀请码时,邀请码不能为空');
+ }
+
// 合并站点配置和注册配置
const updatedSiteConfig = {
...config.SiteConfig,
...registrationSettings,
+ RegistrationInviteCode: registrationSettings.RegistrationInviteCode.trim(),
};
const resp = await fetch('/api/admin/site', {
@@ -9182,205 +9198,269 @@ const RegistrationConfigComponent = ({
注册配置
- {/* 开启注册 */}
-
-
-
-
+
+
+ 基础注册设置
+
+
+
+
+
+
+
+
+ 开启后登录页面将显示注册按钮,允许用户自行注册账号。
+
+
+
+
+
+
+
+ 新注册的用户将自动分配到选中的用户组,选择"无用户组"为无限制
+
+
-
- 开启后登录页面将显示注册按钮,允许用户自行注册账号。
-
-
+
- {/* 注册启用Cloudflare Turnstile */}
-
-
-
-
- {/* 登录启用Cloudflare Turnstile */}
-
-
-
-
- setRegistrationSettings((prev) => ({
- ...prev,
- LoginRequireTurnstile: !prev.LoginRequireTurnstile,
- }))
- }
- className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
- !registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey
- ? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
- : registrationSettings.LoginRequireTurnstile
- ? buttonStyles.toggleOn
- : buttonStyles.toggleOff
- }`}
- >
-
+
+
+
+ setRegistrationSettings((prev) => ({
+ ...prev,
+ RegistrationRequireTurnstile: !prev.RegistrationRequireTurnstile,
+ }))
+ }
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
+ !registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey
+ ? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
+ : registrationSettings.RegistrationRequireTurnstile
+ ? buttonStyles.toggleOn
+ : buttonStyles.toggleOff
+ }`}
+ >
+
+
+
+
+ 开启后注册时需要通过Cloudflare Turnstile人机验证。
+ {(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
+ 需要先配置Site Key和Secret Key才能启用。
+ )}
+
+
+
+
+
+
+
+ setRegistrationSettings((prev) => ({
+ ...prev,
+ LoginRequireTurnstile: !prev.LoginRequireTurnstile,
+ }))
+ }
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
+ !registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey
+ ? 'opacity-50 cursor-not-allowed bg-gray-300 dark:bg-gray-600'
+ : registrationSettings.LoginRequireTurnstile
+ ? buttonStyles.toggleOn
+ : buttonStyles.toggleOff
+ }`}
+ >
+
+
+
+
+ 开启后登录时需要通过Cloudflare Turnstile人机验证。
+ {(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
+ 需要先配置Site Key和Secret Key才能启用。
+ )}
+
+
+
+
+
+
+ setRegistrationSettings((prev) => ({
+ ...prev,
+ TurnstileSiteKey: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
-
+
+ 在Cloudflare Dashboard中获取的Site Key(公钥)
+
+
+
+
+
+
+ setRegistrationSettings((prev) => ({
+ ...prev,
+ TurnstileSecretKey: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ />
+
+ 在Cloudflare Dashboard中获取的Secret Key(私钥),用于服务端验证
+
+
-
- 开启后登录时需要通过Cloudflare Turnstile人机验证。
- {(!registrationSettings.TurnstileSiteKey || !registrationSettings.TurnstileSecretKey) && (
- 需要先配置Site Key和Secret Key才能启用。
- )}
-
-
-
- {/* Cloudflare Turnstile Site Key */}
-
-
-
- setRegistrationSettings((prev) => ({
- ...prev,
- TurnstileSiteKey: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- 在Cloudflare Dashboard中获取的Site Key(公钥)
-
-
-
- {/* Cloudflare Turnstile Secret Key */}
-
-
-
- setRegistrationSettings((prev) => ({
- ...prev,
- TurnstileSecretKey: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- 在Cloudflare Dashboard中获取的Secret Key(私钥),用于服务端验证
-
-
-
- {/* 默认用户组 */}
-
-
-
-
- 新注册的用户将自动分配到选中的用户组,选择"无用户组"为无限制
-
-
+
{/* OIDC配置 */}
-
-
+
+
OIDC配置
-
-
- {/* 启用OIDC登录 */}
-
+
+
+ {/* 启用OIDC登录 */}
+
+
- {/* 启用OIDC注册 */}
-
+ {/* 启用OIDC注册 */}
+
+
- {/* OIDC Issuer */}
-
+ {/* OIDC Issuer */}
+
@@ -9514,10 +9594,10 @@ const RegistrationConfigComponent = ({
OIDC提供商的Issuer URL,填写后可点击"自动发现"按钮自动获取端点配置
-
+
- {/* Authorization Endpoint */}
-
+ {/* Authorization Endpoint */}
+
@@ -9536,10 +9616,10 @@ const RegistrationConfigComponent = ({
用户授权的端点URL
-
+
- {/* Token Endpoint */}
-
+ {/* Token Endpoint */}
+
@@ -9558,10 +9638,10 @@ const RegistrationConfigComponent = ({
交换授权码获取token的端点URL
-
+
- {/* UserInfo Endpoint */}
-
+ {/* UserInfo Endpoint */}
+
@@ -9580,10 +9660,10 @@ const RegistrationConfigComponent = ({
获取用户信息的端点URL
-
+
- {/* OIDC Client ID */}
-
+ {/* OIDC Client ID */}
+
@@ -9602,10 +9682,10 @@ const RegistrationConfigComponent = ({
在OIDC提供商处注册应用后获得的Client ID
-
+
- {/* OIDC Client Secret */}
-
+ {/* OIDC Client Secret */}
+
@@ -9624,10 +9704,10 @@ const RegistrationConfigComponent = ({
在OIDC提供商处注册应用后获得的Client Secret
-
+
- {/* OIDC Redirect URI - 只读显示 */}
-
+ {/* OIDC Redirect URI - 只读显示 */}
+
@@ -9657,10 +9737,10 @@ const RegistrationConfigComponent = ({
这是系统自动生成的回调地址,基于环境变量SITE_BASE。请在OIDC提供商(如Keycloak、Auth0等)的应用配置中添加此地址作为允许的重定向URI
-
+
- {/* OIDC登录按钮文字 */}
-
+ {/* OIDC登录按钮文字 */}
+
@@ -9679,10 +9759,10 @@ const RegistrationConfigComponent = ({
自定义OIDC登录按钮显示的文字,如"使用企业账号登录"、"使用SSO登录"等。留空则显示默认文字"使用OIDC登录"
-
+
- {/* OIDC最低信任等级 */}
-
+ {/* OIDC最低信任等级 */}
+
@@ -9700,11 +9780,12 @@ const RegistrationConfigComponent = ({
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
-
- 仅LinuxDo网站有效。设置为0时不判断,1-4表示最低信任等级要求
-
+
+ 仅LinuxDo网站有效。设置为0时不判断,1-4表示最低信任等级要求
+
+
-
+
{/* 操作按钮 */}
diff --git a/src/app/api/admin/site/route.ts b/src/app/api/admin/site/route.ts
index 6619bff..75ce279 100644
--- a/src/app/api/admin/site/route.ts
+++ b/src/app/api/admin/site/route.ts
@@ -58,6 +58,8 @@ export async function POST(request: NextRequest) {
CustomAdFilterCode,
CustomAdFilterVersion,
EnableRegistration,
+ RequireRegistrationInviteCode,
+ RegistrationInviteCode,
RegistrationRequireTurnstile,
LoginRequireTurnstile,
TurnstileSiteKey,
@@ -103,6 +105,8 @@ export async function POST(request: NextRequest) {
CustomAdFilterCode?: string;
CustomAdFilterVersion?: number;
EnableRegistration?: boolean;
+ RequireRegistrationInviteCode?: boolean;
+ RegistrationInviteCode?: string;
RegistrationRequireTurnstile?: boolean;
LoginRequireTurnstile?: boolean;
TurnstileSiteKey?: string;
@@ -148,6 +152,8 @@ export async function POST(request: NextRequest) {
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
(EnableRegistration !== undefined && typeof EnableRegistration !== 'boolean') ||
+ (RequireRegistrationInviteCode !== undefined && typeof RequireRegistrationInviteCode !== 'boolean') ||
+ (RegistrationInviteCode !== undefined && typeof RegistrationInviteCode !== 'string') ||
(RegistrationRequireTurnstile !== undefined && typeof RegistrationRequireTurnstile !== 'boolean') ||
(LoginRequireTurnstile !== undefined && typeof LoginRequireTurnstile !== 'boolean') ||
(TurnstileSiteKey !== undefined && typeof TurnstileSiteKey !== 'string') ||
@@ -208,6 +214,8 @@ export async function POST(request: NextRequest) {
CustomAdFilterCode,
CustomAdFilterVersion,
EnableRegistration,
+ RequireRegistrationInviteCode,
+ RegistrationInviteCode,
RegistrationRequireTurnstile,
LoginRequireTurnstile,
TurnstileSiteKey,
diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts
index 543df9f..ea0ad05 100644
--- a/src/app/api/register/route.ts
+++ b/src/app/api/register/route.ts
@@ -60,7 +60,7 @@ export async function POST(req: NextRequest) {
);
}
- const { username, password, turnstileToken } = await req.json();
+ const { username, password, inviteCode, turnstileToken } = await req.json();
// 验证输入
if (!username || typeof username !== 'string') {
@@ -69,6 +69,9 @@ export async function POST(req: NextRequest) {
if (!password || typeof password !== 'string') {
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
}
+ if (inviteCode !== undefined && typeof inviteCode !== 'string') {
+ return NextResponse.json({ error: '邀请码格式错误' }, { status: 400 });
+ }
// 验证用户名格式(只允许字母、数字、下划线,长度3-20)
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
@@ -94,6 +97,23 @@ export async function POST(req: NextRequest) {
);
}
+ if (siteConfig.RequireRegistrationInviteCode) {
+ const expectedInviteCode = (siteConfig.RegistrationInviteCode || '').trim();
+ if (!expectedInviteCode) {
+ return NextResponse.json(
+ { error: '服务器未配置邀请码' },
+ { status: 500 }
+ );
+ }
+
+ if (!inviteCode || inviteCode.trim() !== expectedInviteCode) {
+ return NextResponse.json(
+ { error: '邀请码错误' },
+ { status: 400 }
+ );
+ }
+ }
+
// 获取用户名锁,防止并发注册
let releaseLock: (() => void) | null = null;
try {
diff --git a/src/app/api/server-config/route.ts b/src/app/api/server-config/route.ts
index def5431..f9b9ff0 100644
--- a/src/app/api/server-config/route.ts
+++ b/src/app/api/server-config/route.ts
@@ -50,6 +50,7 @@ export async function GET(request: NextRequest) {
WatchRoom: watchRoomConfig,
EnableOfflineDownload: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
EnableRegistration: config.SiteConfig.EnableRegistration || false,
+ RequireRegistrationInviteCode: config.SiteConfig.RequireRegistrationInviteCode || false,
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index cf54887..22eb3a6 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -75,6 +75,7 @@ export default async function RootLayout({
let progressThumbPresetId = '';
let progressThumbCustomUrl = '';
let enableRegistration = false;
+ let requireRegistrationInviteCode = false;
let loginRequireTurnstile = false;
let registrationRequireTurnstile = false;
let turnstileSiteKey = '';
@@ -125,6 +126,7 @@ export default async function RootLayout({
progressThumbPresetId = config.ThemeConfig?.progressThumbPresetId || '';
progressThumbCustomUrl = config.ThemeConfig?.progressThumbCustomUrl || '';
enableRegistration = config.SiteConfig.EnableRegistration || false;
+ requireRegistrationInviteCode = config.SiteConfig.RequireRegistrationInviteCode || false;
loginRequireTurnstile = config.SiteConfig.LoginRequireTurnstile || false;
registrationRequireTurnstile = config.SiteConfig.RegistrationRequireTurnstile || false;
turnstileSiteKey = config.SiteConfig.TurnstileSiteKey || '';
@@ -195,6 +197,7 @@ export default async function RootLayout({
PROGRESS_THUMB_PRESET_ID: progressThumbPresetId,
PROGRESS_THUMB_CUSTOM_URL: progressThumbCustomUrl,
ENABLE_REGISTRATION: enableRegistration,
+ REQUIRE_REGISTRATION_INVITE_CODE: requireRegistrationInviteCode,
LOGIN_REQUIRE_TURNSTILE: loginRequireTurnstile,
REGISTRATION_REQUIRE_TURNSTILE: registrationRequireTurnstile,
TURNSTILE_SITE_KEY: turnstileSiteKey,
diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx
index 0e19786..030a323 100644
--- a/src/app/register/page.tsx
+++ b/src/app/register/page.tsx
@@ -73,6 +73,7 @@ function RegisterPageClient() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
+ const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
@@ -108,6 +109,7 @@ function RegisterPageClient() {
// 设置站点配置
const config = {
EnableRegistration: runtimeConfig?.ENABLE_REGISTRATION || false,
+ RequireRegistrationInviteCode: runtimeConfig?.REQUIRE_REGISTRATION_INVITE_CODE || false,
RegistrationRequireTurnstile: runtimeConfig?.REGISTRATION_REQUIRE_TURNSTILE || false,
TurnstileSiteKey: runtimeConfig?.TURNSTILE_SITE_KEY || '',
};
@@ -167,6 +169,11 @@ function RegisterPageClient() {
return;
}
+ if (siteConfig?.RequireRegistrationInviteCode && !inviteCode.trim()) {
+ setError('请输入邀请码');
+ return;
+ }
+
if (password !== confirmPassword) {
setError('两次输入的密码不一致');
return;
@@ -191,6 +198,7 @@ function RegisterPageClient() {
body: JSON.stringify({
username,
password,
+ inviteCode: siteConfig?.RequireRegistrationInviteCode ? inviteCode.trim() : undefined,
turnstileToken: siteConfig?.RegistrationRequireTurnstile ? turnstileToken : undefined,
}),
});
@@ -340,6 +348,27 @@ function RegisterPageClient() {
+ {siteConfig?.RequireRegistrationInviteCode && (
+
+
+
+
+
+
+
setInviteCode(e.target.value)}
+ />
+
+
+ )}
+
{/* Cloudflare Turnstile */}
{siteConfig?.RegistrationRequireTurnstile && siteConfig?.TurnstileSiteKey && (
@@ -354,6 +383,7 @@ function RegisterPageClient() {
type='submit'
disabled={
!username || !password || !confirmPassword || loading ||
+ (siteConfig?.RequireRegistrationInviteCode && !inviteCode.trim()) ||
(siteConfig?.RegistrationRequireTurnstile && !turnstileToken)
}
className='inline-flex w-full justify-center rounded-lg bg-green-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-green-600 hover:to-blue-600 disabled:cursor-not-allowed disabled:opacity-50'
diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts
index eb74fc8..117f797 100644
--- a/src/lib/admin.types.ts
+++ b/src/lib/admin.types.ts
@@ -42,6 +42,8 @@ export interface AdminConfig {
CustomAdFilterVersion?: number; // 代码版本号(时间戳)
// 注册相关配置
EnableRegistration?: boolean; // 开启注册
+ RequireRegistrationInviteCode?: boolean; // 注册时要求邀请码
+ RegistrationInviteCode?: string; // 通用注册邀请码
RegistrationRequireTurnstile?: boolean; // 注册启用Cloudflare Turnstile
LoginRequireTurnstile?: boolean; // 登录启用Cloudflare Turnstile
TurnstileSiteKey?: string; // Cloudflare Turnstile Site Key
diff --git a/src/lib/config.ts b/src/lib/config.ts
index 6216130..25090aa 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -263,6 +263,14 @@ async function getInitConfig(configFile: string, subConfig: {
MagnetAcgripReverseProxy: '',
// 评论功能开关
EnableComments: false,
+ EnableRegistration: false,
+ RequireRegistrationInviteCode: false,
+ RegistrationInviteCode: '',
+ RegistrationRequireTurnstile: false,
+ LoginRequireTurnstile: false,
+ TurnstileSiteKey: '',
+ TurnstileSecretKey: '',
+ DefaultUserTags: [],
},
UserConfig: {
Users: [],
@@ -442,6 +450,14 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
MagnetDmhyReverseProxy: '',
MagnetAcgripReverseProxy: '',
EnableComments: false,
+ EnableRegistration: false,
+ RequireRegistrationInviteCode: false,
+ RegistrationInviteCode: '',
+ RegistrationRequireTurnstile: false,
+ LoginRequireTurnstile: false,
+ TurnstileSiteKey: '',
+ TurnstileSecretKey: '',
+ DefaultUserTags: [],
};
}
// 确保弹幕配置存在
@@ -455,6 +471,30 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
if (adminConfig.SiteConfig.EnableComments === undefined) {
adminConfig.SiteConfig.EnableComments = false;
}
+ if (adminConfig.SiteConfig.EnableRegistration === undefined) {
+ adminConfig.SiteConfig.EnableRegistration = false;
+ }
+ if (adminConfig.SiteConfig.RequireRegistrationInviteCode === undefined) {
+ adminConfig.SiteConfig.RequireRegistrationInviteCode = false;
+ }
+ if (adminConfig.SiteConfig.RegistrationInviteCode === undefined) {
+ adminConfig.SiteConfig.RegistrationInviteCode = '';
+ }
+ if (adminConfig.SiteConfig.RegistrationRequireTurnstile === undefined) {
+ adminConfig.SiteConfig.RegistrationRequireTurnstile = false;
+ }
+ if (adminConfig.SiteConfig.LoginRequireTurnstile === undefined) {
+ adminConfig.SiteConfig.LoginRequireTurnstile = false;
+ }
+ if (adminConfig.SiteConfig.TurnstileSiteKey === undefined) {
+ adminConfig.SiteConfig.TurnstileSiteKey = '';
+ }
+ if (adminConfig.SiteConfig.TurnstileSecretKey === undefined) {
+ adminConfig.SiteConfig.TurnstileSecretKey = '';
+ }
+ if (adminConfig.SiteConfig.DefaultUserTags === undefined) {
+ adminConfig.SiteConfig.DefaultUserTags = [];
+ }
if (adminConfig.SiteConfig.PansouKeywordBlocklist === undefined) {
adminConfig.SiteConfig.PansouKeywordBlocklist = '';
}
From f9480a13e77868b70ffc9eeec1b4070fa3da1652 Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Wed, 1 Apr 2026 17:26:59 +0800
Subject: [PATCH 02/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BC=B9=E5=B9=95?=
=?UTF-8?q?=E9=80=89=E9=9B=86=E5=88=86=E7=BB=84=E6=97=A0=E6=B3=95=E9=BC=A0?=
=?UTF-8?q?=E6=A0=87=E6=BB=91=E8=BD=AE=E6=BB=9A=E5=8A=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 3 ++
src/components/DanmakuPanel.tsx | 69 ++++++++++++++++++++++++++++++++-
2 files changed, 71 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 1437f13..f45089d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,3 +63,6 @@ public/workbox-*.js.map
*.db
*.db-shm
*.db-wal
+
+# local scripts
+scripts/tvbox/
diff --git a/src/components/DanmakuPanel.tsx b/src/components/DanmakuPanel.tsx
index e4eeed4..1984521 100644
--- a/src/components/DanmakuPanel.tsx
+++ b/src/components/DanmakuPanel.tsx
@@ -35,9 +35,12 @@ export default function DanmakuPanel({
const [searchError, setSearchError] = useState(null);
const initializedRef = useRef(false); // 标记是否已初始化过
const fileInputRef = useRef(null);
+ const episodeGroupContainerRef = useRef(null);
+ const episodeGroupButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [episodeGroupIndex, setEpisodeGroupIndex] = useState(0);
const [episodeDescending, setEpisodeDescending] = useState(false);
const [episodeViewMode, setEpisodeViewMode] = useState<'list' | 'grid'>('list');
+ const [isEpisodeGroupHovered, setIsEpisodeGroupHovered] = useState(false);
const episodesPerGroup = 50;
// 搜索弹幕
@@ -230,6 +233,62 @@ export default function DanmakuPanel({
return String(episodeNumber);
}, []);
+ const preventPageScroll = useCallback((e: WheelEvent) => {
+ if (isEpisodeGroupHovered) {
+ e.preventDefault();
+ }
+ }, [isEpisodeGroupHovered]);
+
+ const handleEpisodeGroupWheel = useCallback((e: WheelEvent) => {
+ if (!isEpisodeGroupHovered || !episodeGroupContainerRef.current) {
+ return;
+ }
+
+ const container = episodeGroupContainerRef.current;
+ if (container.scrollWidth <= container.clientWidth) {
+ return;
+ }
+
+ e.preventDefault();
+ container.scrollBy({
+ left: e.deltaY * 2,
+ behavior: 'smooth',
+ });
+ }, [isEpisodeGroupHovered]);
+
+ useEffect(() => {
+ if (isEpisodeGroupHovered) {
+ document.addEventListener('wheel', preventPageScroll, { passive: false });
+ document.addEventListener('wheel', handleEpisodeGroupWheel, { passive: false });
+ } else {
+ document.removeEventListener('wheel', preventPageScroll);
+ document.removeEventListener('wheel', handleEpisodeGroupWheel);
+ }
+
+ return () => {
+ document.removeEventListener('wheel', preventPageScroll);
+ document.removeEventListener('wheel', handleEpisodeGroupWheel);
+ };
+ }, [handleEpisodeGroupWheel, isEpisodeGroupHovered, preventPageScroll]);
+
+ useEffect(() => {
+ const btn = episodeGroupButtonRefs.current[displayEpisodeGroupIndex];
+ const container = episodeGroupContainerRef.current;
+ if (!btn || !container) {
+ return;
+ }
+
+ const containerRect = container.getBoundingClientRect();
+ const btnRect = btn.getBoundingClientRect();
+ const btnLeft = btnRect.left - containerRect.left + container.scrollLeft;
+ const targetScrollLeft = btnLeft - (containerRect.width - btnRect.width) / 2;
+
+ container.scrollTo({
+ left: targetScrollLeft,
+ behavior: 'smooth',
+ });
+ }, [displayEpisodeGroupIndex]);
+
return (
{/* 搜索区域 - 固定在顶部 */}
@@ -348,12 +407,20 @@ export default function DanmakuPanel({
{!isLoadingEpisodes && episodes.length > 0 && (
-
+
setIsEpisodeGroupHovered(true)}
+ onMouseLeave={() => setIsEpisodeGroupHovered(false)}
+ >
{episodeGroups.map((label, idx) => {
const isActive = idx === displayEpisodeGroupIndex;
return (
{
+ episodeGroupButtonRefs.current[idx] = el;
+ }}
onClick={() =>
setEpisodeGroupIndex(
episodeDescending ? episodeGroupCount - 1 - idx : idx
From 7757a331f88e6d5dff4c319a30fdcba07a143730 Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Thu, 2 Apr 2026 14:36:35 +0800
Subject: [PATCH 03/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=AB=98=E7=BA=A7?=
=?UTF-8?q?=E6=8E=A8=E8=8D=90=E6=8A=A5=E9=94=99=E6=97=A0=E9=99=90=E5=88=B7?=
=?UTF-8?q?=E6=96=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/advanced-recommendation/page.tsx | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/app/advanced-recommendation/page.tsx b/src/app/advanced-recommendation/page.tsx
index 08bba6c..5793a28 100644
--- a/src/app/advanced-recommendation/page.tsx
+++ b/src/app/advanced-recommendation/page.tsx
@@ -110,6 +110,7 @@ export default function AdvancedRecommendationPage() {
setHasMore(Number(data.page || page) < Number(data.pageCount || 1));
} catch (err) {
setError(err instanceof Error ? err.message : '获取推荐失败');
+ setHasMore(false);
} finally {
setIsLoadingVideos(false);
}
@@ -119,7 +120,7 @@ export default function AdvancedRecommendationPage() {
}, [selectedSource, page]);
useEffect(() => {
- if (!loadMoreRef.current || !hasMore || isLoadingVideos) return;
+ if (!loadMoreRef.current || !hasMore || isLoadingVideos || !!error) return;
const observer = new IntersectionObserver(
(entries) => {
@@ -132,7 +133,7 @@ export default function AdvancedRecommendationPage() {
observer.observe(loadMoreRef.current);
return () => observer.disconnect();
- }, [hasMore, isLoadingVideos]);
+ }, [error, hasMore, isLoadingVideos]);
return (
From 97d479786b293d7a9272f933d77bf56a9b82f28c Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Fri, 3 Apr 2026 10:34:52 +0800
Subject: [PATCH 04/18] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=BC=B9=E5=B9=95?=
=?UTF-8?q?=E5=86=85=E7=BD=AE=E6=BA=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/admin/page.tsx | 138 +++++++++++++++++---------
src/app/api/admin/site/route.ts | 6 ++
src/app/api/danmaku/comment/route.ts | 9 +-
src/app/api/danmaku/episodes/route.ts | 9 +-
src/app/api/danmaku/match/route.ts | 9 +-
src/app/api/danmaku/search/route.ts | 9 +-
src/lib/admin.types.ts | 1 +
src/lib/config.ts | 21 +++-
src/lib/danmaku/config.ts | 19 ++++
9 files changed, 145 insertions(+), 76 deletions(-)
create mode 100644 src/lib/danmaku/config.ts
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 4721044..b9700ce 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -341,6 +341,7 @@ interface SiteConfig {
DoubanImageProxy: string;
DisableYellowFilter: boolean;
FluidSearch: boolean;
+ DanmakuSourceType?: 'builtin' | 'custom';
DanmakuApiBase: string;
DanmakuApiToken: string;
TMDBApiKey?: string;
@@ -7923,7 +7924,8 @@ const SiteConfigComponent = ({
DoubanImageProxy: '',
DisableYellowFilter: false,
FluidSearch: true,
- DanmakuApiBase: 'http://localhost:9321',
+ DanmakuSourceType: 'builtin',
+ DanmakuApiBase: 'https://mtvpls-danmu.netlify.app/87654321',
DanmakuApiToken: '87654321',
TMDBApiKey: '',
TMDBProxy: '',
@@ -8018,6 +8020,7 @@ const SiteConfigComponent = ({
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
FluidSearch: config.SiteConfig.FluidSearch || true,
+ DanmakuSourceType: config.SiteConfig.DanmakuSourceType || 'custom',
DanmakuApiBase:
config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
@@ -8567,57 +8570,102 @@ const SiteConfigComponent = ({
弹幕配置
- {/* 弹幕 API 地址 */}
-
-
-
+
+
setSiteSettings((prev) => ({
...prev,
- DanmakuApiBase: e.target.value,
+ DanmakuSourceType: 'builtin',
}))
}
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- 弹幕服务器的 API 地址,默认为 http://localhost:9321。API部署参考
-
- danmu_api
-
-
+ className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
+ siteSettings.DanmakuSourceType !== 'custom'
+ ? 'bg-white text-green-600 shadow-sm dark:bg-gray-700 dark:text-green-400'
+ : 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white'
+ }`}
+ >
+ 内置源
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ DanmakuSourceType: 'custom',
+ }))
+ }
+ className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
+ siteSettings.DanmakuSourceType === 'custom'
+ ? 'bg-white text-green-600 shadow-sm dark:bg-gray-700 dark:text-green-400'
+ : 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white'
+ }`}
+ >
+ 自定义源
+
- {/* 弹幕 API Token */}
-
-
-
- setSiteSettings((prev) => ({
- ...prev,
- DanmakuApiToken: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- 弹幕服务器的访问令牌,默认为 87654321
+ {siteSettings.DanmakuSourceType !== 'custom' && (
+
+ ⚠️ 内置弹幕源为多人共享服务,稳定性可能受使用高峰影响,建议自行部署后使用自定义源。
-
+ )}
+
+ {siteSettings.DanmakuSourceType === 'custom' && (
+ <>
+ {/* 弹幕 API 地址 */}
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ DanmakuApiBase: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ />
+
+ 自定义弹幕服务器的 API 地址。API部署参考
+
+ danmu_api
+
+
+
+
+ {/* 弹幕 API Token */}
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ DanmakuApiToken: e.target.value,
+ }))
+ }
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
+ />
+
+ 自定义弹幕服务器的访问令牌,默认为 87654321
+
+
+ >
+ )}
diff --git a/src/app/api/admin/site/route.ts b/src/app/api/admin/site/route.ts
index 75ce279..b51a4f5 100644
--- a/src/app/api/admin/site/route.ts
+++ b/src/app/api/admin/site/route.ts
@@ -39,6 +39,7 @@ export async function POST(request: NextRequest) {
DoubanImageProxy,
DisableYellowFilter,
FluidSearch,
+ DanmakuSourceType,
DanmakuApiBase,
DanmakuApiToken,
TMDBApiKey,
@@ -86,6 +87,7 @@ export async function POST(request: NextRequest) {
DoubanImageProxy: string;
DisableYellowFilter: boolean;
FluidSearch: boolean;
+ DanmakuSourceType?: 'builtin' | 'custom';
DanmakuApiBase: string;
DanmakuApiToken: string;
TMDBApiKey?: string;
@@ -136,6 +138,9 @@ export async function POST(request: NextRequest) {
typeof DoubanImageProxy !== 'string' ||
typeof DisableYellowFilter !== 'boolean' ||
typeof FluidSearch !== 'boolean' ||
+ (DanmakuSourceType !== undefined &&
+ DanmakuSourceType !== 'builtin' &&
+ DanmakuSourceType !== 'custom') ||
typeof DanmakuApiBase !== 'string' ||
typeof DanmakuApiToken !== 'string' ||
(TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') ||
@@ -195,6 +200,7 @@ export async function POST(request: NextRequest) {
DoubanImageProxy,
DisableYellowFilter,
FluidSearch,
+ DanmakuSourceType,
DanmakuApiBase,
DanmakuApiToken,
TMDBApiKey,
diff --git a/src/app/api/danmaku/comment/route.ts b/src/app/api/danmaku/comment/route.ts
index c56059a..4c754f3 100644
--- a/src/app/api/danmaku/comment/route.ts
+++ b/src/app/api/danmaku/comment/route.ts
@@ -2,6 +2,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
+import { getDanmakuApiBaseUrl } from '@/lib/danmaku/config';
export const runtime = 'nodejs';
@@ -50,13 +51,7 @@ export async function GET(request: NextRequest) {
// 从数据库读取弹幕配置
const config = await getConfig();
- const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
-
- // 构建 API URL
- const baseUrl =
- DanmakuApiToken === '87654321'
- ? DanmakuApiBase
- : `${DanmakuApiBase}/${DanmakuApiToken}`;
+ const baseUrl = getDanmakuApiBaseUrl(config.SiteConfig);
let apiUrl: string;
diff --git a/src/app/api/danmaku/episodes/route.ts b/src/app/api/danmaku/episodes/route.ts
index b28e064..181e93b 100644
--- a/src/app/api/danmaku/episodes/route.ts
+++ b/src/app/api/danmaku/episodes/route.ts
@@ -2,6 +2,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
+import { getDanmakuApiBaseUrl } from '@/lib/danmaku/config';
export const runtime = 'nodejs';
@@ -28,13 +29,7 @@ export async function GET(request: NextRequest) {
// 从数据库读取弹幕配置
const config = await getConfig();
- const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
-
- // 构建 API URL
- const baseUrl =
- DanmakuApiToken === '87654321'
- ? DanmakuApiBase
- : `${DanmakuApiBase}/${DanmakuApiToken}`;
+ const baseUrl = getDanmakuApiBaseUrl(config.SiteConfig);
const apiUrl = `${baseUrl}/api/v2/bangumi/${animeId}`;
diff --git a/src/app/api/danmaku/match/route.ts b/src/app/api/danmaku/match/route.ts
index 972bf00..03b16b5 100644
--- a/src/app/api/danmaku/match/route.ts
+++ b/src/app/api/danmaku/match/route.ts
@@ -2,6 +2,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
+import { getDanmakuApiBaseUrl } from '@/lib/danmaku/config';
export const runtime = 'nodejs';
@@ -25,13 +26,7 @@ export async function POST(request: NextRequest) {
// 从数据库读取弹幕配置
const config = await getConfig();
- const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
-
- // 构建 API URL
- const baseUrl =
- DanmakuApiToken === '87654321'
- ? DanmakuApiBase
- : `${DanmakuApiBase}/${DanmakuApiToken}`;
+ const baseUrl = getDanmakuApiBaseUrl(config.SiteConfig);
const apiUrl = `${baseUrl}/api/v2/match`;
diff --git a/src/app/api/danmaku/search/route.ts b/src/app/api/danmaku/search/route.ts
index ecda7ef..5c025d5 100644
--- a/src/app/api/danmaku/search/route.ts
+++ b/src/app/api/danmaku/search/route.ts
@@ -2,6 +2,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
+import { getDanmakuApiBaseUrl } from '@/lib/danmaku/config';
export const runtime = 'nodejs';
@@ -24,13 +25,7 @@ export async function GET(request: NextRequest) {
// 从数据库读取弹幕配置
const config = await getConfig();
- const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
-
- // 构建 API URL
- const baseUrl =
- DanmakuApiToken === '87654321'
- ? DanmakuApiBase
- : `${DanmakuApiBase}/${DanmakuApiToken}`;
+ const baseUrl = getDanmakuApiBaseUrl(config.SiteConfig);
const apiUrl = `${baseUrl}/api/v2/search/anime?keyword=${encodeURIComponent(keyword)}`;
diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts
index 117f797..33b8332 100644
--- a/src/lib/admin.types.ts
+++ b/src/lib/admin.types.ts
@@ -17,6 +17,7 @@ export interface AdminConfig {
DisableYellowFilter: boolean;
FluidSearch: boolean;
// 弹幕配置
+ DanmakuSourceType?: 'builtin' | 'custom';
DanmakuApiBase: string;
DanmakuApiToken: string;
// TMDB配置
diff --git a/src/lib/config.ts b/src/lib/config.ts
index 25090aa..c35c9c2 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -4,6 +4,8 @@ import { db } from '@/lib/db';
import { AdminConfig } from './admin.types';
+const BUILTIN_DANMAKU_API_BASE = 'https://mtvpls-danmu.netlify.app/87654321';
+
export interface ApiSite {
key: string;
api: string;
@@ -223,6 +225,9 @@ async function getInitConfig(configFile: string, subConfig: {
} catch (e) {
cfgFile = {} as ConfigFileStruct;
}
+ const hasCustomDanmakuEnv = Boolean(
+ process.env.DANMAKU_API_BASE || process.env.DANMAKU_API_TOKEN
+ );
const adminConfig: AdminConfig = {
ConfigFile: configSource,
ConfigSubscribtion: subConfig,
@@ -245,7 +250,10 @@ async function getInitConfig(configFile: string, subConfig: {
FluidSearch:
process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
// 弹幕配置
- DanmakuApiBase: process.env.DANMAKU_API_BASE || 'http://localhost:9321',
+ DanmakuSourceType: hasCustomDanmakuEnv ? 'custom' : 'builtin',
+ DanmakuApiBase:
+ process.env.DANMAKU_API_BASE ||
+ (hasCustomDanmakuEnv ? 'http://localhost:9321' : BUILTIN_DANMAKU_API_BASE),
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
// TMDB配置
TMDBApiKey: process.env.TMDB_API_KEY || '',
@@ -439,7 +447,8 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
DoubanImageProxy: '',
DisableYellowFilter: false,
FluidSearch: true,
- DanmakuApiBase: 'http://localhost:9321',
+ DanmakuSourceType: 'builtin',
+ DanmakuApiBase: BUILTIN_DANMAKU_API_BASE,
DanmakuApiToken: '87654321',
PansouApiUrl: '',
PansouUsername: '',
@@ -461,8 +470,14 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
};
}
// 确保弹幕配置存在
+ if (adminConfig.SiteConfig.DanmakuSourceType === undefined) {
+ adminConfig.SiteConfig.DanmakuSourceType = 'custom';
+ }
if (!adminConfig.SiteConfig.DanmakuApiBase) {
- adminConfig.SiteConfig.DanmakuApiBase = 'http://localhost:9321';
+ adminConfig.SiteConfig.DanmakuApiBase =
+ adminConfig.SiteConfig.DanmakuSourceType === 'builtin'
+ ? BUILTIN_DANMAKU_API_BASE
+ : 'http://localhost:9321';
}
if (!adminConfig.SiteConfig.DanmakuApiToken) {
adminConfig.SiteConfig.DanmakuApiToken = '87654321';
diff --git a/src/lib/danmaku/config.ts b/src/lib/danmaku/config.ts
new file mode 100644
index 0000000..0e9dec4
--- /dev/null
+++ b/src/lib/danmaku/config.ts
@@ -0,0 +1,19 @@
+import type { AdminConfig } from '@/lib/admin.types';
+
+export const BUILTIN_DANMAKU_API_BASE = 'https://mtvpls-danmu.netlify.app/87654321';
+export const BUILTIN_DANMAKU_API_TOKEN = '87654321';
+
+function trimTrailingSlash(value: string) {
+ return value.replace(/\/+$/, '');
+}
+
+export function getDanmakuApiBaseUrl(siteConfig: AdminConfig['SiteConfig']) {
+ if (siteConfig.DanmakuSourceType === 'builtin') {
+ return BUILTIN_DANMAKU_API_BASE;
+ }
+
+ const base = trimTrailingSlash(siteConfig.DanmakuApiBase || 'http://localhost:9321');
+ const token = (siteConfig.DanmakuApiToken || BUILTIN_DANMAKU_API_TOKEN).trim();
+
+ return token === BUILTIN_DANMAKU_API_TOKEN ? base : `${base}/${token}`;
+}
From 0285162e2f64121fe1411777c755a0b4ea6f7340 Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Sat, 4 Apr 2026 01:47:22 +0800
Subject: [PATCH 05/18] =?UTF-8?q?netlify=E9=83=A8=E7=BD=B2=E6=94=AF?=
=?UTF-8?q?=E6=8C=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 7f2d057..d51b088 100644
--- a/README.md
+++ b/README.md
@@ -88,10 +88,12 @@
## 部署
-本项目**支持 Docker、Vercel 和 Cloudflare Workers 平台** 部署。
+本项目**支持 Docker、Vercel、Netlify 和 Cloudflare Workers 平台** 部署。
[](https://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus)
+[](https://app.netlify.com/start/deploy?repository=https://github.com/mtvpls/MoonTVPlus)
+
**一键部署到 Zeabur**
[](https://zeabur.com/templates/SCHCAY/deploy)
From 2ab48b678fe5b0e1821c10d0fa5fd89c8fce8481 Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Sun, 5 Apr 2026 00:15:53 +0800
Subject: [PATCH 06/18] =?UTF-8?q?=E8=A7=82=E5=BD=B1=E5=AE=A4=E5=A2=9E?=
=?UTF-8?q?=E5=8A=A0=E5=B1=8F=E5=B9=95=E5=85=B1=E4=BA=AB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
server.js | 129 ++++++++++++
src/app/watch-room/page.tsx | 113 ++++++++--
src/app/watch-room/screen/page.tsx | 201 ++++++++++++++++++
src/components/MobileBottomNav.tsx | 4 +
src/components/Sidebar.tsx | 4 +
src/components/WatchRoomProvider.tsx | 25 ++-
src/hooks/useScreenShare.ts | 297 +++++++++++++++++++++++++++
src/hooks/useWatchRoom.ts | 40 +++-
src/lib/watch-room-server.ts | 126 ++++++++++++
src/types/watch-room.ts | 30 ++-
10 files changed, 946 insertions(+), 23 deletions(-)
create mode 100644 src/app/watch-room/screen/page.tsx
create mode 100644 src/hooks/useScreenShare.ts
diff --git a/server.js b/server.js
index ebf7e94..1ce336e 100644
--- a/server.js
+++ b/server.js
@@ -32,6 +32,8 @@ class WatchRoomServer {
this.rooms = new Map();
this.members = new Map();
this.socketToRoom = new Map();
+ this.screenHelpers = new Map();
+ this.helperToRoom = new Map();
this.roomDeletionTimers = new Map(); // 房间延迟删除定时器
this.cleanupInterval = null;
this.setupEventHandlers();
@@ -55,6 +57,7 @@ class WatchRoomServer {
description: data.description,
password: data.password,
isPublic: data.isPublic,
+ roomType: data.roomType || 'sync',
ownerId: userId,
ownerName: data.userName,
ownerToken: ownerToken, // 保存房主令牌
@@ -260,6 +263,114 @@ class WatchRoomServer {
}
});
+ socket.on('screen:helper-register', (data, callback) => {
+ try {
+ const room = this.rooms.get(data.roomId);
+ if (!room) {
+ callback({ success: false, error: '房间不存在' });
+ return;
+ }
+
+ if (room.ownerToken !== data.ownerToken) {
+ callback({ success: false, error: '房主身份验证失败' });
+ return;
+ }
+
+ const oldHelperSocketId = this.screenHelpers.get(data.roomId);
+ if (oldHelperSocketId && oldHelperSocketId !== socket.id) {
+ this.helperToRoom.delete(oldHelperSocketId);
+ }
+
+ this.screenHelpers.set(data.roomId, socket.id);
+ this.helperToRoom.set(socket.id, data.roomId);
+ callback({ success: true });
+ } catch (error) {
+ console.error('[WatchRoom] Error registering screen helper:', error);
+ callback({ success: false, error: '注册共享控制窗口失败' });
+ }
+ });
+
+ // 开始屏幕共享
+ socket.on('screen:start', (state) => {
+ const roomInfo = this.socketToRoom.get(socket.id);
+ const helperRoomId = this.helperToRoom.get(socket.id);
+ const roomId = roomInfo?.roomId || helperRoomId;
+ if (!roomId) return;
+ if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return;
+ if (roomInfo && !roomInfo.isOwner) return;
+
+ const room = this.rooms.get(roomId);
+ if (room) {
+ room.currentState = state;
+ this.rooms.set(roomId, room);
+ this.io.to(roomId).emit('screen:start', state);
+ }
+ });
+
+ // 停止屏幕共享
+ socket.on('screen:stop', () => {
+ const roomInfo = this.socketToRoom.get(socket.id);
+ const helperRoomId = this.helperToRoom.get(socket.id);
+ const roomId = roomInfo?.roomId || helperRoomId;
+ if (!roomId) return;
+ if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return;
+ if (roomInfo && !roomInfo.isOwner) return;
+
+ const room = this.rooms.get(roomId);
+ if (room) {
+ room.currentState = null;
+ this.rooms.set(roomId, room);
+ this.io.to(roomId).emit('screen:stop');
+ }
+ });
+
+ socket.on('screen:viewer-ready', () => {
+ const roomInfo = this.socketToRoom.get(socket.id);
+ if (!roomInfo) return;
+
+ const room = this.rooms.get(roomInfo.roomId);
+ if (!room || roomInfo.isOwner || room.currentState?.type !== 'screen') return;
+
+ const targetSocketId = this.screenHelpers.get(roomInfo.roomId) || room.ownerId;
+ this.io.to(targetSocketId).emit('screen:viewer-ready', {
+ userId: socket.id,
+ });
+ });
+
+ // 屏幕共享 WebRTC 信令
+ socket.on('screen:offer', (data) => {
+ const roomInfo = this.socketToRoom.get(socket.id);
+ const helperRoomId = this.helperToRoom.get(socket.id);
+ if (!roomInfo && !helperRoomId) return;
+
+ this.io.to(data.targetUserId).emit('screen:offer', {
+ userId: socket.id,
+ offer: data.offer,
+ });
+ });
+
+ socket.on('screen:answer', (data) => {
+ const roomInfo = this.socketToRoom.get(socket.id);
+ const helperRoomId = this.helperToRoom.get(socket.id);
+ if (!roomInfo && !helperRoomId) return;
+
+ this.io.to(data.targetUserId).emit('screen:answer', {
+ userId: socket.id,
+ answer: data.answer,
+ });
+ });
+
+ socket.on('screen:ice', (data) => {
+ const roomInfo = this.socketToRoom.get(socket.id);
+ const helperRoomId = this.helperToRoom.get(socket.id);
+ if (!roomInfo && !helperRoomId) return;
+
+ this.io.to(data.targetUserId).emit('screen:ice', {
+ userId: socket.id,
+ candidate: data.candidate,
+ });
+ });
+
// 聊天消息
socket.on('chat:message', (data) => {
const roomInfo = this.socketToRoom.get(socket.id);
@@ -347,6 +458,19 @@ class WatchRoomServer {
// 断开连接
socket.on('disconnect', () => {
console.log(`[WatchRoom] Client disconnected: ${socket.id}`);
+ const helperRoomId = this.helperToRoom.get(socket.id);
+ if (helperRoomId) {
+ this.helperToRoom.delete(socket.id);
+ if (this.screenHelpers.get(helperRoomId) === socket.id) {
+ this.screenHelpers.delete(helperRoomId);
+ const room = this.rooms.get(helperRoomId);
+ if (room && room.currentState?.type === 'screen') {
+ room.currentState = null;
+ this.rooms.set(helperRoomId, room);
+ this.io.to(helperRoomId).emit('screen:stop');
+ }
+ }
+ }
this.handleLeaveRoom(socket);
});
});
@@ -425,6 +549,11 @@ class WatchRoomServer {
this.rooms.delete(roomId);
this.members.delete(roomId);
+ const helperSocketId = this.screenHelpers.get(roomId);
+ if (helperSocketId) {
+ this.helperToRoom.delete(helperSocketId);
+ this.screenHelpers.delete(roomId);
+ }
}
startCleanupTimer() {
diff --git a/src/app/watch-room/page.tsx b/src/app/watch-room/page.tsx
index a824fb6..0c2eaf8 100644
--- a/src/app/watch-room/page.tsx
+++ b/src/app/watch-room/page.tsx
@@ -10,7 +10,7 @@ import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import PageLayout from '@/components/PageLayout';
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
-import type { Room } from '@/types/watch-room';
+import type { Room, RoomType } from '@/types/watch-room';
type TabType = 'create' | 'join' | 'list';
@@ -34,6 +34,7 @@ export default function WatchRoomPage() {
description: '',
password: '',
isPublic: true,
+ roomType: 'sync' as RoomType,
});
// 加入房间表单
@@ -49,26 +50,30 @@ export default function WatchRoomPage() {
const [joinLoading, setJoinLoading] = useState(false);
// 加载房间列表
- const loadRooms = async () => {
+ const loadRooms = async (showLoading = false) => {
if (!isConnected) return;
- setLoading(true);
+ if (showLoading) {
+ setLoading(true);
+ }
try {
const roomList = await getRoomList();
setRooms(roomList);
} catch (error) {
console.error('[WatchRoom] Failed to load rooms:', error);
} finally {
- setLoading(false);
+ if (showLoading) {
+ setLoading(false);
+ }
}
};
// 切换到房间列表 tab 时加载房间
useEffect(() => {
if (activeTab === 'list') {
- loadRooms();
+ loadRooms(true);
// 每5秒刷新一次
- const interval = setInterval(loadRooms, 5000);
+ const interval = setInterval(() => loadRooms(false), 5000);
return () => clearInterval(interval);
}
}, [activeTab, isConnected]);
@@ -88,6 +93,7 @@ export default function WatchRoomPage() {
description: createForm.description.trim(),
password: createForm.password.trim() || undefined,
isPublic: createForm.isPublic,
+ roomType: createForm.roomType,
userName: currentUsername,
});
@@ -97,6 +103,7 @@ export default function WatchRoomPage() {
description: '',
password: '',
isPublic: true,
+ roomType: 'sync',
});
} catch (error: any) {
alert(error.message || '创建房间失败');
@@ -141,6 +148,11 @@ export default function WatchRoomPage() {
useEffect(() => {
if (!currentRoom || isOwner) return;
+ if (currentRoom.roomType === 'screen') {
+ router.push('/watch-room/screen');
+ return;
+ }
+
// 房员加入房间后,不立即跳转
// 而是监听 play:change 或 live:change 事件(说明房主正在活跃使用)
// 这样可以避免房主已经离开play页面但状态未清除的情况
@@ -153,6 +165,8 @@ export default function WatchRoomPage() {
useEffect(() => {
if (!currentRoom || isOwner) return;
+ if (currentRoom.roomType === 'screen') return;
+
const handlePlayChange = (state: any) => {
if (state.type === 'play') {
const params = new URLSearchParams({
@@ -196,6 +210,13 @@ export default function WatchRoomPage() {
}
}, [currentRoom, isOwner, router, socket]);
+ // 屏幕共享房间创建/加入后直接进入共享页
+ useEffect(() => {
+ if (currentRoom?.roomType === 'screen') {
+ router.push('/watch-room/screen');
+ }
+ }, [currentRoom?.id, currentRoom?.roomType, router]);
+
// 从房间列表加入房间
const handleJoinFromList = (room: Room) => {
setJoinForm({
@@ -237,7 +258,9 @@ export default function WatchRoomPage() {
- {currentRoom.currentState ? '房主正在播放' : '等待房主开始播放'}
+ {currentRoom.roomType === 'screen'
+ ? currentRoom.currentState?.type === 'screen' ? '房主正在共享屏幕' : '等待房主开始共享'
+ : currentRoom.currentState ? '房主正在播放' : '等待房主开始播放'}
房间: {currentRoom.name} | 房主: {currentRoom.ownerName}
@@ -246,12 +269,14 @@ export default function WatchRoomPage() {
{currentRoom.currentState.type === 'play'
? `${currentRoom.currentState.videoName || '未知视频'}`
- : `${currentRoom.currentState.channelName || '未知频道'}`}
+ : currentRoom.currentState.type === 'live'
+ ? `${currentRoom.currentState.channelName || '未知频道'}`
+ : '屏幕共享进行中'}
)}
{!currentRoom.currentState && (
- 当房主开始播放时,您将自动跟随
+ {currentRoom.roomType === 'screen' ? '当房主开始共享时,您将自动进入共享页' : '当房主开始播放时,您将自动跟随'}
)}
@@ -280,6 +305,8 @@ export default function WatchRoomPage() {
// 普通 live 格式,导航到 live 页面
router.push(`/live?id=${state.channelId}`);
}
+ } else if (state.type === 'screen') {
+ router.push('/watch-room/screen');
}
}}
className="px-6 py-2 bg-white text-blue-600 font-medium rounded-lg hover:bg-white/90 transition-colors whitespace-nowrap"
@@ -303,7 +330,7 @@ export default function WatchRoomPage() {
)}
- 与好友一起看视频,实时同步播放
+ 与好友一起看视频,支持进度同步或屏幕共享
@@ -360,7 +387,7 @@ export default function WatchRoomPage() {
)}
-
+
房间号
{currentRoom.id}
@@ -369,6 +396,10 @@ export default function WatchRoomPage() {
成员数
{members.length} 人
+
+
房间类型
+
{currentRoom.roomType === 'screen' ? '屏幕共享' : '进度同步'}
+
@@ -402,7 +433,9 @@ export default function WatchRoomPage() {
{/* 提示信息 */}
- 💡 前往播放页面或直播页面开始观影,房间成员将自动同步您的操作
+ 💡 {currentRoom.roomType === 'screen'
+ ? '这是屏幕共享房间,创建后将进入共享页,由房主发起屏幕共享'
+ : '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作'}
@@ -471,6 +504,38 @@ export default function WatchRoomPage() {
+
+
+
+
setCreateForm({ ...createForm, roomType: 'sync' })}
+ className={`rounded-lg border p-4 text-left transition-colors ${
+ createForm.roomType === 'sync'
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
+ : 'border-gray-300 dark:border-gray-600'
+ }`}
+ >
+ 进度同步
+ 统一播放进度
+
+
setCreateForm({ ...createForm, roomType: 'screen' })}
+ className={`rounded-lg border p-4 text-left transition-colors ${
+ createForm.roomType === 'screen'
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
+ : 'border-gray-300 dark:border-gray-600'
+ }`}
+ >
+ 屏幕共享
+ 房员直接观看房主共享的浏览器画面
+
+
+
+
- 提示:创建房间后,您将成为房主。所有成员的播放进度将自动跟随您的操作。
+ 提示:创建房间后,您将成为房主。进度同步房会跟随播放状态,屏幕共享房会进入独立共享页。
)}
@@ -518,7 +583,7 @@ export default function WatchRoomPage() {
)}
-
+
房间号
{currentRoom.id}
@@ -527,6 +592,10 @@ export default function WatchRoomPage() {
成员数
{members.length} 人
+
+
房间类型
+
{currentRoom.roomType === 'screen' ? '屏幕共享' : '进度同步'}
+
@@ -560,7 +629,9 @@ export default function WatchRoomPage() {
{/* 提示信息 */}
- 💡 {isOwner ? '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作' : '等待房主开始播放,您的播放进度将自动跟随房主'}
+ 💡 {currentRoom.roomType === 'screen'
+ ? '这是屏幕共享房间,进入后即可观看房主共享画面'
+ : isOwner ? '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作' : '等待房主开始播放,您的播放进度将自动跟随房主'}
@@ -617,7 +688,7 @@ export default function WatchRoomPage() {
{!currentRoom && (
- 提示:加入房间后,您的播放进度将自动跟随房主的操作。
+ 提示:加入进度同步房后将跟随播放,加入屏幕共享房后会进入共享页面。
)}
@@ -633,7 +704,7 @@ export default function WatchRoomPage() {
找到 {rooms.length} 个公开房间
loadRooms(true)}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg text-gray-700 dark:text-gray-300 transition-colors disabled:opacity-50"
>
@@ -704,6 +775,10 @@ export default function WatchRoomPage() {
房主
{room.ownerName}
+
+ 类型
+ {room.roomType === 'screen' ? '屏幕共享' : '进度同步'}
+
创建时间
{formatTime(room.createdAt)}
@@ -713,7 +788,9 @@ export default function WatchRoomPage() {
{room.currentState.type === 'play'
? `正在播放: ${room.currentState.videoName}`
- : `正在观看: ${room.currentState.channelName}`}
+ : room.currentState.type === 'live'
+ ? `正在观看: ${room.currentState.channelName}`
+ : '正在共享屏幕'}
)}
diff --git a/src/app/watch-room/screen/page.tsx b/src/app/watch-room/screen/page.tsx
new file mode 100644
index 0000000..138740f
--- /dev/null
+++ b/src/app/watch-room/screen/page.tsx
@@ -0,0 +1,201 @@
+'use client';
+
+import { Monitor, MonitorPlay, Users } from 'lucide-react';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { useEffect } from 'react';
+
+import { useWatchRoomContext } from '@/components/WatchRoomProvider';
+import { useScreenShare } from '@/hooks/useScreenShare';
+
+const NEW_TAB_KEY_PREFIX = 'watch_room_screen_home_opened_';
+
+export default function WatchRoomScreenPage() {
+ const router = useRouter();
+ const watchRoom = useWatchRoomContext();
+ const { currentRoom, members, leaveRoom } = watchRoom;
+ const {
+ currentRoom: screenRoom,
+ isOwner,
+ isSharing,
+ isStarting,
+ error,
+ localVideoRef,
+ remoteVideoRef,
+ startSharing,
+ stopSharing,
+ } = useScreenShare();
+
+ useEffect(() => {
+ if (!currentRoom) {
+ router.replace('/watch-room');
+ return;
+ }
+
+ if (currentRoom.roomType !== 'screen') {
+ router.replace('/watch-room');
+ }
+ }, [currentRoom, router]);
+
+ useEffect(() => {
+ if (!screenRoom || !isOwner) return;
+
+ const key = `${NEW_TAB_KEY_PREFIX}${screenRoom.id}`;
+ if (sessionStorage.getItem(key)) return;
+
+ sessionStorage.setItem(key, '1');
+ window.open('/?watchRoomNoConnect=1', '_blank', 'noopener,noreferrer');
+ }, [isOwner, screenRoom?.id]);
+
+ if (!screenRoom || screenRoom.roomType !== 'screen') {
+ return null;
+ }
+
+ const handleLeave = () => {
+ if (isOwner && isSharing) {
+ stopSharing(true);
+ }
+ leaveRoom();
+ router.push('/watch-room');
+ };
+
+ return (
+
+
+
+
+
+
+ 屏幕共享观影室
+
+
+ 房间:{screenRoom.name} · 房主:{screenRoom.ownerName}
+
+
+
+ {isOwner && (
+
+ 新开主页
+
+ )}
+
+ 离开房间
+
+
+
+
+
+
+ {isOwner ? (
+
+ ) : (
+
+ )}
+
+ {!isSharing && (
+
+
+
+ {isOwner ? '点击开始共享,向房员推送浏览器画面' : '等待房主开始共享屏幕'}
+
+ {isOwner && (
+
+ 本页不要关闭;已尝试为你新开一个主页标签页方便继续浏览。
+
+ )}
+
+ )}
+
+
+
+
+
共享状态
+
+
类型:屏幕共享
+
状态:{isSharing ? '共享中' : '未开始'}
+
成员:{members.length} 人
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {isOwner ? (
+ <>
+
startSharing()}
+ disabled={isStarting || isSharing}
+ className='flex-1 rounded-lg bg-blue-500 px-4 py-2 text-white disabled:bg-gray-400'
+ >
+ {isStarting ? '启动中...' : isSharing ? '共享中' : '开始共享'}
+
+
stopSharing(true)}
+ disabled={!isSharing}
+ className='rounded-lg bg-red-500 px-4 py-2 text-white disabled:bg-gray-400'
+ >
+ 停止
+
+ >
+ ) : (
+
+ 房员无需操作,房主开始共享后会自动显示画面。
+
+ )}
+
+
+
+
+
+
+ 房间成员
+
+
+ {members.map((member) => (
+
+ {member.name}
+ {member.isOwner && (
+
+ 房主
+
+ )}
+
+ ))}
+
+
+
+
+ 本页不再包裹站点导航,便于直接共享。建议使用桌面版 Chrome / Edge,并优先共享标签页。
+
+
+
+
+
+ );
+}
diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx
index 1154e95..0371727 100644
--- a/src/components/MobileBottomNav.tsx
+++ b/src/components/MobileBottomNav.tsx
@@ -28,6 +28,10 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
};
const currentActive = activePath ?? getCurrentFullPath();
+ if (pathname === '/watch-room/screen') {
+ return null;
+ }
+
const [navItems, setNavItems] = useState([
{ icon: Home, label: '首页', href: '/' },
{
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index ae0f270..2f16a65 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -63,6 +63,10 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const watchRoomContext = useWatchRoomContextSafe();
+
+ if (pathname === '/watch-room/screen') {
+ return null;
+ }
// 若同一次 SPA 会话中已经读取过折叠状态,则直接复用,避免闪烁
const [isCollapsed, setIsCollapsed] = useState(() => {
if (
diff --git a/src/components/WatchRoomProvider.tsx b/src/components/WatchRoomProvider.tsx
index 930ec6a..cbd9af1 100644
--- a/src/components/WatchRoomProvider.tsx
+++ b/src/components/WatchRoomProvider.tsx
@@ -2,6 +2,7 @@
'use client';
import React, { createContext, useCallback,useContext, useEffect, useState } from 'react';
+import { useSearchParams } from 'next/navigation';
import { useWatchRoom } from '@/hooks/useWatchRoom';
@@ -9,7 +10,7 @@ import Toast, { ToastProps } from '@/components/Toast';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
-import type { ChatMessage, Member, Room, WatchRoomConfig } from '@/types/watch-room';
+import type { ChatMessage, Member, Room, RoomType, ScreenState, WatchRoomConfig } from '@/types/watch-room';
// Import type from watch-room-socket
type WatchRoomSocket = import('@/lib/watch-room-socket').WatchRoomSocket;
@@ -31,6 +32,7 @@ interface WatchRoomContextType {
description: string;
password?: string;
isPublic: boolean;
+ roomType: RoomType;
userName: string;
}) => Promise;
joinRoom: (data: {
@@ -51,6 +53,8 @@ interface WatchRoomContextType {
pause: () => void;
changeVideo: (state: any) => void;
changeLiveChannel: (state: any) => void;
+ startScreenShare: (state: ScreenState) => void;
+ stopScreenShare: () => void;
clearRoomState: () => void;
// 重连
@@ -77,6 +81,7 @@ interface WatchRoomProviderProps {
}
export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
+ const searchParams = useSearchParams();
const [config, setConfig] = useState(null);
const [isEnabled, setIsEnabled] = useState(false);
const [toast, setToast] = useState(null);
@@ -118,6 +123,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
}, []);
const watchRoom = useWatchRoom(handleRoomDeleted, handleStateCleared);
+ const shouldDisableWatchRoomConnection = searchParams.get('watchRoomNoConnect') === '1';
// 检查登录状态
useEffect(() => {
@@ -169,6 +175,15 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
// 加载配置
useEffect(() => {
+ if (shouldDisableWatchRoomConnection) {
+ setConfig({
+ enabled: false,
+ serverType: 'internal',
+ });
+ setIsEnabled(false);
+ return;
+ }
+
const loadConfig = async () => {
try {
// 使用公共 API 获取观影室配置(不需要管理员权限)
@@ -253,12 +268,14 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
};
loadConfig();
+ }, [isLoggedIn, shouldDisableWatchRoomConnection]); // 添加 isLoggedIn 作为依赖
- // 清理
+ // 仅在 Provider 卸载时断开,避免路由切换时误断开房间连接
+ useEffect(() => {
return () => {
watchRoom.disconnect();
};
- }, [isLoggedIn]); // 添加 isLoggedIn 作为依赖
+ }, []);
const contextValue: WatchRoomContextType = {
socket: watchRoom.socket,
@@ -281,6 +298,8 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
pause: watchRoom.pause,
changeVideo: watchRoom.changeVideo,
changeLiveChannel: watchRoom.changeLiveChannel,
+ startScreenShare: watchRoom.startScreenShare,
+ stopScreenShare: watchRoom.stopScreenShare,
clearRoomState: watchRoom.clearRoomState,
manualReconnect,
};
diff --git a/src/hooks/useScreenShare.ts b/src/hooks/useScreenShare.ts
new file mode 100644
index 0000000..3fdc57c
--- /dev/null
+++ b/src/hooks/useScreenShare.ts
@@ -0,0 +1,297 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { useWatchRoomContextSafe } from '@/components/WatchRoomProvider';
+
+import type { ScreenState } from '@/types/watch-room';
+
+const iceServers = [
+ { urls: 'stun:stun.cloudflare.com:3478' },
+ { urls: 'stun:stun.l.google.com:19302' },
+ { urls: 'stun:stun1.l.google.com:19302' },
+];
+
+export function useScreenShare() {
+ const watchRoom = useWatchRoomContextSafe();
+ const localVideoRef = useRef(null);
+ const remoteVideoRef = useRef(null);
+ const displayStreamRef = useRef(null);
+ const remoteStreamRef = useRef(null);
+ const peerConnectionsRef = useRef
屏幕共享
- 房员直接观看房主共享的浏览器画面
+ 房员直接观看房主共享的浏览器画面(适合完全实时同步的情况)
diff --git a/src/app/watch-room/screen/page.tsx b/src/app/watch-room/screen/page.tsx
index b8fb56b..0bd73c1 100644
--- a/src/app/watch-room/screen/page.tsx
+++ b/src/app/watch-room/screen/page.tsx
@@ -3,13 +3,15 @@
import { Monitor, MonitorPlay, Users } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import Toast, { ToastProps } from '@/components/Toast';
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
-import { useScreenShare } from '@/hooks/useScreenShare';
+import { screenShareQualityOptions, type ScreenShareQualityPreset, useScreenShare } from '@/hooks/useScreenShare';
const NEW_TAB_KEY_PREFIX = 'watch_room_screen_home_opened_';
+const WATCH_ROOM_NO_CONNECT_KEY = 'watch_room_no_connect';
+const SCREEN_SHARE_QUALITY_KEY = 'watch_room_screen_quality';
function getScreenShareHostSupportError() {
if (typeof window === 'undefined') return null;
@@ -44,17 +46,19 @@ export default function WatchRoomScreenPage() {
const watchRoom = useWatchRoomContext();
const { currentRoom, members, leaveRoom } = watchRoom;
const [toast, setToast] = useState(null);
+ const [qualityPreset, setQualityPreset] = useState('smooth');
const {
currentRoom: screenRoom,
isOwner,
isSharing,
isStarting,
error,
+ captureSettings,
localVideoRef,
remoteVideoRef,
startSharing,
stopSharing,
- } = useScreenShare();
+ } = useScreenShare(qualityPreset);
const showToast = (message: string, type: ToastProps['type'] = 'info') => {
setToast({
@@ -65,6 +69,24 @@ export default function WatchRoomScreenPage() {
});
};
+ const openDetachedPage = useCallback(() => {
+ window.open('/', '_blank', 'noopener,noreferrer');
+ }, []);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+
+ const saved = window.localStorage.getItem(SCREEN_SHARE_QUALITY_KEY);
+ if (saved === 'smooth' || saved === 'hd' || saved === 'ultra') {
+ setQualityPreset(saved);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ window.localStorage.setItem(SCREEN_SHARE_QUALITY_KEY, qualityPreset);
+ }, [qualityPreset]);
+
useEffect(() => {
if (!currentRoom) {
router.replace('/watch-room');
@@ -92,12 +114,17 @@ export default function WatchRoomScreenPage() {
useEffect(() => {
if (!screenRoom || !isOwner) return;
+ localStorage.setItem(WATCH_ROOM_NO_CONNECT_KEY, '1');
const key = `${NEW_TAB_KEY_PREFIX}${screenRoom.id}`;
- if (sessionStorage.getItem(key)) return;
+ if (!sessionStorage.getItem(key)) {
+ sessionStorage.setItem(key, '1');
+ openDetachedPage();
+ }
- sessionStorage.setItem(key, '1');
- window.open('/?watchRoomNoConnect=1', '_blank', 'noopener,noreferrer');
- }, [isOwner, screenRoom?.id]);
+ return () => {
+ localStorage.removeItem(WATCH_ROOM_NO_CONNECT_KEY);
+ };
+ }, [isOwner, openDetachedPage, screenRoom?.id]);
if (!screenRoom || screenRoom.roomType !== 'screen') {
return null;
@@ -111,6 +138,15 @@ export default function WatchRoomScreenPage() {
router.push('/watch-room');
};
+ const captureSettingsText = captureSettings
+ ? [
+ captureSettings.width && captureSettings.height
+ ? `${captureSettings.width}x${captureSettings.height}`
+ : '分辨率未知',
+ captureSettings.frameRate ? `${Math.round(captureSettings.frameRate)} fps` : '帧率未知',
+ ].join(' / ')
+ : '未开始';
+
return (
@@ -127,9 +163,13 @@ export default function WatchRoomScreenPage() {
{isOwner && (
{
+ event.preventDefault();
+ openDetachedPage();
+ }}
className='rounded-lg bg-blue-500 px-4 py-2 text-white'
>
新开主页
@@ -188,12 +228,41 @@ export default function WatchRoomScreenPage() {
成员:{members.length} 人
+ {isOwner && (
+
+ 实际采集:{captureSettingsText}
+
+ )}
+
{error && (
{error}
)}
+ {isOwner && (
+
+
+
+
+ 画质越高越清晰,但更依赖网络和设备性能。共享开始后不可切换。
+
+
+ )}
+
{isOwner ? (
<>
@@ -243,7 +312,7 @@ export default function WatchRoomScreenPage() {
- 本页不再包裹站点导航,便于直接共享。建议使用桌面版 Chrome / Edge,并优先共享标签页。
+ 建议使用桌面版 Chrome / Edge,并优先共享标签页。
diff --git a/src/components/WatchRoomProvider.tsx b/src/components/WatchRoomProvider.tsx
index cbd9af1..cb464a6 100644
--- a/src/components/WatchRoomProvider.tsx
+++ b/src/components/WatchRoomProvider.tsx
@@ -2,7 +2,6 @@
'use client';
import React, { createContext, useCallback,useContext, useEffect, useState } from 'react';
-import { useSearchParams } from 'next/navigation';
import { useWatchRoom } from '@/hooks/useWatchRoom';
@@ -14,6 +13,8 @@ import type { ChatMessage, Member, Room, RoomType, ScreenState, WatchRoomConfig
// Import type from watch-room-socket
type WatchRoomSocket = import('@/lib/watch-room-socket').WatchRoomSocket;
+const WATCH_ROOM_NO_CONNECT_KEY = 'watch_room_no_connect';
+const WATCH_ROOM_SCREEN_PATH = '/watch-room/screen';
interface WatchRoomContextType {
socket: WatchRoomSocket | null;
@@ -39,6 +40,7 @@ interface WatchRoomContextType {
roomId: string;
password?: string;
userName: string;
+ ownerToken?: string;
}) => Promise<{ room: Room; members: Member[] }>;
leaveRoom: () => void;
getRoomList: () => Promise;
@@ -81,12 +83,12 @@ interface WatchRoomProviderProps {
}
export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
- const searchParams = useSearchParams();
const [config, setConfig] = useState(null);
const [isEnabled, setIsEnabled] = useState(false);
const [toast, setToast] = useState(null);
const [reconnectFailed, setReconnectFailed] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(false);
+ const [shouldDisableWatchRoomConnection, setShouldDisableWatchRoomConnection] = useState(null);
// 处理房间删除的回调
const handleRoomDeleted = useCallback((data?: { reason?: string }) => {
@@ -123,7 +125,15 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
}, []);
const watchRoom = useWatchRoom(handleRoomDeleted, handleStateCleared);
- const shouldDisableWatchRoomConnection = searchParams.get('watchRoomNoConnect') === '1';
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+
+ setShouldDisableWatchRoomConnection(
+ window.location.pathname !== WATCH_ROOM_SCREEN_PATH
+ && window.localStorage.getItem(WATCH_ROOM_NO_CONNECT_KEY) === '1'
+ );
+ }, []);
// 检查登录状态
useEffect(() => {
@@ -162,6 +172,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
roomId: info.roomId,
password: info.password,
userName: info.userName,
+ ownerToken: info.ownerToken,
});
} catch (error) {
console.error('[WatchRoomProvider] Failed to rejoin room after reconnect:', error);
@@ -175,6 +186,10 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
// 加载配置
useEffect(() => {
+ if (shouldDisableWatchRoomConnection === null) {
+ return;
+ }
+
if (shouldDisableWatchRoomConnection) {
setConfig({
enabled: false,
diff --git a/src/hooks/useScreenShare.ts b/src/hooks/useScreenShare.ts
index 3fdc57c..da88699 100644
--- a/src/hooks/useScreenShare.ts
+++ b/src/hooks/useScreenShare.ts
@@ -12,7 +12,51 @@ const iceServers = [
{ urls: 'stun:stun1.l.google.com:19302' },
];
-export function useScreenShare() {
+export type ScreenShareQualityPreset = 'smooth' | 'hd' | 'ultra';
+
+const SCREEN_SHARE_CONSTRAINTS: Record<
+ ScreenShareQualityPreset,
+ {
+ label: string;
+ frameRate: number;
+ width: number;
+ height: number;
+ }
+> = {
+ smooth: {
+ label: '流畅 720p / 15fps',
+ frameRate: 15,
+ width: 1280,
+ height: 720,
+ },
+ hd: {
+ label: '高清 1080p / 30fps',
+ frameRate: 30,
+ width: 1920,
+ height: 1080,
+ },
+ ultra: {
+ label: '超清 1440p / 30fps',
+ frameRate: 30,
+ width: 2560,
+ height: 1440,
+ },
+};
+
+export const screenShareQualityOptions = Object.entries(SCREEN_SHARE_CONSTRAINTS).map(
+ ([value, preset]) => ({
+ value: value as ScreenShareQualityPreset,
+ label: preset.label,
+ })
+);
+
+export interface ScreenShareCaptureSettings {
+ width: number | null;
+ height: number | null;
+ frameRate: number | null;
+}
+
+export function useScreenShare(qualityPreset: ScreenShareQualityPreset = 'smooth') {
const watchRoom = useWatchRoomContextSafe();
const localVideoRef = useRef(null);
const remoteVideoRef = useRef(null);
@@ -23,9 +67,11 @@ export function useScreenShare() {
const [error, setError] = useState(null);
const [isStarting, setIsStarting] = useState(false);
+ const [captureSettings, setCaptureSettings] = useState(null);
const currentRoom = watchRoom?.currentRoom || null;
const socket = watchRoom?.socket || null;
+ const isConnected = watchRoom?.isConnected || false;
const isOwner = watchRoom?.isOwner || false;
const members = watchRoom?.members || [];
const currentState = currentRoom?.currentState;
@@ -67,6 +113,7 @@ export function useScreenShare() {
localVideoRef.current.srcObject = null;
}
+ setCaptureSettings(null);
clearRemoteVideo();
stoppingRef.current = false;
}, [clearRemoteVideo, closePeerConnection]);
@@ -136,11 +183,12 @@ export function useScreenShare() {
setError(null);
try {
+ const constraints = SCREEN_SHARE_CONSTRAINTS[qualityPreset];
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
- frameRate: 15,
- width: { ideal: 1280 },
- height: { ideal: 720 },
+ frameRate: constraints.frameRate,
+ width: { ideal: constraints.width },
+ height: { ideal: constraints.height },
},
audio: true,
});
@@ -152,6 +200,12 @@ export function useScreenShare() {
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack) {
+ const settings = videoTrack.getSettings();
+ setCaptureSettings({
+ width: typeof settings.width === 'number' ? settings.width : null,
+ height: typeof settings.height === 'number' ? settings.height : null,
+ frameRate: typeof settings.frameRate === 'number' ? settings.frameRate : null,
+ });
videoTrack.onended = () => {
stopSharing(true);
};
@@ -176,7 +230,7 @@ export function useScreenShare() {
} finally {
setIsStarting(false);
}
- }, [currentRoom, isOwner, members, sendOfferToMember, stopSharing, watchRoom]);
+ }, [currentRoom, isOwner, members, qualityPreset, sendOfferToMember, stopSharing, watchRoom]);
useEffect(() => {
if (!socket || !currentRoom) return;
@@ -231,6 +285,14 @@ export function useScreenShare() {
}
};
+ const handleSocketDisconnect = () => {
+ if (!isOwner) {
+ peerConnectionsRef.current.forEach((_pc, userId) => closePeerConnection(userId));
+ peerConnectionsRef.current.clear();
+ clearRemoteVideo();
+ }
+ };
+
const handleViewerReady = (data: { userId: string }) => {
if (!isOwner || !displayStreamRef.current) return;
sendOfferToMember(data.userId);
@@ -241,6 +303,7 @@ export function useScreenShare() {
socket.on('screen:ice', handleIce);
socket.on('screen:stop', handleScreenStop);
socket.on('screen:viewer-ready', handleViewerReady);
+ socket.on('disconnect', handleSocketDisconnect);
return () => {
socket.off('screen:offer', handleOffer);
@@ -248,6 +311,7 @@ export function useScreenShare() {
socket.off('screen:ice', handleIce);
socket.off('screen:stop', handleScreenStop);
socket.off('screen:viewer-ready', handleViewerReady);
+ socket.off('disconnect', handleSocketDisconnect);
};
}, [clearRemoteVideo, closePeerConnection, createPeerConnection, currentRoom, isOwner, sendOfferToMember, socket]);
@@ -277,11 +341,11 @@ export function useScreenShare() {
}, [cleanupSharingResources]);
useEffect(() => {
- if (!socket || !currentRoom || isOwner) return;
+ if (!socket || !currentRoom || isOwner || !isConnected) return;
if (currentState?.type !== 'screen' || currentState.status !== 'sharing') return;
socket.emit('screen:viewer-ready');
- }, [currentRoom, currentState, isOwner, socket]);
+ }, [currentRoom, currentState, isConnected, isOwner, socket]);
return {
currentRoom,
@@ -289,6 +353,7 @@ export function useScreenShare() {
isSharing,
isStarting,
error,
+ captureSettings,
localVideoRef,
remoteVideoRef,
startSharing,
diff --git a/src/hooks/useWatchRoom.ts b/src/hooks/useWatchRoom.ts
index 5700550..6cf245f 100644
--- a/src/hooks/useWatchRoom.ts
+++ b/src/hooks/useWatchRoom.ts
@@ -30,9 +30,15 @@ export function useWatchRoom(
const [chatMessages, setChatMessages] = useState([]);
const [isOwner, setIsOwner] = useState(false);
const reconnectTimeoutRef = useRef(null);
+ const rejoinInFlightRef = useRef(false);
// 重新加入房间(自动重连)
const rejoinRoom = useCallback(async (info: StoredRoomInfo) => {
+ if (rejoinInFlightRef.current) {
+ return;
+ }
+
+ rejoinInFlightRef.current = true;
console.log('[WatchRoom] Auto-rejoining room:', info);
try {
const sock = watchRoomSocketManager.getSocket();
@@ -64,9 +70,21 @@ export function useWatchRoom(
} catch (error) {
console.error('[WatchRoom] Failed to rejoin room:', error);
clearStoredRoomInfo();
+ } finally {
+ rejoinInFlightRef.current = false;
}
}, []);
+ const scheduleRejoin = useCallback((info: StoredRoomInfo, delay = 300) => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current);
+ }
+
+ reconnectTimeoutRef.current = setTimeout(() => {
+ rejoinRoom(info);
+ }, delay);
+ }, [rejoinRoom]);
+
// 连接到服务器
const connect = useCallback(async (config: WatchRoomConfig) => {
try {
@@ -78,15 +96,13 @@ export function useWatchRoom(
const storedInfo = getStoredRoomInfo();
if (storedInfo) {
console.log('[WatchRoom] Attempting to reconnect to room:', storedInfo.roomId);
- reconnectTimeoutRef.current = setTimeout(() => {
- rejoinRoom(storedInfo);
- }, 1000);
+ scheduleRejoin(storedInfo);
}
} catch (error) {
console.error('[WatchRoom] Failed to connect:', error);
setIsConnected(false);
}
- }, [rejoinRoom]);
+ }, [scheduleRejoin]);
// 断开连接
const disconnect = useCallback(() => {
@@ -99,6 +115,7 @@ export function useWatchRoom(
setCurrentRoom(null);
setMembers([]);
setChatMessages([]);
+ setIsOwner(false);
}, []);
// 创建房间
@@ -142,7 +159,7 @@ export function useWatchRoom(
// 加入房间
const joinRoom = useCallback(
- async (data: { roomId: string; password?: string; userName: string }) => {
+ async (data: { roomId: string; password?: string; userName: string; ownerToken?: string }) => {
const sock = watchRoomSocketManager.getSocket();
if (!sock || !watchRoomSocketManager.isConnected()) {
throw new Error('Not connected');
@@ -162,7 +179,7 @@ export function useWatchRoom(
isOwner: isRoomOwner,
userName: data.userName,
password: data.password,
- ownerToken: isRoomOwner ? response.room.ownerToken : undefined,
+ ownerToken: isRoomOwner ? (response.room.ownerToken || data.ownerToken) : undefined,
timestamp: Date.now(),
});
resolve({ room: response.room, members: response.members });
@@ -343,7 +360,11 @@ export function useWatchRoom(
});
socket.on('room:member-joined', (member) => {
- setMembers((prev) => [...prev, member]);
+ setMembers((prev) => {
+ const next = prev.filter((existing) => existing.id !== member.id);
+ next.push(member);
+ return next;
+ });
});
socket.on('room:member-left', (userId) => {
@@ -415,6 +436,10 @@ export function useWatchRoom(
// 连接状态
socket.on('connect', () => {
setIsConnected(true);
+ const storedInfo = getStoredRoomInfo();
+ if (storedInfo) {
+ scheduleRejoin(storedInfo);
+ }
});
socket.on('disconnect', () => {
@@ -436,7 +461,7 @@ export function useWatchRoom(
socket.off('connect');
socket.off('disconnect');
};
- }, [socket, currentRoom, onRoomDeleted, onStateCleared]);
+ }, [socket, currentRoom, onRoomDeleted, onStateCleared, scheduleRejoin]);
// 清理
useEffect(() => {
diff --git a/src/lib/watch-room-server.ts b/src/lib/watch-room-server.ts
index deb1862..a4bcd30 100644
--- a/src/lib/watch-room-server.ts
+++ b/src/lib/watch-room-server.ts
@@ -92,15 +92,33 @@ export class WatchRoomServer {
}
const userId = socket.id;
+ let isOwner = false;
+
+ if (data.ownerToken && data.ownerToken === room.ownerToken) {
+ isOwner = true;
+ room.ownerId = userId;
+ room.lastOwnerHeartbeat = Date.now();
+ this.rooms.set(data.roomId, room);
+ console.log(`[WatchRoom] Owner ${data.userName} reconnected to room ${data.roomId}`);
+ }
+
const member: Member = {
id: userId,
name: data.userName,
- isOwner: false,
+ isOwner,
lastHeartbeat: Date.now(),
};
const roomMembers = this.members.get(data.roomId);
if (roomMembers) {
+ if (isOwner) {
+ Array.from(roomMembers.entries()).forEach(([memberId, existingMember]) => {
+ if (existingMember.isOwner && memberId !== userId) {
+ roomMembers.delete(memberId);
+ }
+ });
+ }
+
roomMembers.set(userId, member);
room.memberCount = roomMembers.size;
this.rooms.set(data.roomId, room);
@@ -110,7 +128,7 @@ export class WatchRoomServer {
roomId: data.roomId,
userId,
userName: data.userName,
- isOwner: false,
+ isOwner,
});
socket.join(data.roomId);
@@ -118,7 +136,7 @@ export class WatchRoomServer {
// 通知房间内其他成员
socket.to(data.roomId).emit('room:member-joined', member);
- console.log(`[WatchRoom] User ${data.userName} joined room ${data.roomId}`);
+ console.log(`[WatchRoom] User ${data.userName} joined room ${data.roomId}${isOwner ? ' (as owner)' : ''}`);
const members = Array.from(roomMembers?.values() || []);
callback({ success: true, room, members });
diff --git a/src/lib/watch-room-socket.ts b/src/lib/watch-room-socket.ts
index 1949d98..02e5ee8 100644
--- a/src/lib/watch-room-socket.ts
+++ b/src/lib/watch-room-socket.ts
@@ -13,6 +13,7 @@ export type WatchRoomSocket = Socket
class WatchRoomSocketManager {
private socket: WatchRoomSocket | null = null;
private config: WatchRoomConfig | null = null;
+ private connectionPromise: Promise | null = null;
private heartbeatInterval: NodeJS.Timeout | null = null;
private heartbeatTimeoutCheck: NodeJS.Timeout | null = null;
private lastHeartbeatResponse: number = Date.now();
@@ -25,6 +26,37 @@ class WatchRoomSocketManager {
return this.socket;
}
+ if (this.connectionPromise) {
+ return this.connectionPromise;
+ }
+
+ if (this.socket) {
+ this.connectionPromise = new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ this.connectionPromise = null;
+ reject(new Error('Socket connection timeout'));
+ }, 10000);
+
+ this.socket!.once('connect', () => {
+ clearTimeout(timeout);
+ this.connectionPromise = null;
+ resolve(this.socket!);
+ });
+
+ this.socket!.once('connect_error', (error) => {
+ clearTimeout(timeout);
+ this.connectionPromise = null;
+ reject(error);
+ });
+
+ if (!this.socket!.connected) {
+ this.socket!.connect();
+ }
+ });
+
+ return this.connectionPromise;
+ }
+
this.config = config;
const socketOptions = {
@@ -72,8 +104,9 @@ class WatchRoomSocketManager {
// 设置浏览器可见性监听
this.setupVisibilityListener();
- return new Promise((resolve, reject) => {
+ this.connectionPromise = new Promise((resolve, reject) => {
if (!this.socket) {
+ this.connectionPromise = null;
reject(new Error('Socket not initialized'));
return;
}
@@ -82,6 +115,7 @@ class WatchRoomSocketManager {
this.socket.once('connect', () => {
// eslint-disable-next-line no-console
console.log('[WatchRoom] Connected to server');
+ this.connectionPromise = null;
if (this.socket) {
resolve(this.socket);
}
@@ -90,9 +124,12 @@ class WatchRoomSocketManager {
this.socket.once('connect_error', (error) => {
// eslint-disable-next-line no-console
console.error('[WatchRoom] Connection error:', error);
+ this.connectionPromise = null;
reject(error);
});
});
+
+ return this.connectionPromise;
}
disconnect() {
@@ -122,6 +159,8 @@ class WatchRoomSocketManager {
this.socket.disconnect();
this.socket = null;
}
+
+ this.connectionPromise = null;
}
getSocket(): WatchRoomSocket | null {
From 4bed01e7a3479d6667b3cd698d4b81b87398de8d Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Mon, 6 Apr 2026 11:02:57 +0800
Subject: [PATCH 09/18] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=85=A7=E7=89=87?=
=?UTF-8?q?=E5=A2=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/api/tmdb/images/route.ts | 105 +++++++++
src/components/DetailPanel.tsx | 359 ++++++++++++++++++++++++++-----
src/lib/tmdb.client.ts | 44 ++++
3 files changed, 459 insertions(+), 49 deletions(-)
create mode 100644 src/app/api/tmdb/images/route.ts
diff --git a/src/app/api/tmdb/images/route.ts b/src/app/api/tmdb/images/route.ts
new file mode 100644
index 0000000..e83120a
--- /dev/null
+++ b/src/app/api/tmdb/images/route.ts
@@ -0,0 +1,105 @@
+/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
+
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getConfig } from '@/lib/config';
+import { getTMDBImages } from '@/lib/tmdb.client';
+
+export const runtime = 'nodejs';
+
+/**
+ * GET /api/tmdb/images?id=xxx&type=movie|tv&page=1&pageSize=24
+ * 获取 TMDB 照片墙数据,并在服务端分页
+ */
+export async function GET(request: NextRequest) {
+ try {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo || !authInfo.username) {
+ return NextResponse.json({ error: '未授权' }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const id = searchParams.get('id');
+ const type = searchParams.get('type') || 'movie';
+ const pageParam = searchParams.get('page');
+ const pageSizeParam = searchParams.get('pageSize');
+ const page = pageParam ? Math.max(parseInt(pageParam, 10), 1) : null;
+ const pageSize = pageSizeParam ? Math.min(Math.max(parseInt(pageSizeParam, 10), 1), 60) : null;
+
+ if (!id) {
+ return NextResponse.json({ error: '缺少ID参数' }, { status: 400 });
+ }
+
+ if (type !== 'movie' && type !== 'tv') {
+ return NextResponse.json({ error: '类型参数必须是movie或tv' }, { status: 400 });
+ }
+
+ const config = await getConfig();
+ const tmdbApiKey = config.SiteConfig.TMDBApiKey;
+ const tmdbProxy = config.SiteConfig.TMDBProxy;
+ const tmdbReverseProxy = config.SiteConfig.TMDBReverseProxy;
+
+ if (!tmdbApiKey) {
+ return NextResponse.json({ error: 'TMDB API Key 未配置' }, { status: 400 });
+ }
+
+ const response = await getTMDBImages(
+ tmdbApiKey,
+ parseInt(id, 10),
+ type as 'movie' | 'tv',
+ tmdbProxy,
+ tmdbReverseProxy
+ );
+
+ if (response.code !== 200 || !response.images) {
+ return NextResponse.json(
+ { error: 'TMDB 图片信息获取失败', code: response.code },
+ { status: response.code }
+ );
+ }
+
+ const backdrops = (response.images.backdrops || []).map((item: any) => ({
+ ...item,
+ imageType: 'backdrop' as const,
+ }));
+ const posters = (response.images.posters || []).map((item: any) => ({
+ ...item,
+ imageType: 'poster' as const,
+ }));
+
+ const allImages = [...backdrops, ...posters].sort((a, b) => {
+ const voteDiff = (b.vote_average || 0) - (a.vote_average || 0);
+ if (voteDiff !== 0) return voteDiff;
+ return (b.vote_count || 0) - (a.vote_count || 0);
+ });
+
+ const total = allImages.length;
+
+ if (!page || !pageSize) {
+ return NextResponse.json({
+ total,
+ list: allImages,
+ });
+ }
+
+ const totalPages = Math.max(Math.ceil(total / pageSize), 1);
+ const safePage = Math.min(page, totalPages);
+ const start = (safePage - 1) * pageSize;
+ const list = allImages.slice(start, start + pageSize);
+
+ return NextResponse.json({
+ page: safePage,
+ pageSize,
+ total,
+ totalPages,
+ list,
+ });
+ } catch (error) {
+ console.error('TMDB图片信息获取失败:', error);
+ return NextResponse.json(
+ { error: '获取图片信息失败', details: (error as Error).message },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/components/DetailPanel.tsx b/src/components/DetailPanel.tsx
index 40cffb4..9c8cbe8 100644
--- a/src/components/DetailPanel.tsx
+++ b/src/components/DetailPanel.tsx
@@ -1,6 +1,6 @@
'use client';
-import { Calendar, Clock, ExternalLink, Film,Globe, Star, Tag, Users, X } from 'lucide-react';
+import { Calendar, Clock, ExternalLink, Film, Globe, Images, Star, Tag, Users, X } from 'lucide-react';
import Image from 'next/image';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
@@ -69,6 +69,16 @@ interface Episode {
air_date: string;
}
+interface GalleryImage {
+ file_path: string;
+ width: number;
+ height: number;
+ vote_average?: number;
+ vote_count?: number;
+ iso_639_1?: string | null;
+ imageType: 'backdrop' | 'poster';
+}
+
const DetailPanel: React.FC = ({
isOpen,
onClose,
@@ -100,6 +110,15 @@ const DetailPanel: React.FC = ({
const [seasonsLoaded, setSeasonsLoaded] = useState(false);
const [showImageViewer, setShowImageViewer] = useState(false);
const [selectedImage, setSelectedImage] = useState('');
+ const [showGallery, setShowGallery] = useState(false);
+ const [galleryLoading, setGalleryLoading] = useState(false);
+ const [galleryError, setGalleryError] = useState(null);
+ const [galleryImages, setGalleryImages] = useState([]);
+ const [galleryTotal, setGalleryTotal] = useState(0);
+ const [galleryScrollTop, setGalleryScrollTop] = useState(0);
+ const [galleryViewportHeight, setGalleryViewportHeight] = useState(0);
+ const [galleryViewportWidth, setGalleryViewportWidth] = useState(0);
+ const galleryScrollRef = React.useRef(null);
// 数据源状态管理
@@ -151,11 +170,82 @@ const DetailPanel: React.FC = ({
setShowImageViewer(true);
};
+ const galleryTmdbId = detailData?.tmdbId || tmdbId;
+ const galleryMediaType = detailData?.mediaType || type;
+ const canShowGalleryEntry = !!galleryTmdbId && !!galleryMediaType;
+
+ const fetchGalleryImages = async () => {
+ if (!galleryTmdbId || !galleryMediaType) return;
+
+ setGalleryLoading(true);
+ setGalleryError(null);
+
+ try {
+ const response = await fetch(
+ `/api/tmdb/images?id=${galleryTmdbId}&type=${galleryMediaType}`
+ );
+
+ if (!response.ok) {
+ throw new Error('获取照片墙失败');
+ }
+
+ const data = await response.json();
+ setGalleryImages(data.list || []);
+ setGalleryTotal(data.total || 0);
+ } catch (err) {
+ console.error('获取照片墙失败:', err);
+ setGalleryError(err instanceof Error ? err.message : '获取照片墙失败');
+ } finally {
+ setGalleryLoading(false);
+ }
+ };
+
+ const openGallery = () => {
+ setShowGallery(true);
+ };
+
// 确保组件在客户端挂载后才渲染 Portal
useEffect(() => {
setMounted(true);
}, []);
+ useEffect(() => {
+ if (!showGallery) {
+ setGalleryImages([]);
+ setGalleryError(null);
+ setGalleryLoading(false);
+ setGalleryTotal(0);
+ setGalleryScrollTop(0);
+ setGalleryViewportHeight(0);
+ setGalleryViewportWidth(0);
+ return;
+ }
+
+ fetchGalleryImages();
+ }, [showGallery, galleryTmdbId, galleryMediaType]);
+
+ useEffect(() => {
+ if (!showGallery || !galleryScrollRef.current) return;
+
+ const element = galleryScrollRef.current;
+
+ const updateMetrics = () => {
+ setGalleryViewportHeight(element.clientHeight);
+ setGalleryViewportWidth(element.clientWidth);
+ setGalleryScrollTop(element.scrollTop);
+ };
+
+ updateMetrics();
+ element.addEventListener('scroll', updateMetrics, { passive: true });
+ const resizeObserver = new ResizeObserver(updateMetrics);
+ resizeObserver.observe(element);
+
+ return () => {
+ element.removeEventListener('scroll', updateMetrics);
+ resizeObserver.disconnect();
+ };
+ }, [showGallery]);
+
// 控制动画状态
useEffect(() => {
let animationId: number;
@@ -185,6 +275,12 @@ const DetailPanel: React.FC = ({
};
}, [isOpen]);
+ useEffect(() => {
+ if (!isOpen) {
+ setShowGallery(false);
+ }
+ }, [isOpen]);
+
// 阻止背景滚动(仅在非抽屉模式下)
useEffect(() => {
if (isVisible && !useDrawer) {
@@ -887,6 +983,157 @@ const DetailPanel: React.FC = ({
}
};
+ const galleryEntryButton = canShowGalleryEntry ? (
+
+
+ 照片墙
+
+ ) : null;
+
+ const virtualGalleryLayout = React.useMemo(() => {
+ if (galleryImages.length === 0 || galleryViewportWidth <= 0) {
+ return {
+ visibleItems: [] as Array,
+ totalHeight: 0,
+ usedWidth: 0,
+ };
+ }
+
+ const gap = 4;
+ const overscan = 800;
+ const horizontalPadding = 32;
+ const width = Math.max(galleryViewportWidth - horizontalPadding, 0);
+ const columnCount = width >= 1280 ? 5 : width >= 1024 ? 4 : width >= 640 ? 3 : 2;
+ const columnWidth = Math.floor((width - gap * (columnCount - 1)) / columnCount);
+ const usedWidth = columnWidth * columnCount + gap * (columnCount - 1);
+ const columnHeights = new Array(columnCount).fill(0);
+
+ const items = galleryImages.map((image, index) => {
+ let targetColumn = 0;
+ for (let i = 1; i < columnCount; i++) {
+ if (columnHeights[i] < columnHeights[targetColumn]) {
+ targetColumn = i;
+ }
+ }
+
+ const ratio = image.width && image.height ? image.height / image.width : (image.imageType === 'poster' ? 1.5 : 0.5625);
+ const renderHeight = Math.max(Math.round(columnWidth * ratio), 80);
+ const top = columnHeights[targetColumn];
+ const left = targetColumn * (columnWidth + gap);
+
+ columnHeights[targetColumn] += renderHeight + gap;
+
+ return {
+ ...image,
+ index,
+ top,
+ left,
+ renderWidth: columnWidth,
+ renderHeight,
+ };
+ });
+
+ const totalHeight = Math.max(...columnHeights, 0);
+ const minVisibleTop = Math.max(galleryScrollTop - overscan, 0);
+ const maxVisibleBottom = galleryScrollTop + galleryViewportHeight + overscan;
+ const visibleItems = items.filter(item => item.top + item.renderHeight >= minVisibleTop && item.top <= maxVisibleBottom);
+
+ return { visibleItems, totalHeight, usedWidth };
+ }, [galleryImages, galleryScrollTop, galleryViewportHeight, galleryViewportWidth]);
+
+ const galleryModal = showGallery ? (
+
+
setShowGallery(false)}
+ />
+
+
+
+
照片墙
+ {!galleryLoading && (
+
+ 共 {galleryTotal} 张
+
+ )}
+
+
setShowGallery(false)}
+ className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
+ aria-label="关闭照片墙"
+ >
+
+
+
+
+
+ {galleryLoading && (
+
+ )}
+
+ {!galleryLoading && galleryError && (
+
{galleryError}
+ )}
+
+ {!galleryLoading && !galleryError && galleryImages.length === 0 && (
+
暂无图片
+ )}
+
+ {!galleryLoading && !galleryError && galleryImages.length > 0 && (
+
+ {virtualGalleryLayout.visibleItems.map((image) => {
+ const imageUrl = processImageUrl(
+ getTMDBImageUrl(image.file_path, image.imageType === 'poster' ? 'w500' : 'original')
+ );
+ const thumbUrl = processImageUrl(
+ getTMDBImageUrl(image.file_path, image.imageType === 'poster' ? 'w342' : 'w780')
+ );
+
+ return (
+
+
handleImageClick(imageUrl)}
+ >
+
+
+ {image.imageType === 'poster' ? '海报' : '剧照'}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ ) : null;
+
if (!isVisible || !mounted) return null;
const content = useDrawer ? (
@@ -938,7 +1185,7 @@ const DetailPanel: React.FC
= ({
{/* 数据源显示和切换 - 错误时也显示 */}
-
+
数据来源:
@@ -948,24 +1195,27 @@ const DetailPanel: React.FC = ({
{currentSource === 'tmdb' && 'TMDB'}
- {currentSource !== 'tmdb' && (
-
- 切换到 TMDB
-
- )}
- {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
-
- 切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
-
- )}
+
+ {galleryEntryButton}
+ {currentSource !== 'tmdb' && (
+
+ 切换到 TMDB
+
+ )}
+ {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
+
+ 切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
+
+ )}
+
@@ -976,11 +1226,14 @@ const DetailPanel: React.FC = ({
{/* 海报和基本信息 */}
{detailData.poster && (
-
handleImageClick(detailData.poster!)}
- >
-
+
+
handleImageClick(detailData.poster!)}
+ >
+
+
+ {galleryEntryButton}
)}
@@ -1332,7 +1585,7 @@ const DetailPanel: React.FC
= ({
{/* 数据源显示和切换 */}
-
+
数据来源:
@@ -1342,24 +1595,27 @@ const DetailPanel: React.FC = ({
{currentSource === 'tmdb' && 'TMDB'}
- {currentSource !== 'tmdb' && (
-
- 切换到 TMDB
-
- )}
- {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
-
- 切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
-
- )}
+
+ {galleryEntryButton}
+ {currentSource !== 'tmdb' && (
+
+ 切换到 TMDB
+
+ )}
+ {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
+
+ 切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
+
+ )}
+
@@ -1368,6 +1624,7 @@ const DetailPanel: React.FC = ({
{/* 图片查看器 */}
+ {galleryModal}
{showImageViewer && (
= ({
{/* 海报和基本信息 */}
{detailData.poster && (
-
handleImageClick(detailData.poster!)}
- >
-
+
+
handleImageClick(detailData.poster!)}
+ >
+
+
+ {galleryEntryButton}
)}
@@ -1872,6 +2132,7 @@ const DetailPanel: React.FC = ({
{/* 图片查看器 */}
+ {galleryModal}
{showImageViewer && (
{
+ try {
+ const actualKey = getNextApiKey(apiKey);
+ if (!actualKey) {
+ return { code: 400, images: null };
+ }
+
+ const baseUrl = reverseProxyBaseUrl || DEFAULT_TMDB_BASE_URL;
+ const url = `${baseUrl}/3/${mediaType}/${mediaId}/images?api_key=${actualKey}`;
+
+ const response = await universalFetch(url, proxy);
+
+ if (!response.ok) {
+ console.error('TMDB Images API 请求失败:', response.status, response.statusText);
+ return { code: response.status, images: null };
+ }
+
+ const data: any = await response.json();
+
+ return {
+ code: 200,
+ images: data,
+ };
+ } catch (error) {
+ console.error('获取 TMDB 图片信息失败:', error);
+ return { code: 500, images: null };
+ }
+}
From 9b707c1a17bc42cb11cb5b84c72d4f32715e37cf Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Tue, 7 Apr 2026 16:58:22 +0800
Subject: [PATCH 10/18] =?UTF-8?q?=E5=A4=B8=E5=85=8B=E7=BD=91=E7=9B=98?=
=?UTF-8?q?=E8=BD=AC=E5=AD=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/admin/page.tsx | 226 ++++++++++++
src/app/api/admin/netdisk/route.ts | 85 +++++
src/app/api/quark/instant-play/route.ts | 67 ++++
src/app/api/quark/transfer/route.ts | 44 +++
src/app/api/source-detail/route.ts | 127 ++++++-
src/app/play/page.tsx | 4 +
src/components/PansouSearch.tsx | 84 ++++-
src/lib/admin.types.ts | 9 +
src/lib/config.ts | 22 ++
src/lib/quark.client.ts | 471 ++++++++++++++++++++++++
10 files changed, 1132 insertions(+), 7 deletions(-)
create mode 100644 src/app/api/admin/netdisk/route.ts
create mode 100644 src/app/api/quark/instant-play/route.ts
create mode 100644 src/app/api/quark/transfer/route.ts
create mode 100644 src/lib/quark.client.ts
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index b9700ce..1f5a406 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -30,6 +30,7 @@ import {
CheckCircle,
ChevronDown,
ChevronUp,
+ Cloud,
Database,
ExternalLink,
FileText,
@@ -3499,6 +3500,219 @@ const OpenListConfigComponent = ({
);
};
+const NetDiskConfigComponent = ({
+ config,
+ refreshConfig,
+}: {
+ config: AdminConfig | null;
+ refreshConfig: () => Promise;
+}) => {
+ const { alertModal, showAlert, hideAlert } = useAlertModal();
+ const { isLoading, withLoading } = useLoadingState();
+ const [enabled, setEnabled] = useState(false);
+ const [cookie, setCookie] = useState('');
+ const [savePath, setSavePath] = useState('/');
+ const [playTempSavePath, setPlayTempSavePath] = useState('/');
+ const [openListTempPath, setOpenListTempPath] = useState('/');
+
+ useEffect(() => {
+ const quark = config?.NetDiskConfig?.Quark;
+ setEnabled(quark?.Enabled || false);
+ setCookie(quark?.Cookie || '');
+ setSavePath(quark?.SavePath || '/');
+ setPlayTempSavePath(quark?.PlayTempSavePath || '/');
+ setOpenListTempPath(quark?.OpenListTempPath || '/');
+ }, [config]);
+
+ const handleSave = async () => {
+ await withLoading('saveNetDisk', async () => {
+ const response = await fetch('/api/admin/netdisk', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'save',
+ Quark: {
+ Enabled: enabled,
+ Cookie: cookie,
+ SavePath: savePath,
+ PlayTempSavePath: playTempSavePath,
+ OpenListTempPath: openListTempPath,
+ },
+ }),
+ });
+
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || '保存失败');
+ }
+
+ showSuccess('保存成功', showAlert);
+ await refreshConfig();
+ });
+ };
+
+ const handleValidate = async () => {
+ await withLoading('validateNetDisk', async () => {
+ try {
+ const response = await fetch('/api/admin/netdisk', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'validate',
+ Quark: {
+ Cookie: cookie,
+ SavePath: savePath,
+ PlayTempSavePath: playTempSavePath,
+ },
+ }),
+ });
+
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || '校验失败');
+ }
+
+ showSuccess(data.message || '夸克 Cookie 可读', showAlert);
+ } catch (error) {
+ showError(error instanceof Error ? error.message : '校验失败', showAlert);
+ throw error;
+ }
+ });
+ };
+
+ return (
+
+
+
+ 夸克网盘
+
+
+
+
+
+
+ 夸克网盘说明
+
+
+
+
• 转存:把整个分享保存到夸克正式目录。
+
• 立即播放:将分享内所有视频文件转存到临时播放目录,再通过 OpenList 临时目录直接播放。
+
• OpenList 临时目录必须映射到夸克临时播放目录,否则立即播放无法找到文件。
+
+
+
+
+
+
+ 启用夸克网盘
+
+
+ 开启后,网盘搜索中的夸克资源会显示“立即播放”和“转存”按钮
+
+
+
+
+
+
+
+
+
+
+
+ setSavePath(e.target.value)}
+ disabled={!enabled}
+ placeholder='/影视/正式转存'
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
+ />
+
+
+
+
+ setPlayTempSavePath(e.target.value)}
+ disabled={!enabled}
+ placeholder='/影视/.play-temp'
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
+ />
+
+
+
+
+
setOpenListTempPath(e.target.value)}
+ disabled={!enabled}
+ placeholder='/Quark/影视/.play-temp'
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
+ />
+
+ OpenList 中能访问到临时播放目录的路径。
+
+
+
+
+
+ {isLoading('validateNetDisk') ? '校验中...' : '校验夸克配置'}
+
+
+ {isLoading('saveNetDisk') ? '保存中...' : '保存配置'}
+
+
+
+
+
+
+
+ );
+};
+
// Emby 媒体库配置组件 - 多源管理版本
const EmbyConfigComponent = ({
config,
@@ -12732,6 +12946,7 @@ function AdminPageClient() {
sourceScriptLab: false,
mediaLibrary: false,
openListConfig: false,
+ netDiskConfig: false,
embyConfig: false,
xiaoyaConfig: false,
animeSubscription: false,
@@ -13210,6 +13425,17 @@ function AdminPageClient() {
>
+
+
+ }
+ isExpanded={expandedTabs.netDiskConfig}
+ onToggle={() => toggleTab('netDiskConfig')}
+ >
+
+
diff --git a/src/app/api/admin/netdisk/route.ts b/src/app/api/admin/netdisk/route.ts
new file mode 100644
index 0000000..70795df
--- /dev/null
+++ b/src/app/api/admin/netdisk/route.ts
@@ -0,0 +1,85 @@
+/* eslint-disable no-console */
+
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getConfig, setCachedConfig } from '@/lib/config';
+import { db } from '@/lib/db';
+import {
+ assertQuarkCookieHeaderSafe,
+ normalizeQuarkCookie,
+ validateQuarkCookieReadable,
+} from '@/lib/quark.client';
+
+export const runtime = 'nodejs';
+
+function requireOwner(username: string | undefined) {
+ return username === process.env.USERNAME;
+}
+
+export async function POST(request: NextRequest) {
+ const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
+ if (storageType === 'localstorage') {
+ return NextResponse.json(
+ { error: '不支持本地存储进行管理员配置' },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo?.username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ if (!requireOwner(authInfo.username)) {
+ const userInfo = await db.getUserInfoV2(authInfo.username);
+ if (!userInfo || userInfo.role !== 'admin' || userInfo.banned) {
+ return NextResponse.json({ error: '权限不足' }, { status: 401 });
+ }
+ }
+
+ const body = await request.json();
+ const { action, Quark } = body;
+ const adminConfig = await getConfig();
+
+ if (action === 'save') {
+ const normalizedCookie = Quark?.Cookie ? assertQuarkCookieHeaderSafe(Quark.Cookie) : '';
+
+ adminConfig.NetDiskConfig = adminConfig.NetDiskConfig || {};
+ adminConfig.NetDiskConfig.Quark = {
+ Enabled: Boolean(Quark?.Enabled),
+ Cookie: normalizedCookie,
+ SavePath: Quark?.SavePath || '/',
+ PlayTempSavePath: Quark?.PlayTempSavePath || '/',
+ OpenListTempPath: Quark?.OpenListTempPath || '/',
+ };
+
+ await db.saveAdminConfig(adminConfig);
+ await setCachedConfig(adminConfig);
+
+ return NextResponse.json({ success: true, message: '保存成功' });
+ }
+
+ if (action === 'validate') {
+ if (!Quark?.Cookie) {
+ return NextResponse.json({ error: '请先填写夸克 Cookie' }, { status: 400 });
+ }
+
+ await validateQuarkCookieReadable(normalizeQuarkCookie(Quark.Cookie));
+
+ return NextResponse.json({
+ success: true,
+ message: '夸克cookie正常',
+ });
+ }
+
+ return NextResponse.json({ error: '未知操作' }, { status: 400 });
+ } catch (error) {
+ console.error('[Admin NetDisk] 操作失败:', error);
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : '操作失败' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/quark/instant-play/route.ts b/src/app/api/quark/instant-play/route.ts
new file mode 100644
index 0000000..5a281bc
--- /dev/null
+++ b/src/app/api/quark/instant-play/route.ts
@@ -0,0 +1,67 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getConfig } from '@/lib/config';
+import { createQuarkInstantPlayFolder } from '@/lib/quark.client';
+import { base58Encode } from '@/lib/utils';
+
+export const runtime = 'nodejs';
+
+function joinPath(...parts: string[]) {
+ const joined = parts
+ .filter(Boolean)
+ .join('/')
+ .replace(/\/+/g, '/');
+ return joined.startsWith('/') ? joined : `/${joined}`;
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo?.username) {
+ return NextResponse.json({ error: '未登录' }, { status: 401 });
+ }
+
+ const { shareUrl, passcode, title } = await request.json();
+ if (!shareUrl) {
+ return NextResponse.json({ error: '分享链接不能为空' }, { status: 400 });
+ }
+
+ const config = await getConfig();
+ const quarkConfig = config.NetDiskConfig?.Quark;
+
+ if (!quarkConfig?.Enabled || !quarkConfig.Cookie) {
+ return NextResponse.json({ error: '夸克网盘未配置或未启用' }, { status: 400 });
+ }
+
+ const result = await createQuarkInstantPlayFolder(quarkConfig.Cookie, {
+ shareUrl,
+ passcode,
+ playTempSavePath: quarkConfig.PlayTempSavePath,
+ title,
+ });
+
+ if (!result.folderName) {
+ throw new Error('未生成临时播放目录');
+ }
+
+ const openlistFolderPath = joinPath(
+ quarkConfig.OpenListTempPath,
+ result.folderName
+ );
+
+ return NextResponse.json({
+ success: true,
+ source: 'quark-temp',
+ id: base58Encode(openlistFolderPath),
+ title: title || result.folderName,
+ openlistFolderPath,
+ ...result,
+ });
+ } catch (error) {
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : '立即播放失败' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/quark/transfer/route.ts b/src/app/api/quark/transfer/route.ts
new file mode 100644
index 0000000..9009b83
--- /dev/null
+++ b/src/app/api/quark/transfer/route.ts
@@ -0,0 +1,44 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getConfig } from '@/lib/config';
+import { transferQuarkShare } from '@/lib/quark.client';
+
+export const runtime = 'nodejs';
+
+export async function POST(request: NextRequest) {
+ try {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo?.username) {
+ return NextResponse.json({ error: '未登录' }, { status: 401 });
+ }
+
+ const { shareUrl, passcode } = await request.json();
+ if (!shareUrl) {
+ return NextResponse.json({ error: '分享链接不能为空' }, { status: 400 });
+ }
+
+ const config = await getConfig();
+ const quarkConfig = config.NetDiskConfig?.Quark;
+
+ if (!quarkConfig?.Enabled || !quarkConfig.Cookie) {
+ return NextResponse.json({ error: '夸克网盘未配置或未启用' }, { status: 400 });
+ }
+
+ const result = await transferQuarkShare(quarkConfig.Cookie, {
+ shareUrl,
+ passcode,
+ savePath: quarkConfig.SavePath,
+ });
+
+ return NextResponse.json({
+ success: true,
+ ...result,
+ });
+ } catch (error) {
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : '转存失败' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts
index 381cf62..ebed2ec 100644
--- a/src/app/api/source-detail/route.ts
+++ b/src/app/api/source-detail/route.ts
@@ -29,6 +29,7 @@ export async function GET(request: NextRequest) {
const id = searchParams.get('id');
const sourceCode = searchParams.get('source');
const fileName = searchParams.get('fileName'); // 小雅源:用户点击的文件名
+ const title = searchParams.get('title');
if (!id || !sourceCode) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
@@ -274,6 +275,124 @@ export async function GET(request: NextRequest) {
}
}
+ if (sourceCode === 'quark-temp') {
+ try {
+ const config = await getConfig();
+ const openListConfig = config.OpenListConfig;
+
+ if (
+ !openListConfig ||
+ !openListConfig.Enabled ||
+ !openListConfig.URL ||
+ !openListConfig.Username ||
+ !openListConfig.Password
+ ) {
+ throw new Error('OpenList 未配置或未启用');
+ }
+
+ const { base58Decode } = await import('@/lib/utils');
+ const { OpenListClient } = await import('@/lib/openlist.client');
+ const { parseVideoFileName } = await import('@/lib/video-parser');
+
+ const folderPath = base58Decode(id);
+ if (!folderPath) {
+ throw new Error('无效的临时播放目录');
+ }
+
+ const client = new OpenListClient(
+ openListConfig.URL,
+ openListConfig.Username,
+ openListConfig.Password
+ );
+
+ const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob'];
+
+ const collectFiles = async (currentPath: string): Promise
> => {
+ const allFiles: Array<{ path: string; name: string }> = [];
+ let currentPage = 1;
+ const pageSize = 100;
+ let hasMore = true;
+
+ while (hasMore) {
+ const response = await client.listDirectory(currentPath, currentPage, pageSize);
+ if (response.code !== 200) {
+ throw new Error('读取临时目录失败');
+ }
+
+ for (const item of response.data.content) {
+ const itemPath = `${currentPath}${currentPath.endsWith('/') ? '' : '/'}${item.name}`;
+ if (item.is_dir) {
+ const nested = await collectFiles(itemPath);
+ allFiles.push(...nested);
+ } else if (
+ !item.name.startsWith('.') &&
+ videoExtensions.some((ext) => item.name.toLowerCase().endsWith(ext))
+ ) {
+ allFiles.push({
+ path: itemPath,
+ name: item.name,
+ });
+ }
+ }
+
+ hasMore = !(
+ response.data.content.length < pageSize ||
+ currentPage * pageSize >= response.data.total
+ );
+ currentPage += 1;
+ }
+
+ return allFiles;
+ };
+
+ const files = await collectFiles(folderPath);
+ if (files.length === 0) {
+ throw new Error('临时播放目录中没有视频文件');
+ }
+
+ const episodes = files
+ .map((file, index) => {
+ const parsed = parseVideoFileName(file.name);
+ const fileDir = file.path.substring(0, file.path.lastIndexOf('/')) || '/';
+ return {
+ fileName: file.name,
+ fileDir,
+ episode: parsed.episode || index + 1,
+ title:
+ parsed.title ||
+ (parsed.episode ? `第${parsed.episode}集` : file.name),
+ isOVA: parsed.isOVA,
+ };
+ })
+ .sort((a, b) => {
+ if (a.isOVA && !b.isOVA) return 1;
+ if (!a.isOVA && b.isOVA) return -1;
+ return a.episode !== b.episode
+ ? a.episode - b.episode
+ : a.fileName.localeCompare(b.fileName);
+ });
+
+ return NextResponse.json({
+ source: 'quark-temp',
+ source_name: '夸克临时播放',
+ id,
+ title: title || folderPath.split('/').filter(Boolean).pop() || '夸克临时播放',
+ poster: '',
+ year: '',
+ douban_id: 0,
+ desc: `临时播放目录:${folderPath}`,
+ episodes: episodes.map((ep) => `/api/openlist/play?folder=${encodeURIComponent(ep.fileDir)}&fileName=${encodeURIComponent(ep.fileName)}`),
+ episodes_titles: episodes.map((ep) => ep.title),
+ proxyMode: false,
+ });
+ } catch (error) {
+ return NextResponse.json(
+ { error: (error as Error).message },
+ { status: 500 }
+ );
+ }
+ }
+
// 特殊处理 openlist 源 - 直接调用 /api/detail
if (sourceCode === 'openlist') {
try {
@@ -340,8 +459,9 @@ export async function GET(request: NextRequest) {
let currentPage = 1;
const pageSize = 100;
let total = 0;
+ let hasMore = true;
- while (true) {
+ while (hasMore) {
const listResponse = await client.listDirectory(folderPath, currentPage, pageSize);
if (listResponse.code !== 200) {
@@ -351,10 +471,7 @@ export async function GET(request: NextRequest) {
total = listResponse.data.total;
allFiles.push(...listResponse.data.content);
- if (allFiles.length >= total) {
- break;
- }
-
+ hasMore = allFiles.length < total;
currentPage++;
}
diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx
index 1ac0d4c..11fd253 100644
--- a/src/app/play/page.tsx
+++ b/src/app/play/page.tsx
@@ -1413,6 +1413,7 @@ function PlayPageClient() {
!isM3u8LikeUrl(videoUrl) &&
(
detail.source === 'openlist' ||
+ detail.source === 'quark-temp' ||
detail.source === 'xiaoya' ||
detail.source.startsWith('emby')
)
@@ -9161,6 +9162,7 @@ function PlayPageClient() {
// 特殊源使用 tmdb,其他使用 cms(通过 doubanId)
// 如果有豆瓣ID且不为0,传入doubanId
detail.source === 'openlist' ||
+ detail.source === 'quark-temp' ||
detail.source?.startsWith('emby') ||
detail.source === 'xiaoya'
? undefined
@@ -9171,6 +9173,7 @@ function PlayPageClient() {
tmdbId={
// 特殊源使用 tmdb
detail.source === 'openlist' ||
+ detail.source === 'quark-temp' ||
detail.source?.startsWith('emby') ||
detail.source === 'xiaoya'
? detail.tmdb_id
@@ -9182,6 +9185,7 @@ function PlayPageClient() {
// 非特殊源使用 cms 数据
// 但如果有豆瓣ID且不为0,则不传入cmsData,优先使用豆瓣数据
detail.source !== 'openlist' &&
+ detail.source !== 'quark-temp' &&
!detail.source?.startsWith('emby') &&
detail.source !== 'xiaoya' &&
!(detail.douban_id && detail.douban_id !== 0)
diff --git a/src/components/PansouSearch.tsx b/src/components/PansouSearch.tsx
index 0d78a75..72e830b 100644
--- a/src/components/PansouSearch.tsx
+++ b/src/components/PansouSearch.tsx
@@ -2,7 +2,8 @@
'use client';
import { AlertCircle, Copy, ExternalLink, Loader2, RefreshCw } from 'lucide-react';
-import { useEffect, useState, useCallback } from 'react';
+import { useRouter } from 'next/navigation';
+import { useCallback, useEffect, useState } from 'react';
import { PansouLink, PansouSearchResult } from '@/lib/pansou.client';
@@ -51,11 +52,14 @@ export default function PansouSearch({
triggerSearch,
onError,
}: PansouSearchProps) {
+ const router = useRouter();
const [loading, setLoading] = useState(false);
const [results, setResults] = useState(null);
const [error, setError] = useState(null);
const [copiedUrl, setCopiedUrl] = useState(null);
const [selectedType, setSelectedType] = useState('all'); // 'all' 表示显示全部
+ const [transferingUrl, setTransferingUrl] = useState(null);
+ const [playingUrl, setPlayingUrl] = useState(null);
// 提取搜索函数,以便在重试时调用
const searchPansou = useCallback(async () => {
@@ -118,6 +122,63 @@ export default function PansouSearch({
window.open(url, '_blank', 'noopener,noreferrer');
};
+ const handleQuarkTransfer = async (link: PansouLink) => {
+ try {
+ setTransferingUrl(link.url);
+ const response = await fetch('/api/quark/transfer', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ shareUrl: link.url,
+ passcode: link.password || '',
+ }),
+ });
+
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || '转存失败');
+ }
+
+ window.alert(`转存成功,已保存到:${data.targetPath}`);
+ } catch (err: any) {
+ window.alert(err?.message || '转存失败');
+ } finally {
+ setTransferingUrl(null);
+ }
+ };
+
+ const handleQuarkInstantPlay = async (link: PansouLink) => {
+ try {
+ setPlayingUrl(link.url);
+ const response = await fetch('/api/quark/instant-play', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ shareUrl: link.url,
+ passcode: link.password || '',
+ title: link.note || keyword,
+ }),
+ });
+
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || '立即播放失败');
+ }
+
+ router.push(
+ `/play?source=quark-temp&id=${encodeURIComponent(data.id)}&title=${encodeURIComponent(data.title || keyword)}`
+ );
+ } catch (err: any) {
+ window.alert(err?.message || '立即播放失败');
+ } finally {
+ setPlayingUrl(null);
+ }
+ };
+
if (loading) {
return (
@@ -196,7 +257,6 @@ export default function PansouSearch({
{typeStats.map(({ type, count }) => {
const typeName = CLOUD_TYPE_NAMES[type] || type;
- const typeColor = CLOUD_TYPE_COLORS[type] || CLOUD_TYPE_COLORS.others;
return (
+ {cloudType === 'quark' && (
+ <>
+ handleQuarkInstantPlay(link)}
+ disabled={playingUrl === link.url}
+ className='px-2 py-1 rounded-md bg-green-600 hover:bg-green-700 text-white text-xs transition-colors disabled:opacity-60'
+ title='立即播放'
+ >
+ {playingUrl === link.url ? '处理中...' : '立即播放'}
+
+ handleQuarkTransfer(link)}
+ disabled={transferingUrl === link.url}
+ className='px-2 py-1 rounded-md bg-purple-600 hover:bg-purple-700 text-white text-xs transition-colors disabled:opacity-60'
+ title='转存到配置目录'
+ >
+ {transferingUrl === link.url ? '转存中...' : '转存'}
+
+ >
+ )}
handleCopy(
link.password ? `${link.url}\n提取码: ${link.password}` : link.url,
diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts
index 33b8332..9c20072 100644
--- a/src/lib/admin.types.ts
+++ b/src/lib/admin.types.ts
@@ -143,6 +143,15 @@ export interface AdminConfig {
ScanMode?: 'torrent' | 'name' | 'hybrid'; // 扫描模式:torrent=种子库匹配,name=名字匹配,hybrid=混合模式(默认)
DisableVideoPreview?: boolean; // 禁用预览视频,直接返回直连链接
};
+ NetDiskConfig?: {
+ Quark?: {
+ Enabled: boolean;
+ Cookie: string;
+ SavePath: string;
+ PlayTempSavePath: string;
+ OpenListTempPath: string;
+ };
+ };
AIConfig?: {
Enabled: boolean; // 是否启用AI问片功能
Provider: 'openai' | 'claude' | 'custom'; // AI服务提供商
diff --git a/src/lib/config.ts b/src/lib/config.ts
index c35c9c2..042c89c 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -618,6 +618,28 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
}
}
+ if (!adminConfig.NetDiskConfig) {
+ adminConfig.NetDiskConfig = {
+ Quark: {
+ Enabled: false,
+ Cookie: '',
+ SavePath: '/',
+ PlayTempSavePath: '/',
+ OpenListTempPath: '/',
+ },
+ };
+ }
+
+ if (!adminConfig.NetDiskConfig.Quark) {
+ adminConfig.NetDiskConfig.Quark = {
+ Enabled: false,
+ Cookie: '',
+ SavePath: '/',
+ PlayTempSavePath: '/',
+ OpenListTempPath: '/',
+ };
+ }
+
// 确保音乐配置存在
if (!adminConfig.MusicConfig) {
adminConfig.MusicConfig = {
diff --git a/src/lib/quark.client.ts b/src/lib/quark.client.ts
new file mode 100644
index 0000000..4e2dc0c
--- /dev/null
+++ b/src/lib/quark.client.ts
@@ -0,0 +1,471 @@
+/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
+
+const QUARK_SHARE_API_BASE = 'https://drive-h.quark.cn/1/clouddrive';
+const QUARK_DRIVE_API_BASE = 'https://drive-pc.quark.cn/1/clouddrive';
+const QUARK_QUERY = 'pr=ucpro&fr=pc';
+
+export interface QuarkShareLinkInfo {
+ pwdId: string;
+ passcode: string;
+}
+
+export interface QuarkShareItem {
+ fid: string;
+ fileName: string;
+ dir: boolean;
+ shareFidToken?: string;
+ pdirFid?: string;
+}
+
+export interface QuarkTransferTaskResult {
+ taskId?: string;
+ fileCount: number;
+ targetPath: string;
+ folderName?: string;
+}
+
+const VIDEO_EXTENSIONS = [
+ '.mp4',
+ '.mkv',
+ '.avi',
+ '.m3u8',
+ '.flv',
+ '.ts',
+ '.mov',
+ '.wmv',
+ '.webm',
+ '.rmvb',
+ '.rm',
+ '.mpg',
+ '.mpeg',
+ '.3gp',
+ '.f4v',
+ '.m4v',
+ '.vob',
+];
+
+function buildApiUrl(base: string, path: string, query = '') {
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
+ return `${base}${normalizedPath}?${QUARK_QUERY}${query ? `&${query}` : ''}`;
+}
+
+function getHeaders(cookie: string): HeadersInit {
+ return {
+ 'content-type': 'application/json',
+ cookie,
+ origin: 'https://pan.quark.cn',
+ referer: 'https://pan.quark.cn/',
+ 'user-agent':
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
+ };
+}
+
+export function normalizeQuarkCookie(cookie: string): string {
+ return cookie
+ .replace(/;/g, ';')
+ .replace(/:/g, ':')
+ .replace(/,/g, ',')
+ .trim();
+}
+
+export function assertQuarkCookieHeaderSafe(cookie: string): string {
+ const normalized = normalizeQuarkCookie(cookie);
+ for (let i = 0; i < normalized.length; i += 1) {
+ if (normalized.charCodeAt(i) > 255) {
+ throw new Error('夸克 Cookie 含有非法字符,请确认没有中文标点、中文空格或说明文字');
+ }
+ }
+ return normalized;
+}
+
+function normalizePath(path: string): string {
+ const trimmed = path.trim();
+ if (!trimmed || trimmed === '/') return '/';
+ return `/${trimmed.replace(/^\/+|\/+$/g, '')}`;
+}
+
+function joinPath(...parts: string[]) {
+ const joined = parts
+ .filter(Boolean)
+ .join('/')
+ .replace(/\/+/g, '/');
+ return normalizePath(joined);
+}
+
+function sanitizeFolderName(name: string) {
+ return (name || 'quark-temp')
+ .replace(/[<>:"/\\|?*]/g, ' ')
+ .replace(/[\r\n\t]/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, 80);
+}
+
+async function parseJson(response: Response) {
+ const text = await response.text();
+ try {
+ return JSON.parse(text);
+ } catch {
+ throw new Error(`夸克接口返回异常:${text.slice(0, 200)}`);
+ }
+}
+
+function ensureOk(data: any, fallbackMessage: string) {
+ if (data?.code === 0 || data?.code === 200 || data?.status === 200) {
+ return;
+ }
+ throw new Error(data?.message || data?.msg || fallbackMessage);
+}
+
+export function parseQuarkShareUrl(url: string, passcode = ''): QuarkShareLinkInfo {
+ const parsed = new URL(url);
+ const pwdId =
+ parsed.pathname.match(/\/s\/([A-Za-z0-9_-]+)/)?.[1] ||
+ parsed.searchParams.get('pwd_id') ||
+ '';
+
+ if (!pwdId) {
+ throw new Error('无法解析夸克分享链接');
+ }
+
+ return {
+ pwdId,
+ passcode:
+ passcode ||
+ parsed.searchParams.get('pwd') ||
+ parsed.searchParams.get('passcode') ||
+ '',
+ };
+}
+
+async function fetchShareToken(cookie: string, share: QuarkShareLinkInfo) {
+ const response = await fetch(
+ buildApiUrl(QUARK_SHARE_API_BASE, '/share/sharepage/token'),
+ {
+ method: 'POST',
+ headers: getHeaders(cookie),
+ body: JSON.stringify({
+ pwd_id: share.pwdId,
+ passcode: share.passcode,
+ }),
+ }
+ );
+
+ const data = await parseJson(response);
+ ensureOk(data, '获取夸克分享 token 失败');
+
+ const stoken =
+ data?.data?.stoken ||
+ data?.data?.share_token ||
+ data?.data?.token;
+
+ if (!stoken) {
+ throw new Error('夸克分享 token 缺失');
+ }
+
+ return {
+ stoken,
+ shareTitle: data?.data?.title || '',
+ };
+}
+
+async function fetchShareFolderItems(
+ cookie: string,
+ pwdId: string,
+ stoken: string,
+ pdirFid = '0'
+): Promise {
+ const query = new URLSearchParams({
+ pwd_id: pwdId,
+ stoken,
+ pdir_fid: pdirFid,
+ _page: '1',
+ _size: '200',
+ _fetch_banner: '0',
+ });
+
+ const response = await fetch(
+ buildApiUrl(QUARK_SHARE_API_BASE, '/share/sharepage/detail', query.toString()),
+ {
+ method: 'GET',
+ headers: getHeaders(cookie),
+ }
+ );
+
+ const data = await parseJson(response);
+ ensureOk(data, '获取夸克分享详情失败');
+
+ const list = data?.data?.list || [];
+ return list.map((item: any) => ({
+ fid: String(item.fid || item.file_id || ''),
+ fileName: String(item.file_name || item.name || ''),
+ dir: Boolean(item.dir || item.is_dir || item.file_type === 0),
+ shareFidToken:
+ item.share_fid_token || item.fid_token || item.share_token || undefined,
+ pdirFid: String(item.pdir_fid || pdirFid || '0'),
+ }));
+}
+
+async function fetchDriveFolderItems(
+ cookie: string,
+ pdirFid = '0'
+): Promise {
+ const query = new URLSearchParams({
+ pdir_fid: pdirFid,
+ _page: '1',
+ _size: '200',
+ _sort: 'file_type:asc,file_name:asc',
+ });
+
+ const response = await fetch(
+ buildApiUrl(QUARK_DRIVE_API_BASE, '/file/sort', query.toString()),
+ {
+ method: 'GET',
+ headers: getHeaders(cookie),
+ }
+ );
+
+ const data = await parseJson(response);
+ ensureOk(data, '获取夸克目录列表失败');
+ return data?.data?.list || [];
+}
+
+export async function validateQuarkCookieReadable(cookie: string): Promise {
+ const safeCookie = assertQuarkCookieHeaderSafe(cookie);
+ await fetchDriveFolderItems(safeCookie, '0');
+}
+
+async function createDriveFolder(
+ cookie: string,
+ parentFid: string,
+ folderName: string
+) {
+ const response = await fetch(buildApiUrl(QUARK_DRIVE_API_BASE, '/file'), {
+ method: 'POST',
+ headers: getHeaders(cookie),
+ body: JSON.stringify({
+ pdir_fid: parentFid,
+ file_name: folderName,
+ dir_path: '',
+ dir_init_lock: false,
+ }),
+ });
+
+ const data = await parseJson(response);
+ ensureOk(data, `创建夸克目录失败:${folderName}`);
+
+ const fid =
+ data?.data?.fid ||
+ data?.data?.file_id ||
+ data?.metadata?.fid;
+
+ if (!fid) {
+ throw new Error(`夸克目录创建成功但未返回 fid:${folderName}`);
+ }
+
+ return String(fid);
+}
+
+export async function ensureQuarkDrivePath(
+ cookie: string,
+ inputPath: string
+): Promise<{ fid: string; path: string }> {
+ const normalized = normalizePath(inputPath);
+ if (normalized === '/') {
+ return { fid: '0', path: normalized };
+ }
+
+ const segments = normalized.split('/').filter(Boolean);
+ let currentFid = '0';
+ let currentPath = '';
+
+ for (const segment of segments) {
+ const items = await fetchDriveFolderItems(cookie, currentFid);
+ const existed = items.find(
+ (item: any) =>
+ Boolean(item.dir || item.is_dir) &&
+ String(item.file_name || item.name || '') === segment
+ );
+
+ currentPath = joinPath(currentPath, segment);
+
+ if (existed) {
+ currentFid = String(existed.fid || existed.file_id);
+ continue;
+ }
+
+ currentFid = await createDriveFolder(cookie, currentFid, segment);
+ }
+
+ return {
+ fid: currentFid,
+ path: currentPath || '/',
+ };
+}
+
+async function collectShareItemsRecursive(
+ cookie: string,
+ pwdId: string,
+ stoken: string,
+ pdirFid = '0'
+): Promise {
+ const items = await fetchShareFolderItems(cookie, pwdId, stoken, pdirFid);
+ const result: QuarkShareItem[] = [];
+
+ for (const item of items) {
+ if (item.dir) {
+ const children = await collectShareItemsRecursive(
+ cookie,
+ pwdId,
+ stoken,
+ item.fid
+ );
+ result.push(...children);
+ } else {
+ result.push(item);
+ }
+ }
+
+ return result;
+}
+
+function isVideoFile(fileName: string) {
+ const lower = fileName.toLowerCase();
+ return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));
+}
+
+async function submitSaveTask(
+ cookie: string,
+ share: QuarkShareLinkInfo,
+ stoken: string,
+ toPdirFid: string,
+ items: QuarkShareItem[]
+) {
+ if (items.length === 0) {
+ throw new Error('没有可保存的文件');
+ }
+
+ const response = await fetch(
+ buildApiUrl(QUARK_SHARE_API_BASE, '/share/sharepage/save'),
+ {
+ method: 'POST',
+ headers: getHeaders(cookie),
+ body: JSON.stringify({
+ pwd_id: share.pwdId,
+ stoken,
+ pdir_fid: '0',
+ to_pdir_fid: toPdirFid,
+ scene: 'link',
+ filelist: items.map((item) => item.fid),
+ fid_list: items.map((item) => item.fid),
+ fid_token_list: items.map((item) => item.shareFidToken || ''),
+ share_fid_token_list: items.map((item) => item.shareFidToken || ''),
+ }),
+ }
+ );
+
+ const data = await parseJson(response);
+ ensureOk(data, '提交夸克转存任务失败');
+ return data?.data?.task_id ? String(data.data.task_id) : undefined;
+}
+
+async function pollTask(cookie: string, taskId: string) {
+ for (let i = 0; i < 25; i += 1) {
+ const query = new URLSearchParams({
+ task_id: taskId,
+ retry_index: String(i),
+ });
+
+ const response = await fetch(buildApiUrl(QUARK_SHARE_API_BASE, '/task', query.toString()), {
+ method: 'GET',
+ headers: getHeaders(cookie),
+ });
+
+ const data = await parseJson(response);
+ ensureOk(data, '查询夸克任务状态失败');
+
+ const task = data?.data || {};
+ if (
+ task?.status === 2 ||
+ task?.status === 'finished' ||
+ task?.status === 'success' ||
+ task?.finished_at
+ ) {
+ return;
+ }
+
+ if (
+ task?.status === -1 ||
+ task?.status === 'failed' ||
+ task?.err_code
+ ) {
+ throw new Error(task?.message || task?.err_msg || '夸克任务执行失败');
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 1200));
+ }
+
+ throw new Error('夸克任务处理超时');
+}
+
+export async function transferQuarkShare(
+ cookie: string,
+ input: {
+ shareUrl: string;
+ passcode?: string;
+ savePath: string;
+ }
+): Promise {
+ const safeCookie = assertQuarkCookieHeaderSafe(cookie);
+ const share = parseQuarkShareUrl(input.shareUrl, input.passcode);
+ const { stoken } = await fetchShareToken(safeCookie, share);
+ const topLevelItems = await fetchShareFolderItems(safeCookie, share.pwdId, stoken, '0');
+ const target = await ensureQuarkDrivePath(safeCookie, input.savePath);
+ const taskId = await submitSaveTask(safeCookie, share, stoken, target.fid, topLevelItems);
+
+ if (taskId) {
+ await pollTask(safeCookie, taskId);
+ }
+
+ return {
+ taskId,
+ fileCount: topLevelItems.length,
+ targetPath: target.path,
+ };
+}
+
+export async function createQuarkInstantPlayFolder(
+ cookie: string,
+ input: {
+ shareUrl: string;
+ passcode?: string;
+ playTempSavePath: string;
+ title?: string;
+ }
+): Promise {
+ const safeCookie = assertQuarkCookieHeaderSafe(cookie);
+ const share = parseQuarkShareUrl(input.shareUrl, input.passcode);
+ const { stoken, shareTitle } = await fetchShareToken(safeCookie, share);
+ const allItems = await collectShareItemsRecursive(safeCookie, share.pwdId, stoken, '0');
+ const videoItems = allItems.filter((item) => !item.dir && isVideoFile(item.fileName));
+
+ if (videoItems.length === 0) {
+ throw new Error('分享中没有可播放的视频文件');
+ }
+
+ const tempRoot = await ensureQuarkDrivePath(safeCookie, input.playTempSavePath);
+ const folderName = `${sanitizeFolderName(input.title || shareTitle || 'quark-temp')}_${Date.now()}`;
+ const folderFid = await createDriveFolder(safeCookie, tempRoot.fid, folderName);
+ const taskId = await submitSaveTask(safeCookie, share, stoken, folderFid, videoItems);
+
+ if (taskId) {
+ await pollTask(safeCookie, taskId);
+ }
+
+ return {
+ taskId,
+ fileCount: videoItems.length,
+ targetPath: joinPath(tempRoot.path, folderName),
+ folderName,
+ };
+}
From 4b62d7d7afb1b6f1dc597e12a9baa03762b43c28 Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Tue, 7 Apr 2026 20:06:58 +0800
Subject: [PATCH 11/18] =?UTF-8?q?=E9=87=8D=E5=A4=8D=E8=BD=AC=E5=AD=98?=
=?UTF-8?q?=E6=A0=A1=E9=AA=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/api/openlist/play/route.ts | 13 +++-
src/app/api/quark/instant-play/route.ts | 20 ++++++
src/app/api/source-detail/route.ts | 25 ++++++--
src/lib/quark.client.ts | 82 ++++++++++++++++++++++---
4 files changed, 126 insertions(+), 14 deletions(-)
diff --git a/src/app/api/openlist/play/route.ts b/src/app/api/openlist/play/route.ts
index 9952c16..a7b756f 100644
--- a/src/app/api/openlist/play/route.ts
+++ b/src/app/api/openlist/play/route.ts
@@ -168,11 +168,18 @@ export async function GET(request: NextRequest) {
throw new Error('未找到已完成的播放链接');
}
- // 如果指定了 format=json,返回 JSON 格式
+ // 如果指定了 format=json,尝试解析到最终直链后再返回 JSON
if (format === 'json') {
+ const resolvedQualities = await Promise.all(
+ qualities.map(async (quality: any) => ({
+ ...quality,
+ url: await getFinalUrl(quality.url),
+ }))
+ );
+
return NextResponse.json({
- url: qualities[0].url,
- qualities
+ url: resolvedQualities[0].url,
+ qualities: resolvedQualities,
});
}
diff --git a/src/app/api/quark/instant-play/route.ts b/src/app/api/quark/instant-play/route.ts
index 5a281bc..75c2407 100644
--- a/src/app/api/quark/instant-play/route.ts
+++ b/src/app/api/quark/instant-play/route.ts
@@ -50,6 +50,26 @@ export async function POST(request: NextRequest) {
result.folderName
);
+ if (
+ config.OpenListConfig?.Enabled &&
+ config.OpenListConfig.URL &&
+ config.OpenListConfig.Username &&
+ config.OpenListConfig.Password
+ ) {
+ try {
+ const { OpenListClient } = await import('@/lib/openlist.client');
+ const openListClient = new OpenListClient(
+ config.OpenListConfig.URL,
+ config.OpenListConfig.Username,
+ config.OpenListConfig.Password
+ );
+ await openListClient.refreshDirectory(quarkConfig.OpenListTempPath || '/');
+ await openListClient.refreshDirectory(openlistFolderPath);
+ } catch (refreshError) {
+ console.warn('[quark instant-play] 刷新 OpenList 临时目录失败:', refreshError);
+ }
+ }
+
return NextResponse.json({
success: true,
source: 'quark-temp',
diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts
index ebed2ec..e0688c5 100644
--- a/src/app/api/source-detail/route.ts
+++ b/src/app/api/source-detail/route.ts
@@ -307,6 +307,26 @@ export async function GET(request: NextRequest) {
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob'];
+ const listTempDirectory = async (currentPath: string, page: number, pageSize: number) => {
+ const load = async (refresh = false) => client.listDirectory(currentPath, page, pageSize, refresh);
+
+ let response = await load(page === 1);
+ if (response.code === 200) {
+ return response;
+ }
+
+ const parentPath = currentPath.substring(0, currentPath.lastIndexOf('/')) || '/';
+ await client.refreshDirectory(parentPath);
+ response = await load(true);
+
+ if (response.code !== 200) {
+ const message = response.message || '目录不存在或 OpenList 路径未映射';
+ throw new Error(`读取临时目录失败: ${message}(路径: ${currentPath})`);
+ }
+
+ return response;
+ };
+
const collectFiles = async (currentPath: string): Promise> => {
const allFiles: Array<{ path: string; name: string }> = [];
let currentPage = 1;
@@ -314,10 +334,7 @@ export async function GET(request: NextRequest) {
let hasMore = true;
while (hasMore) {
- const response = await client.listDirectory(currentPath, currentPage, pageSize);
- if (response.code !== 200) {
- throw new Error('读取临时目录失败');
- }
+ const response = await listTempDirectory(currentPath, currentPage, pageSize);
for (const item of response.data.content) {
const itemPath = `${currentPath}${currentPath.endsWith('/') ? '' : '/'}${item.name}`;
diff --git a/src/lib/quark.client.ts b/src/lib/quark.client.ts
index 4e2dc0c..6adef65 100644
--- a/src/lib/quark.client.ts
+++ b/src/lib/quark.client.ts
@@ -22,6 +22,8 @@ export interface QuarkTransferTaskResult {
fileCount: number;
targetPath: string;
folderName?: string;
+ skipped?: boolean;
+ reused?: boolean;
}
const VIDEO_EXTENSIONS = [
@@ -208,12 +210,14 @@ async function fetchShareFolderItems(
async function fetchDriveFolderItems(
cookie: string,
- pdirFid = '0'
+ pdirFid = '0',
+ page = 1,
+ size = 200
): Promise {
const query = new URLSearchParams({
pdir_fid: pdirFid,
- _page: '1',
- _size: '200',
+ _page: String(page),
+ _size: String(size),
_sort: 'file_type:asc,file_name:asc',
});
@@ -230,6 +234,45 @@ async function fetchDriveFolderItems(
return data?.data?.list || [];
}
+async function fetchAllDriveFolderItems(
+ cookie: string,
+ pdirFid = '0'
+): Promise {
+ const allItems: any[] = [];
+ const pageSize = 200;
+
+ for (let page = 1; page < 100; page += 1) {
+ const items = await fetchDriveFolderItems(cookie, pdirFid, page, pageSize);
+ allItems.push(...items);
+
+ if (items.length < pageSize) {
+ break;
+ }
+ }
+
+ return allItems;
+}
+
+function getDriveItemName(item: any): string {
+ return String(item?.file_name || item?.name || '');
+}
+
+function buildInstantPlayFolderName(pwdId: string, title?: string) {
+ const baseName = sanitizeFolderName(title || 'quark-temp') || 'quark-temp';
+ return `${baseName}_${pwdId}`.slice(0, 120);
+}
+
+async function findDirectoryByName(
+ cookie: string,
+ parentFid: string,
+ folderName: string
+): Promise {
+ const items = await fetchAllDriveFolderItems(cookie, parentFid);
+ return items.find(
+ (item: any) => Boolean(item.dir || item.is_dir) && getDriveItemName(item) === folderName
+ ) || null;
+}
+
export async function validateQuarkCookieReadable(cookie: string): Promise {
const safeCookie = assertQuarkCookieHeaderSafe(cookie);
await fetchDriveFolderItems(safeCookie, '0');
@@ -421,7 +464,19 @@ export async function transferQuarkShare(
const { stoken } = await fetchShareToken(safeCookie, share);
const topLevelItems = await fetchShareFolderItems(safeCookie, share.pwdId, stoken, '0');
const target = await ensureQuarkDrivePath(safeCookie, input.savePath);
- const taskId = await submitSaveTask(safeCookie, share, stoken, target.fid, topLevelItems);
+ const existedItems = await fetchAllDriveFolderItems(safeCookie, target.fid);
+ const existedNames = new Set(existedItems.map((item: any) => getDriveItemName(item)));
+ const pendingItems = topLevelItems.filter((item) => !existedNames.has(item.fileName));
+
+ if (pendingItems.length === 0) {
+ return {
+ fileCount: 0,
+ targetPath: target.path,
+ skipped: true,
+ };
+ }
+
+ const taskId = await submitSaveTask(safeCookie, share, stoken, target.fid, pendingItems);
if (taskId) {
await pollTask(safeCookie, taskId);
@@ -429,7 +484,7 @@ export async function transferQuarkShare(
return {
taskId,
- fileCount: topLevelItems.length,
+ fileCount: pendingItems.length,
targetPath: target.path,
};
}
@@ -454,7 +509,18 @@ export async function createQuarkInstantPlayFolder(
}
const tempRoot = await ensureQuarkDrivePath(safeCookie, input.playTempSavePath);
- const folderName = `${sanitizeFolderName(input.title || shareTitle || 'quark-temp')}_${Date.now()}`;
+ const folderName = buildInstantPlayFolderName(share.pwdId, input.title || shareTitle);
+ const existedFolder = await findDirectoryByName(safeCookie, tempRoot.fid, folderName);
+
+ if (existedFolder) {
+ return {
+ fileCount: videoItems.length,
+ targetPath: joinPath(tempRoot.path, folderName),
+ folderName,
+ reused: true,
+ };
+ }
+
const folderFid = await createDriveFolder(safeCookie, tempRoot.fid, folderName);
const taskId = await submitSaveTask(safeCookie, share, stoken, folderFid, videoItems);
@@ -462,10 +528,12 @@ export async function createQuarkInstantPlayFolder(
await pollTask(safeCookie, taskId);
}
+ const targetPath = joinPath(tempRoot.path, folderName);
+
return {
taskId,
fileCount: videoItems.length,
- targetPath: joinPath(tempRoot.path, folderName),
+ targetPath,
folderName,
};
}
From 80ae3e8c2178771693018a00c51e08af39a90a6e Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Tue, 7 Apr 2026 21:17:18 +0800
Subject: [PATCH 12/18] =?UTF-8?q?=E8=BD=AC=E5=AD=98=E6=92=AD=E6=94=BE?=
=?UTF-8?q?=E8=A1=A5=E5=85=85=E5=85=83=E4=BF=A1=E6=81=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/play/page.tsx | 181 ++++++++++++++++++++++-------
src/components/EpisodeSelector.tsx | 2 +-
src/components/VideoCard.tsx | 4 +-
3 files changed, 145 insertions(+), 42 deletions(-)
diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx
index 11fd253..1394534 100644
--- a/src/app/play/page.tsx
+++ b/src/app/play/page.tsx
@@ -572,6 +572,13 @@ function PlayPageClient() {
// 纠错后的描述信息(用于显示,不触发 detail 更新)
const [correctedDesc, setCorrectedDesc] = useState('');
+ const [quarkTempTMDBMeta, setQuarkTempTMDBMeta] = useState<{
+ desc?: string;
+ poster?: string;
+ year?: string;
+ tmdbId?: number;
+ } | null>(null);
+ const [pendingQuarkTempTMDBData, setPendingQuarkTempTMDBData] = useState(null);
// 当前源和ID - source 直接存储完整格式(如 'emby_wumei' 或 'emby')
const [currentSource, setCurrentSource] = useState(searchParams.get('source') || '');
@@ -579,6 +586,11 @@ function PlayPageClient() {
const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名
const isDirectPlay = currentSource === 'directplay';
+ useEffect(() => {
+ setQuarkTempTMDBMeta(null);
+ setPendingQuarkTempTMDBData(null);
+ }, [currentSource, currentId]);
+
// 解析 source 参数以获取 embyKey(仅用于 API 调用)
const parseSourceForApi = (source: string): { source: string; embyKey?: string } => {
if (source.startsWith('emby_')) {
@@ -1187,14 +1199,18 @@ function PlayPageClient() {
const detCacheAge = Date.now() - detTimestamp;
const detCacheMaxAge = 24 * 60 * 60 * 1000; // 1天
- if (detCacheAge < detCacheMaxAge && data && data.backdrop) {
- console.log('使用缓存的TMDB详情数据');
- setTmdbBackdrop(processImageUrl(data.backdrop));
+ if (detCacheAge < detCacheMaxAge && data) {
+ if (data.backdrop) {
+ setTmdbBackdrop(processImageUrl(data.backdrop));
+ } else {
+ setTmdbBackdrop(null);
+ }
// 如果没有豆瓣ID,使用TMDb数据补充
if (!videoDoubanId || videoDoubanId === 0) {
populateDoubanFieldsFromTMDB(data);
}
+ populatePlayMetadataFromTMDB(data);
return;
}
} catch (e) {
@@ -1215,7 +1231,6 @@ function PlayPageClient() {
const response = await fetch(url);
if (!response.ok) {
- console.log('获取TMDB详情失败');
setTmdbBackdrop(null);
return;
}
@@ -1224,45 +1239,94 @@ function PlayPageClient() {
if (result.backdrop) {
setTmdbBackdrop(processImageUrl(result.backdrop));
-
- // 如果没有豆瓣ID,使用TMDb数据补充
- if (!videoDoubanId || videoDoubanId === 0) {
- populateDoubanFieldsFromTMDB(result);
- }
-
- // 保存title到tmdbId的映射到localStorage(1个月)
- if (result.tmdbId) {
- try {
- localStorage.setItem(
- mappingCacheKey,
- JSON.stringify({
- tmdbId: result.tmdbId,
- timestamp: Date.now(),
- })
- );
-
- // 保存TMDB详情数据到localStorage(1天)
- const detailsCacheKey = `tmdb_details_${result.tmdbId}`;
- localStorage.setItem(
- detailsCacheKey,
- JSON.stringify({
- data: result,
- timestamp: Date.now(),
- })
- );
- } catch (e) {
- console.error('保存缓存失败:', e);
- }
- }
} else {
setTmdbBackdrop(null);
}
+
+ // 如果没有豆瓣ID,使用TMDb数据补充
+ if (!videoDoubanId || videoDoubanId === 0) {
+ populateDoubanFieldsFromTMDB(result);
+ }
+ populatePlayMetadataFromTMDB(result);
+
+ // 保存title到tmdbId的映射到localStorage(1个月)
+ if (result.tmdbId) {
+ try {
+ localStorage.setItem(
+ mappingCacheKey,
+ JSON.stringify({
+ tmdbId: result.tmdbId,
+ timestamp: Date.now(),
+ })
+ );
+
+ // 保存TMDB详情数据到localStorage(1天)
+ const detailsCacheKey = `tmdb_details_${result.tmdbId}`;
+ localStorage.setItem(
+ detailsCacheKey,
+ JSON.stringify({
+ data: result,
+ timestamp: Date.now(),
+ })
+ );
+ } catch (e) {
+ console.error('保存缓存失败:', e);
+ }
+ }
} catch (error) {
console.error('获取TMDB背景图失败:', error);
setTmdbBackdrop(null);
}
};
+ const populatePlayMetadataFromTMDB = (tmdbData: any) => {
+ const currentDetail = detailRef.current;
+ if (!currentDetail || currentDetail.source !== 'quark-temp') {
+ setPendingQuarkTempTMDBData(tmdbData);
+ return;
+ }
+
+ const tmdbYear = tmdbData.releaseDate?.split('-')[0] || '';
+ const shouldReplaceDesc = !currentDetail.desc || currentDetail.desc.startsWith('临时播放目录:');
+
+ const resolvedTmdbId = typeof tmdbData.tmdbId === 'string'
+ ? Number(String(tmdbData.tmdbId).split(':')[1] || 0)
+ : tmdbData.tmdbId;
+
+ setQuarkTempTMDBMeta({
+ desc: shouldReplaceDesc ? (tmdbData.overview || currentDetail.desc) : currentDetail.desc,
+ poster: currentDetail.poster || tmdbData.poster || '',
+ year: currentDetail.year || tmdbYear,
+ tmdbId: currentDetail.tmdb_id || resolvedTmdbId,
+ });
+
+ setDetail((prev) => {
+ if (!prev || prev.source !== 'quark-temp') {
+ return prev;
+ }
+
+ return {
+ ...prev,
+ poster: prev.poster || tmdbData.poster || '',
+ year: prev.year || tmdbYear,
+ desc: shouldReplaceDesc ? (tmdbData.overview || prev.desc) : prev.desc,
+ tmdb_id: prev.tmdb_id || resolvedTmdbId,
+ };
+ });
+
+ if (tmdbData.overview && (!correctedDesc || currentDetail.desc?.startsWith('临时播放目录:'))) {
+ setCorrectedDesc(tmdbData.overview);
+ }
+
+ if (tmdbData.poster && !currentDetail.poster) {
+ setVideoCover(processImageUrl(tmdbData.poster));
+ }
+
+ if (tmdbYear && !currentDetail.year) {
+ setVideoYear(tmdbYear);
+ }
+ };
+
// 辅助函数:使用TMDb数据填充豆瓣字段
const populateDoubanFieldsFromTMDB = (tmdbData: any) => {
// 设置评分
@@ -1296,6 +1360,45 @@ function PlayPageClient() {
fetchTMDBBackdrop();
}, [videoTitle, videoDoubanId, isDirectPlay]);
+ useEffect(() => {
+ if (
+ pendingQuarkTempTMDBData &&
+ detail?.source === 'quark-temp'
+ ) {
+ const pending = pendingQuarkTempTMDBData;
+ setPendingQuarkTempTMDBData(null);
+ const tmdbYear = pending.releaseDate?.split('-')[0] || '';
+ const shouldReplaceDesc = !detail.desc || detail.desc.startsWith('临时播放目录:');
+ const resolvedTmdbId = typeof pending.tmdbId === 'string'
+ ? Number(String(pending.tmdbId).split(':')[1] || 0)
+ : pending.tmdbId;
+
+ setQuarkTempTMDBMeta({
+ desc: shouldReplaceDesc ? (pending.overview || detail.desc) : detail.desc,
+ poster: detail.poster || pending.poster || '',
+ year: detail.year || tmdbYear,
+ tmdbId: detail.tmdb_id || resolvedTmdbId,
+ });
+
+ setDetail((prev) => prev && prev.source === 'quark-temp' ? {
+ ...prev,
+ poster: prev.poster || pending.poster || '',
+ year: prev.year || tmdbYear,
+ desc: shouldReplaceDesc ? (pending.overview || prev.desc) : prev.desc,
+ tmdb_id: prev.tmdb_id || resolvedTmdbId,
+ } : prev);
+
+ if (pending.poster && !detail.poster) {
+ setVideoCover(processImageUrl(pending.poster));
+ }
+ if (tmdbYear && !detail.year) {
+ setVideoYear(tmdbYear);
+ }
+ if (pending.overview) {
+ setCorrectedDesc(pending.overview);
+ }
+ }
+ }, [pendingQuarkTempTMDBData, detail]);
// 视频播放地址
const [videoUrl, setVideoUrl] = useState('');
@@ -8872,12 +8975,12 @@ function PlayPageClient() {
)}
{/* 优先使用 doubanYear,如果没有则使用 detail.year 或 videoYear */}
- {(doubanYear || detail?.year || videoYear) && (
- {doubanYear || detail?.year || videoYear}
+ {(doubanYear || quarkTempTMDBMeta?.year || detail?.year || videoYear) && (
+ {doubanYear || quarkTempTMDBMeta?.year || detail?.year || videoYear}
)}
{detail?.source_name && (
@@ -8897,7 +9000,7 @@ function PlayPageClient() {
{detail?.type_name && {detail.type_name}}
{/* 剧情简介 */}
- {(doubanCardSubtitle || correctedDesc || detail?.desc) && (
+ {(doubanCardSubtitle || quarkTempTMDBMeta?.desc || correctedDesc || detail?.desc) && (
)}
- {correctedDesc || detail?.desc}
+ {quarkTempTMDBMeta?.desc || correctedDesc || detail?.desc}
)}
diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx
index 799bebc..933ba63 100644
--- a/src/components/EpisodeSelector.tsx
+++ b/src/components/EpisodeSelector.tsx
@@ -940,7 +940,7 @@ const EpisodeSelector: React.FC = ({
{/* 源名称和集数信息 - 垂直居中 */}
diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx
index 95ddcf7..87a3342 100644
--- a/src/components/VideoCard.tsx
+++ b/src/components/VideoCard.tsx
@@ -1043,7 +1043,7 @@ const VideoCard = forwardRef(function VideoCard
>
(function VideoCard
{config.showSourceName && source_name && !cmsData && (
Date: Wed, 8 Apr 2026 09:21:43 +0800
Subject: [PATCH 13/18] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E9=9F=B3=E4=B9=90?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/admin/page.tsx | 19 +------------------
src/app/page.tsx | 35 ++++++++++++++---------------------
2 files changed, 15 insertions(+), 39 deletions(-)
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 1f5a406..9daf1a6 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -37,7 +37,6 @@ import {
FolderOpen,
Globe,
Mail,
- Music,
Palette,
Settings,
Tv,
@@ -7773,7 +7772,7 @@ const ThemeConfigComponent = ({
};
// 音乐配置组件
-const MusicConfigComponent = ({
+export const MusicConfigComponent = ({
config,
refreshConfig,
}: {
@@ -12954,7 +12953,6 @@ function AdminPageClient() {
liveSource: false,
webLive: false,
siteConfig: false,
- musicConfig: false,
registrationConfig: false,
categoryConfig: false,
configFile: false,
@@ -13293,21 +13291,6 @@ function AdminPageClient() {
/>
- {/* 音乐配置标签 */}
-
- }
- isExpanded={expandedTabs.musicConfig}
- onToggle={() => toggleTab('musicConfig')}
- >
-
-
-
{/* 视频源配置标签 */}
{
- if (typeof window !== 'undefined') {
- const enabled = (window as any).RUNTIME_CONFIG?.TUNEHUB_ENABLED === true;
- setMusicEnabled(enabled);
- }
- }, []);
-
// 检查公告弹窗状态
useEffect(() => {
if (typeof window !== 'undefined' && announcement) {
@@ -613,17 +604,19 @@ function HomeClient() {
- {/* 音乐视听入口 */}
- {musicEnabled && (
-
-
-
-
-
- )}
+ {/* 音乐视听入口(暂时隐藏,后续可能恢复) */}
+ {/**
+ * {musicEnabled && (
+ *
+ *
+ *
+ *
+ *
+ * )}
+ */}
{/* 源站寻片入口 */}
{sourceSearchEnabled && (
From 204eec782fb190c61aeada440452d01d5d9a980c Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Wed, 8 Apr 2026 18:03:18 +0800
Subject: [PATCH 14/18] =?UTF-8?q?=E8=B0=83=E6=95=B4api=E8=B7=AF=E5=BE=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/admin/page.tsx | 345 +-----------------
src/app/api/admin/netdisk/route.ts | 2 +-
.../{ => netdisk}/quark/instant-play/route.ts | 2 +-
.../api/{ => netdisk}/quark/transfer/route.ts | 2 +-
src/components/PansouSearch.tsx | 4 +-
src/lib/{ => netdisk}/quark.client.ts | 0
6 files changed, 7 insertions(+), 348 deletions(-)
rename src/app/api/{ => netdisk}/quark/instant-play/route.ts (97%)
rename src/app/api/{ => netdisk}/quark/transfer/route.ts (94%)
rename src/lib/{ => netdisk}/quark.client.ts (100%)
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 9daf1a6..263c6ff 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -7771,349 +7771,8 @@ const ThemeConfigComponent = ({
);
};
-// 音乐配置组件
-export const MusicConfigComponent = ({
- config,
- refreshConfig,
-}: {
- config: AdminConfig | null;
- refreshConfig: () => Promise;
-}) => {
- const { alertModal, showAlert, hideAlert } = useAlertModal();
- const { isLoading, withLoading } = useLoadingState();
-
- const [musicSettings, setMusicSettings] = useState({
- TuneHubEnabled: false,
- TuneHubBaseUrl: 'https://tunehub.sayqz.com/api',
- TuneHubApiKey: '',
- OpenListCacheEnabled: false,
- OpenListCacheURL: '',
- OpenListCacheUsername: '',
- OpenListCachePassword: '',
- OpenListCachePath: '/music-cache',
- OpenListCacheProxyEnabled: true,
- });
-
- // 从配置加载音乐设置
- useEffect(() => {
- if (config?.MusicConfig) {
- setMusicSettings({
- TuneHubEnabled: config.MusicConfig.TuneHubEnabled ?? false,
- TuneHubBaseUrl: config.MusicConfig.TuneHubBaseUrl ?? 'https://tunehub.sayqz.com/api',
- TuneHubApiKey: config.MusicConfig.TuneHubApiKey ?? '',
- OpenListCacheEnabled: config.MusicConfig.OpenListCacheEnabled ?? false,
- OpenListCacheURL: config.MusicConfig.OpenListCacheURL ?? '',
- OpenListCacheUsername: config.MusicConfig.OpenListCacheUsername ?? '',
- OpenListCachePassword: config.MusicConfig.OpenListCachePassword ?? '',
- OpenListCachePath: config.MusicConfig.OpenListCachePath ?? '/music-cache',
- OpenListCacheProxyEnabled: config.MusicConfig.OpenListCacheProxyEnabled ?? true,
- });
- }
- }, [config]);
-
- const handleSave = async () => {
- await withLoading('saveMusicConfig', async () => {
- try {
- const resp = await fetch('/api/admin/music', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ ...musicSettings }),
- });
-
- if (!resp.ok) {
- const data = await resp.json().catch(() => ({}));
- throw new Error(data.error || '保存失败');
- }
-
- showAlert({ type: 'success', title: '保存成功', message: '音乐配置已更新', timer: 2000 });
- await refreshConfig();
- } catch (error: any) {
- showAlert({ type: 'error', title: '保存失败', message: error.message || '未知错误', showConfirm: true });
- }
- });
- };
-
- return (
-
- {/* TuneHub 音乐配置 */}
-
-
- TuneHub 音乐配置
-
-
- {/* 开启音乐功能 */}
-
-
-
-
- setMusicSettings((prev) => ({
- ...prev,
- TuneHubEnabled: !prev.TuneHubEnabled,
- }))
- }
- className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
- musicSettings.TuneHubEnabled
- ? buttonStyles.toggleOn
- : buttonStyles.toggleOff
- }`}
- >
-
-
-
-
- 开启后将在首页显示音乐视听入口,支持网易云、QQ音乐、酷我音乐
-
-
-
- {/* TuneHub Base URL */}
-
-
-
- setMusicSettings((prev) => ({
- ...prev,
- TuneHubBaseUrl: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- TuneHub API 的基础地址,默认为 https://tunehub.sayqz.com/api。也可以通过环境变量 TUNEHUB_BASE_URL 配置
-
-
-
- {/* TuneHub API Key */}
-
-
-
- setMusicSettings((prev) => ({
- ...prev,
- TuneHubApiKey: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- 用于解析歌曲播放链接的 API Key(消耗积分)。搜索、榜单、歌单等功能不需要 Key。也可以通过环境变量 TUNEHUB_API_KEY 配置
-
-
-
-
- {/* OpenList 缓存配置 */}
-
-
- OpenList 缓存配置
-
-
- {/* 开启 OpenList 缓存 */}
-
-
-
-
- setMusicSettings((prev) => ({
- ...prev,
- OpenListCacheEnabled: !prev.OpenListCacheEnabled,
- }))
- }
- className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
- musicSettings.OpenListCacheEnabled
- ? buttonStyles.toggleOn
- : buttonStyles.toggleOff
- }`}
- >
-
-
-
-
- 开启后将音乐解析结果(播放链接、歌词、元信息)和音频文件缓存到 OpenList,减少 API 调用次数并支持离线播放
-
-
-
- {/* OpenList URL */}
-
-
-
- setMusicSettings((prev) => ({
- ...prev,
- OpenListCacheURL: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- OpenList 服务器的完整地址(例如:https://your-openlist-server.com)
-
-
-
- {/* OpenList 用户名 */}
-
-
-
- setMusicSettings((prev) => ({
- ...prev,
- OpenListCacheUsername: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
-
- {/* OpenList 密码 */}
-
-
-
- setMusicSettings((prev) => ({
- ...prev,
- OpenListCachePassword: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- 用于登录 OpenList 并获取访问权限
-
-
-
- {/* OpenList 缓存目录 */}
-
-
-
- setMusicSettings((prev) => ({
- ...prev,
- OpenListCachePath: e.target.value,
- }))
- }
- className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
- />
-
- 音乐缓存在 OpenList 中的存储目录(例如:/music-cache)
-
-
-
- {/* 缓存代理返回开关 */}
-
-
-
-
- setMusicSettings((prev) => ({
- ...prev,
- OpenListCacheProxyEnabled: !prev.OpenListCacheProxyEnabled,
- }))
- }
- className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
- musicSettings.OpenListCacheProxyEnabled
- ? buttonStyles.toggleOn
- : buttonStyles.toggleOff
- }`}
- >
-
-
-
-
- 开启后,如果 OpenList 有缓存,将通过代理方式返回给前端,并设置永久缓存头,提升加载速度
-
-
-
-
- {/* 操作按钮 */}
-
-
- {isLoading('saveMusicConfig') ? '保存中…' : '保存'}
-
-
-
- {/* 弹窗 */}
-
-
- );
-};
+// 音乐配置组件(已停用)
+// const MusicConfigComponent = (...) => { ... }
// 新增站点配置组件
const SiteConfigComponent = ({
diff --git a/src/app/api/admin/netdisk/route.ts b/src/app/api/admin/netdisk/route.ts
index 70795df..0536e66 100644
--- a/src/app/api/admin/netdisk/route.ts
+++ b/src/app/api/admin/netdisk/route.ts
@@ -9,7 +9,7 @@ import {
assertQuarkCookieHeaderSafe,
normalizeQuarkCookie,
validateQuarkCookieReadable,
-} from '@/lib/quark.client';
+} from '@/lib/netdisk/quark.client';
export const runtime = 'nodejs';
diff --git a/src/app/api/quark/instant-play/route.ts b/src/app/api/netdisk/quark/instant-play/route.ts
similarity index 97%
rename from src/app/api/quark/instant-play/route.ts
rename to src/app/api/netdisk/quark/instant-play/route.ts
index 75c2407..1e47de2 100644
--- a/src/app/api/quark/instant-play/route.ts
+++ b/src/app/api/netdisk/quark/instant-play/route.ts
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
-import { createQuarkInstantPlayFolder } from '@/lib/quark.client';
+import { createQuarkInstantPlayFolder } from '@/lib/netdisk/quark.client';
import { base58Encode } from '@/lib/utils';
export const runtime = 'nodejs';
diff --git a/src/app/api/quark/transfer/route.ts b/src/app/api/netdisk/quark/transfer/route.ts
similarity index 94%
rename from src/app/api/quark/transfer/route.ts
rename to src/app/api/netdisk/quark/transfer/route.ts
index 9009b83..fe0201b 100644
--- a/src/app/api/quark/transfer/route.ts
+++ b/src/app/api/netdisk/quark/transfer/route.ts
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
-import { transferQuarkShare } from '@/lib/quark.client';
+import { transferQuarkShare } from '@/lib/netdisk/quark.client';
export const runtime = 'nodejs';
diff --git a/src/components/PansouSearch.tsx b/src/components/PansouSearch.tsx
index 72e830b..b9136c5 100644
--- a/src/components/PansouSearch.tsx
+++ b/src/components/PansouSearch.tsx
@@ -125,7 +125,7 @@ export default function PansouSearch({
const handleQuarkTransfer = async (link: PansouLink) => {
try {
setTransferingUrl(link.url);
- const response = await fetch('/api/quark/transfer', {
+ const response = await fetch('/api/netdisk/quark/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -152,7 +152,7 @@ export default function PansouSearch({
const handleQuarkInstantPlay = async (link: PansouLink) => {
try {
setPlayingUrl(link.url);
- const response = await fetch('/api/quark/instant-play', {
+ const response = await fetch('/api/netdisk/quark/instant-play', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
diff --git a/src/lib/quark.client.ts b/src/lib/netdisk/quark.client.ts
similarity index 100%
rename from src/lib/quark.client.ts
rename to src/lib/netdisk/quark.client.ts
From 769dbf8310466725804db11286f8d9652f9e4d95 Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Thu, 9 Apr 2026 21:30:07 +0800
Subject: [PATCH 15/18] =?UTF-8?q?=E8=B1=86=E7=93=A3=E6=95=B0=E6=8D=AE?=
=?UTF-8?q?=E6=BA=90=E5=A2=9E=E5=8A=A0=E5=A4=87=E7=94=A8=E6=BA=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/play/page.tsx | 5 +-
src/app/search/page.tsx | 6 +-
src/components/BannerCarousel.tsx | 23 +-
src/components/DetailPanel.tsx | 97 ++++----
src/components/EpisodeSelector.tsx | 8 +-
src/components/ImageViewer.tsx | 12 +-
src/components/ProxyImage.tsx | 49 +++++
src/components/UserMenu.tsx | 279 +++++++++++++++++++++++
src/components/VideoCard.tsx | 10 +-
src/lib/douban.client.ts | 342 ++++++++++++++++++++---------
src/lib/utils.ts | 149 ++++++++++---
11 files changed, 778 insertions(+), 202 deletions(-)
create mode 100644 src/components/ProxyImage.tsx
diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx
index 1394534..6ceff05 100644
--- a/src/app/play/page.tsx
+++ b/src/app/play/page.tsx
@@ -64,6 +64,7 @@ import Drawer from '@/components/Drawer';
import EpisodeSelector from '@/components/EpisodeSelector';
import PageLayout from '@/components/PageLayout';
import PansouSearch from '@/components/PansouSearch';
+import ProxyImage from '@/components/ProxyImage';
import { useSite } from '@/components/SiteProvider';
import SmartRecommendations from '@/components/SmartRecommendations';
import Toast, { ToastProps } from '@/components/Toast';
@@ -9023,8 +9024,8 @@ function PlayPageClient() {
{videoCover ? (
<>
-
})
diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx
index 39bc6ca..2118fbb 100644
--- a/src/app/search/page.tsx
+++ b/src/app/search/page.tsx
@@ -38,6 +38,7 @@ import CapsuleSwitch from '@/components/CapsuleSwitch';
import ImageViewer from '@/components/ImageViewer';
import PageLayout from '@/components/PageLayout';
import PansouSearch from '@/components/PansouSearch';
+import ProxyImage from '@/components/ProxyImage';
import SearchResultFilter, {
SearchFilterCategory,
} from '@/components/SearchResultFilter';
@@ -829,9 +830,8 @@ function SearchPageClient() {
>
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
})
{
if (!path) return '';
- // 如果是完整URL(TX数据源或豆瓣),使用processImageUrl统一处理
+ // 如果是完整URL(TX数据源或豆瓣),直接返回原始地址
if (path.startsWith('http://') || path.startsWith('https://')) {
- return processImageUrl(path);
+ return path;
}
- // 否则使用TMDB的URL拼接,并通过processImageUrl处理
- return processImageUrl(getTMDBImageUrl(path, 'original'));
+ // 否则使用TMDB的URL拼接原始地址
+ return getTMDBImageUrl(path, 'original');
};
// 获取视频URL(处理豆瓣视频代理)
@@ -454,13 +455,11 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
) : (
/* 显示图片 */
-
)}
{/* 渐变遮罩 */}
diff --git a/src/components/DetailPanel.tsx b/src/components/DetailPanel.tsx
index 9c8cbe8..545dcc4 100644
--- a/src/components/DetailPanel.tsx
+++ b/src/components/DetailPanel.tsx
@@ -9,6 +9,7 @@ import { getTMDBImageUrl } from '@/lib/tmdb.client';
import { processImageUrl } from '@/lib/utils';
import ImageViewer from '@/components/ImageViewer';
+import ProxyImage from '@/components/ProxyImage';
interface DetailPanelProps {
isOpen: boolean;
@@ -373,7 +374,7 @@ const DetailPanel: React.FC
= ({
title: title,
intro: cmsData.desc,
episodesCount: cmsData.episodes?.length,
- poster: poster ? processImageUrl(poster) : poster,
+ poster: poster,
};
setDetailData(data);
setOriginalDetailData(data);
@@ -393,7 +394,7 @@ const DetailPanel: React.FC = ({
title: data.title || title,
intro: data.desc || '',
episodesCount: data.episodes?.length || cmsData.episodes?.length,
- poster: data.poster ? processImageUrl(data.poster) : poster,
+ poster: data.poster || poster,
year: data.year,
};
setDetailData(detailData);
@@ -423,7 +424,7 @@ const DetailPanel: React.FC = ({
title: data.name_cn || data.name,
originalTitle: data.name,
year: data.date ? data.date.substring(0, 4) : undefined,
- poster: data.images?.large ? processImageUrl(data.images.large) : poster,
+ poster: data.images?.large || poster,
rating: data.rating
? {
value: data.rating.score,
@@ -454,7 +455,7 @@ const DetailPanel: React.FC = ({
title: data.title,
originalTitle: data.original_title,
year: data.year,
- poster: (data.pic?.large || data.pic?.normal) ? processImageUrl(data.pic?.large || data.pic?.normal) : poster,
+ poster: data.pic?.large || data.pic?.normal || poster,
rating: data.rating
? {
value: data.rating.value,
@@ -873,7 +874,7 @@ const DetailPanel: React.FC = ({
...prev,
title: episodesData.name || season?.name || prev.title,
intro: episodesData.overview || season?.overview || prev.overview,
- poster: season?.poster_path ? processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')) : prev.poster,
+ poster: season?.poster_path ? getTMDBImageUrl(season.poster_path, 'w500') : prev.poster,
releaseDate: episodesData.air_date || season?.air_date || prev.releaseDate,
year: episodesData.air_date?.substring(0, 4) || season?.air_date?.substring(0, 4) || prev.year,
episodesCount: episodesData.episodes?.length || season?.episode_count || prev.episodesCount,
@@ -1090,11 +1091,13 @@ const DetailPanel: React.FC = ({
style={{ height: virtualGalleryLayout.totalHeight, width: virtualGalleryLayout.usedWidth || '100%' }}
>
{virtualGalleryLayout.visibleItems.map((image) => {
- const imageUrl = processImageUrl(
- getTMDBImageUrl(image.file_path, image.imageType === 'poster' ? 'w500' : 'original')
+ const imageUrl = getTMDBImageUrl(
+ image.file_path,
+ image.imageType === 'poster' ? 'w500' : 'original'
);
- const thumbUrl = processImageUrl(
- getTMDBImageUrl(image.file_path, image.imageType === 'poster' ? 'w342' : 'w780')
+ const thumbUrl = getTMDBImageUrl(
+ image.file_path,
+ image.imageType === 'poster' ? 'w342' : 'w780'
);
return (
@@ -1112,12 +1115,10 @@ const DetailPanel: React.FC = ({
className="relative w-full h-full overflow-hidden rounded-md bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(imageUrl)}
>
-
@@ -1231,7 +1232,12 @@ const DetailPanel: React.FC
= ({
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(detailData.poster!)}
>
-
+
{galleryEntryButton}
@@ -1356,13 +1362,12 @@ const DetailPanel: React.FC
= ({
{actor.profile_path ? (
handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185')))}
+ onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
>
-
@@ -1474,14 +1479,13 @@ const DetailPanel: React.FC = ({
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
onClick={(e) => {
e.stopPropagation();
- handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')));
+ handleImageClick(getTMDBImageUrl(season.poster_path, 'w500'));
}}
>
-
@@ -1536,13 +1540,12 @@ const DetailPanel: React.FC = ({
{episode.still_path && (
handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))}
+ onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))}
>
-
@@ -1742,7 +1745,12 @@ const DetailPanel: React.FC = ({
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(detailData.poster!)}
>
-
+
{galleryEntryButton}
@@ -1867,13 +1875,12 @@ const DetailPanel: React.FC
= ({
{actor.profile_path ? (
handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185')))}
+ onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
>
-
@@ -1985,14 +1992,13 @@ const DetailPanel: React.FC = ({
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
onClick={(e) => {
e.stopPropagation();
- handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')));
+ handleImageClick(getTMDBImageUrl(season.poster_path, 'w500'));
}}
>
-
@@ -2047,13 +2053,12 @@ const DetailPanel: React.FC = ({
{episode.still_path && (
handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))}
+ onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))}
>
-
diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx
index 933ba63..401bb8d 100644
--- a/src/components/EpisodeSelector.tsx
+++ b/src/components/EpisodeSelector.tsx
@@ -12,10 +12,11 @@ import React, {
import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types';
import { EpisodeFilterConfig,SearchResult } from '@/lib/types';
-import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
+import { getVideoResolutionFromM3u8 } from '@/lib/utils';
import DanmakuPanel from '@/components/DanmakuPanel';
import EpisodeFilterSettings from '@/components/EpisodeFilterSettings';
+import ProxyImage from '@/components/ProxyImage';
// 定义视频信息类型
interface VideoInfo {
@@ -870,10 +871,11 @@ const EpisodeSelector: React.FC = ({
{source.source === 'directplay' ? (
) : source.poster ? (
-
{
const target = e.target as HTMLImageElement;
target.style.display = 'none';
diff --git a/src/components/ImageViewer.tsx b/src/components/ImageViewer.tsx
index 4eda739..c4c6fe2 100644
--- a/src/components/ImageViewer.tsx
+++ b/src/components/ImageViewer.tsx
@@ -1,10 +1,11 @@
'use client';
import { X } from 'lucide-react';
-import Image from 'next/image';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
+import ProxyImage from '@/components/ProxyImage';
+
interface ImageViewerProps {
isOpen: boolean;
onClose: () => void;
@@ -151,18 +152,15 @@ const ImageViewer: React.FC = ({
onClick={(e) => e.stopPropagation()}
>
-
diff --git a/src/components/ProxyImage.tsx b/src/components/ProxyImage.tsx
new file mode 100644
index 0000000..5684344
--- /dev/null
+++ b/src/components/ProxyImage.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import React from 'react';
+
+import { processImageUrl, tryApplyDoubanImageFallback } from '@/lib/utils';
+
+interface ProxyImageProps extends React.ImgHTMLAttributes {
+ originalSrc: string;
+ displaySrc?: string;
+ retryDelay?: number;
+ retryOnError?: boolean;
+}
+
+const ProxyImage: React.FC = ({
+ originalSrc,
+ displaySrc,
+ retryDelay = 2000,
+ retryOnError = true,
+ onError,
+ src: _src,
+ ...props
+}) => {
+ const handleError = (e: React.SyntheticEvent) => {
+ const img = e.currentTarget;
+
+ if (tryApplyDoubanImageFallback(img, originalSrc)) {
+ return;
+ }
+
+ if (retryOnError && !img.dataset.retried) {
+ img.dataset.retried = 'true';
+ window.setTimeout(() => {
+ img.src = displaySrc || processImageUrl(originalSrc);
+ }, retryDelay);
+ }
+
+ onError?.(e);
+ };
+
+ return (
+
+ );
+};
+
+export default ProxyImage;
diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx
index ad182bf..104694a 100644
--- a/src/components/UserMenu.tsx
+++ b/src/components/UserMenu.tsx
@@ -118,11 +118,18 @@ export const UserMenu: React.FC = () => {
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
const [enableTrailers, setEnableTrailers] = useState(false);
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
+ const [doubanDataSourceBackup, setDoubanDataSourceBackup] = useState('direct');
const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent');
+ const [doubanImageProxyTypeBackup, setDoubanImageProxyTypeBackup] = useState('server');
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
+ const [doubanProxyUrlBackup, setDoubanProxyUrlBackup] = useState('');
+ const [doubanImageProxyUrlBackup, setDoubanImageProxyUrlBackup] = useState('');
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
+ const [isDoubanBackupDropdownOpen, setIsDoubanBackupDropdownOpen] = useState(false);
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
useState(false);
+ const [isDoubanImageProxyBackupDropdownOpen, setIsDoubanImageProxyBackupDropdownOpen] =
+ useState(false);
const [bufferStrategy, setBufferStrategy] = useState('medium');
const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true);
const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] = useState(true);
@@ -440,6 +447,16 @@ export const UserMenu: React.FC = () => {
setDoubanProxyUrl(defaultDoubanProxy);
}
+ const savedDoubanDataSourceBackup = localStorage.getItem(
+ 'doubanDataSourceBackup'
+ );
+ setDoubanDataSourceBackup(savedDoubanDataSourceBackup || 'direct');
+
+ const savedDoubanProxyUrlBackup = localStorage.getItem(
+ 'doubanProxyUrlBackup'
+ );
+ setDoubanProxyUrlBackup(savedDoubanProxyUrlBackup || '');
+
const savedDoubanImageProxyType = localStorage.getItem(
'doubanImageProxyType'
);
@@ -462,6 +479,16 @@ export const UserMenu: React.FC = () => {
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
}
+ const savedDoubanImageProxyTypeBackup = localStorage.getItem(
+ 'doubanImageProxyTypeBackup'
+ );
+ setDoubanImageProxyTypeBackup(savedDoubanImageProxyTypeBackup || 'server');
+
+ const savedDoubanImageProxyUrlBackup = localStorage.getItem(
+ 'doubanImageProxyUrlBackup'
+ );
+ setDoubanImageProxyUrlBackup(savedDoubanImageProxyUrlBackup || '');
+
const savedTmdbImageBaseUrl = localStorage.getItem('tmdbImageBaseUrl');
if (savedTmdbImageBaseUrl !== null) {
setTmdbImageBaseUrl(savedTmdbImageBaseUrl);
@@ -754,6 +781,23 @@ export const UserMenu: React.FC = () => {
}
}, [isDoubanDropdownOpen]);
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (isDoubanBackupDropdownOpen) {
+ const target = event.target as Element;
+ if (!target.closest('[data-dropdown="douban-datasource-backup"]')) {
+ setIsDoubanBackupDropdownOpen(false);
+ }
+ }
+ };
+
+ if (isDoubanBackupDropdownOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ return () =>
+ document.removeEventListener('mousedown', handleClickOutside);
+ }
+ }, [isDoubanBackupDropdownOpen]);
+
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanImageProxyDropdownOpen) {
@@ -771,6 +815,23 @@ export const UserMenu: React.FC = () => {
}
}, [isDoubanImageProxyDropdownOpen]);
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (isDoubanImageProxyBackupDropdownOpen) {
+ const target = event.target as Element;
+ if (!target.closest('[data-dropdown="douban-image-proxy-backup"]')) {
+ setIsDoubanImageProxyBackupDropdownOpen(false);
+ }
+ }
+ };
+
+ if (isDoubanImageProxyBackupDropdownOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ return () =>
+ document.removeEventListener('mousedown', handleClickOutside);
+ }
+ }, [isDoubanImageProxyBackupDropdownOpen]);
+
const handleMenuClick = () => {
setIsOpen(!isOpen);
};
@@ -1049,6 +1110,13 @@ export const UserMenu: React.FC = () => {
}
};
+ const handleDoubanDataSourceBackupChange = (value: string) => {
+ setDoubanDataSourceBackup(value);
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('doubanDataSourceBackup', value);
+ }
+ };
+
const handleDoubanImageProxyTypeChange = (value: string) => {
setDoubanImageProxyType(value);
if (typeof window !== 'undefined') {
@@ -1056,6 +1124,20 @@ export const UserMenu: React.FC = () => {
}
};
+ const handleDoubanImageProxyTypeBackupChange = (value: string) => {
+ setDoubanImageProxyTypeBackup(value);
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('doubanImageProxyTypeBackup', value);
+ }
+ };
+
+ const handleDoubanProxyUrlBackupChange = (value: string) => {
+ setDoubanProxyUrlBackup(value);
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('doubanProxyUrlBackup', value);
+ }
+ };
+
const handleDoubanImageProxyUrlChange = (value: string) => {
setDoubanImageProxyUrl(value);
if (typeof window !== 'undefined') {
@@ -1063,6 +1145,13 @@ export const UserMenu: React.FC = () => {
}
};
+ const handleDoubanImageProxyUrlBackupChange = (value: string) => {
+ setDoubanImageProxyUrlBackup(value);
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('doubanImageProxyUrlBackup', value);
+ }
+ };
+
const handleTmdbImageBaseUrlChange = (value: string) => {
setTmdbImageBaseUrl(value);
if (typeof window !== 'undefined') {
@@ -1240,8 +1329,12 @@ export const UserMenu: React.FC = () => {
setEnableTrailers(false);
setDoubanProxyUrl(defaultDoubanProxy);
setDoubanDataSource(defaultDoubanProxyType);
+ setDoubanDataSourceBackup('direct');
+ setDoubanProxyUrlBackup('');
setDoubanImageProxyType(defaultDoubanImageProxyType);
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
+ setDoubanImageProxyTypeBackup('server');
+ setDoubanImageProxyUrlBackup('');
setTmdbImageBaseUrl('https://image.tmdb.org');
setBufferStrategy('medium');
setNextEpisodePreCache(true);
@@ -1261,8 +1354,12 @@ export const UserMenu: React.FC = () => {
localStorage.setItem('enableTrailers', 'false');
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
+ localStorage.setItem('doubanDataSourceBackup', 'direct');
+ localStorage.setItem('doubanProxyUrlBackup', '');
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl);
+ localStorage.setItem('doubanImageProxyTypeBackup', 'server');
+ localStorage.setItem('doubanImageProxyUrlBackup', '');
localStorage.setItem('tmdbImageBaseUrl', 'https://image.tmdb.org');
localStorage.setItem('bufferStrategy', 'medium');
localStorage.setItem('nextEpisodePreCache', 'true');
@@ -1723,6 +1820,96 @@ export const UserMenu: React.FC = () => {
value={doubanProxyUrl}
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
/>
+ {!doubanProxyUrl.trim() && (
+
+ 未填写地址时将自动按直连处理
+
+ )}
+
+ )}
+
+
+
+
+ 豆瓣数据备用渠道
+
+
+ 主渠道失败后自动切换,默认直连
+
+
+
+
+ setIsDoubanBackupDropdownOpen(!isDoubanBackupDropdownOpen)
+ }
+ className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
+ >
+ {
+ doubanDataSourceOptions.find(
+ (option) => option.value === doubanDataSourceBackup
+ )?.label
+ }
+
+
+
+
+ {isDoubanBackupDropdownOpen && (
+
+ {doubanDataSourceOptions.map((option) => (
+ {
+ handleDoubanDataSourceBackupChange(option.value);
+ setIsDoubanBackupDropdownOpen(false);
+ }}
+ className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanDataSourceBackup === option.value
+ ? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
+ : 'text-gray-900 dark:text-gray-100'
+ }`}
+ >
+ {option.label}
+ {doubanDataSourceBackup === option.value && (
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ {doubanDataSourceBackup === 'custom' && (
+
+
+
+ 豆瓣备用代理地址
+
+
+ 备用渠道为自定义代理时生效
+
+
+
+ handleDoubanProxyUrlBackupChange(e.target.value)
+ }
+ />
+ {!doubanProxyUrlBackup.trim() && (
+
+ 未填写地址时备用渠道将自动按直连处理
+
+ )}
)}
@@ -1833,6 +2020,98 @@ export const UserMenu: React.FC = () => {
handleDoubanImageProxyUrlChange(e.target.value)
}
/>
+ {!doubanImageProxyUrl.trim() && (
+
+ 未填写地址时将自动按服务器代理处理
+
+ )}
+
+ )}
+
+
+
+
+ 豆瓣图片备用渠道
+
+
+ 主图片渠道失败后自动切换,默认服务器代理
+
+
+
+
+ setIsDoubanImageProxyBackupDropdownOpen(
+ !isDoubanImageProxyBackupDropdownOpen
+ )
+ }
+ className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
+ >
+ {
+ doubanImageProxyTypeOptions.find(
+ (option) => option.value === doubanImageProxyTypeBackup
+ )?.label
+ }
+
+
+
+
+ {isDoubanImageProxyBackupDropdownOpen && (
+
+ {doubanImageProxyTypeOptions.map((option) => (
+ {
+ handleDoubanImageProxyTypeBackupChange(option.value);
+ setIsDoubanImageProxyBackupDropdownOpen(false);
+ }}
+ className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanImageProxyTypeBackup === option.value
+ ? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
+ : 'text-gray-900 dark:text-gray-100'
+ }`}
+ >
+ {option.label}
+ {doubanImageProxyTypeBackup === option.value && (
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ {doubanImageProxyTypeBackup === 'custom' && (
+
+
+
+ 豆瓣图片备用代理地址
+
+
+ 备用图片渠道为自定义代理时生效
+
+
+
+ handleDoubanImageProxyUrlBackupChange(e.target.value)
+ }
+ />
+ {!doubanImageProxyUrlBackup.trim() && (
+
+ 未填写地址时备用图片渠道将自动按服务器代理处理
+
+ )}
)}
diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx
index 87a3342..13c6f2f 100644
--- a/src/components/VideoCard.tsx
+++ b/src/components/VideoCard.tsx
@@ -21,7 +21,7 @@ import {
saveFavorite,
subscribeToDataUpdates,
} from '@/lib/db.client';
-import { processImageUrl, base58Decode } from '@/lib/utils';
+import { processImageUrl, base58Decode, tryApplyDoubanImageFallback } from '@/lib/utils';
import { useLongPress } from '@/hooks/useLongPress';
import AIChatPanel from '@/components/AIChatPanel';
@@ -750,8 +750,12 @@ const VideoCard = forwardRef(function VideoCard
setShowImageViewer(true);
}}
onError={(e) => {
+ const img = e.currentTarget as HTMLImageElement;
+ if (tryApplyDoubanImageFallback(img, actualPoster)) {
+ return;
+ }
+
// 图片加载失败时的重试机制
- const img = e.target as HTMLImageElement;
if (!img.dataset.retried) {
img.dataset.retried = 'true';
setTimeout(() => {
@@ -1598,7 +1602,7 @@ const VideoCard = forwardRef(function VideoCard
setShowImageViewer(false)}
- imageUrl={processImageUrl(actualPoster)}
+ imageUrl={actualPoster}
alt={actualTitle}
/>
)}
diff --git a/src/lib/douban.client.ts b/src/lib/douban.client.ts
index 399510d..4ff0525 100644
--- a/src/lib/douban.client.ts
+++ b/src/lib/douban.client.ts
@@ -88,6 +88,36 @@ interface DoubanDetailApiResponse {
[key: string]: any; // 允许其他字段
}
+type DoubanProxyType =
+ | 'direct'
+ | 'cors-proxy-zwei'
+ | 'cmliussss-cdn-tencent'
+ | 'cmliussss-cdn-ali'
+ | 'cors-anywhere'
+ | 'custom';
+
+function normalizeDoubanProxyConfig(
+ proxyType: DoubanProxyType,
+ proxyUrl: string
+): {
+ proxyType: DoubanProxyType;
+ proxyUrl: string;
+} {
+ const normalizedProxyUrl = proxyUrl.trim();
+
+ if (proxyType === 'custom' && !normalizedProxyUrl) {
+ return {
+ proxyType: 'direct',
+ proxyUrl: '',
+ };
+ }
+
+ return {
+ proxyType,
+ proxyUrl: normalizedProxyUrl,
+ };
+}
+
/**
* 带超时的 fetch 请求
*/
@@ -135,6 +165,14 @@ function getDoubanProxyConfig(): {
| 'cors-anywhere'
| 'custom';
proxyUrl: string;
+ backupProxyType:
+ | 'direct'
+ | 'cors-proxy-zwei'
+ | 'cmliussss-cdn-tencent'
+ | 'cmliussss-cdn-ali'
+ | 'cors-anywhere'
+ | 'custom';
+ backupProxyUrl: string;
} {
const doubanProxyType =
localStorage.getItem('doubanDataSource') ||
@@ -144,12 +182,115 @@ function getDoubanProxyConfig(): {
localStorage.getItem('doubanProxyUrl') ||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY ||
'';
+ const doubanProxyBackupType =
+ (localStorage.getItem('doubanDataSourceBackup') as DoubanProxyType | null) ||
+ 'direct';
+ const doubanProxyBackupUrl =
+ localStorage.getItem('doubanProxyUrlBackup') || '';
+ const primaryConfig = normalizeDoubanProxyConfig(doubanProxyType, doubanProxy);
+ const backupConfig = normalizeDoubanProxyConfig(
+ doubanProxyBackupType,
+ doubanProxyBackupUrl
+ );
return {
- proxyType: doubanProxyType,
- proxyUrl: doubanProxy,
+ proxyType: primaryConfig.proxyType,
+ proxyUrl: primaryConfig.proxyUrl,
+ backupProxyType: backupConfig.proxyType,
+ backupProxyUrl: backupConfig.proxyUrl,
};
}
+function buildDoubanRequester(
+ proxyType: DoubanProxyType,
+ proxyUrl: string
+): {
+ useDirectApi: boolean;
+ requestProxyUrl: string;
+ useTencentCDN: boolean;
+ useAliCDN: boolean;
+} {
+ switch (proxyType) {
+ case 'cors-proxy-zwei':
+ return {
+ useDirectApi: false,
+ requestProxyUrl: 'https://ciao-cors.is-an.org/',
+ useTencentCDN: false,
+ useAliCDN: false,
+ };
+ case 'cmliussss-cdn-tencent':
+ return {
+ useDirectApi: false,
+ requestProxyUrl: '',
+ useTencentCDN: true,
+ useAliCDN: false,
+ };
+ case 'cmliussss-cdn-ali':
+ return {
+ useDirectApi: false,
+ requestProxyUrl: '',
+ useTencentCDN: false,
+ useAliCDN: true,
+ };
+ case 'cors-anywhere':
+ return {
+ useDirectApi: false,
+ requestProxyUrl: 'https://cors-anywhere.com/',
+ useTencentCDN: false,
+ useAliCDN: false,
+ };
+ case 'custom':
+ return {
+ useDirectApi: false,
+ requestProxyUrl: proxyUrl,
+ useTencentCDN: false,
+ useAliCDN: false,
+ };
+ case 'direct':
+ default:
+ return {
+ useDirectApi: true,
+ requestProxyUrl: '',
+ useTencentCDN: false,
+ useAliCDN: false,
+ };
+ }
+}
+
+async function requestDoubanWithFallback(
+ primary: { proxyType: DoubanProxyType; proxyUrl: string },
+ backup: { proxyType: DoubanProxyType; proxyUrl: string },
+ runner: (requester: ReturnType) => Promise
+): Promise {
+ const primaryRequester = buildDoubanRequester(primary.proxyType, primary.proxyUrl);
+ const backupRequester = buildDoubanRequester(backup.proxyType, backup.proxyUrl);
+
+ try {
+ return await runner(primaryRequester);
+ } catch (primaryError) {
+ const sameStrategy =
+ primary.proxyType === backup.proxyType && primary.proxyUrl === backup.proxyUrl;
+ if (sameStrategy) {
+ throw primaryError;
+ }
+
+ console.warn(
+ `[Douban] 主渠道失败,切换备用渠道: ${primary.proxyType} -> ${backup.proxyType}`,
+ primaryError
+ );
+ return runner(backupRequester);
+ }
+}
+
+function dispatchDoubanGlobalError(message: string) {
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(
+ new CustomEvent('globalError', {
+ detail: { message },
+ })
+ );
+ }
+}
+
/**
* 浏览器端豆瓣分类数据获取函数
*/
@@ -211,14 +352,6 @@ export async function fetchDoubanCategories(
list: list,
};
} catch (error) {
- // 触发全局错误提示
- if (typeof window !== 'undefined') {
- window.dispatchEvent(
- new CustomEvent('globalError', {
- detail: { message: '获取豆瓣分类数据失败' },
- })
- );
- }
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
}
}
@@ -230,25 +363,34 @@ export async function getDoubanCategories(
params: DoubanCategoriesParams
): Promise {
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
- const { proxyType, proxyUrl } = getDoubanProxyConfig();
- switch (proxyType) {
- case 'cors-proxy-zwei':
- return fetchDoubanCategories(params, 'https://ciao-cors.is-an.org/');
- case 'cmliussss-cdn-tencent':
- return fetchDoubanCategories(params, '', true, false);
- case 'cmliussss-cdn-ali':
- return fetchDoubanCategories(params, '', false, true);
- case 'cors-anywhere':
- return fetchDoubanCategories(params, 'https://cors-anywhere.com/');
- case 'custom':
- return fetchDoubanCategories(params, proxyUrl);
- case 'direct':
- default:
- const response = await fetch(
- `/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
- );
+ const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
+ getDoubanProxyConfig();
+ try {
+ return await requestDoubanWithFallback(
+ { proxyType, proxyUrl },
+ { proxyType: backupProxyType, proxyUrl: backupProxyUrl },
+ async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
+ if (useDirectApi) {
+ const response = await fetch(
+ `/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
+ );
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+ return response.json();
+ }
- return response.json();
+ return fetchDoubanCategories(
+ params,
+ requestProxyUrl,
+ useTencentCDN,
+ useAliCDN
+ );
+ }
+ );
+ } catch (error) {
+ dispatchDoubanGlobalError('获取豆瓣分类数据失败');
+ throw error;
}
}
@@ -263,25 +405,34 @@ export async function getDoubanList(
params: DoubanListParams
): Promise {
const { tag, type, pageLimit = 20, pageStart = 0 } = params;
- const { proxyType, proxyUrl } = getDoubanProxyConfig();
- switch (proxyType) {
- case 'cors-proxy-zwei':
- return fetchDoubanList(params, 'https://ciao-cors.is-an.org/');
- case 'cmliussss-cdn-tencent':
- return fetchDoubanList(params, '', true, false);
- case 'cmliussss-cdn-ali':
- return fetchDoubanList(params, '', false, true);
- case 'cors-anywhere':
- return fetchDoubanList(params, 'https://cors-anywhere.com/');
- case 'custom':
- return fetchDoubanList(params, proxyUrl);
- case 'direct':
- default:
- const response = await fetch(
- `/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`
- );
+ const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
+ getDoubanProxyConfig();
+ try {
+ return await requestDoubanWithFallback(
+ { proxyType, proxyUrl },
+ { proxyType: backupProxyType, proxyUrl: backupProxyUrl },
+ async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
+ if (useDirectApi) {
+ const response = await fetch(
+ `/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`
+ );
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+ return response.json();
+ }
- return response.json();
+ return fetchDoubanList(
+ params,
+ requestProxyUrl,
+ useTencentCDN,
+ useAliCDN
+ );
+ }
+ );
+ } catch (error) {
+ dispatchDoubanGlobalError('获取豆瓣列表数据失败');
+ throw error;
}
}
@@ -343,14 +494,6 @@ export async function fetchDoubanList(
list: list,
};
} catch (error) {
- // 触发全局错误提示
- if (typeof window !== 'undefined') {
- window.dispatchEvent(
- new CustomEvent('globalError', {
- detail: { message: '获取豆瓣列表数据失败' },
- })
- );
- }
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
}
}
@@ -383,25 +526,34 @@ export async function getDoubanRecommends(
platform,
sort,
} = params;
- const { proxyType, proxyUrl } = getDoubanProxyConfig();
- switch (proxyType) {
- case 'cors-proxy-zwei':
- return fetchDoubanRecommends(params, 'https://ciao-cors.is-an.org/');
- case 'cmliussss-cdn-tencent':
- return fetchDoubanRecommends(params, '', true, false);
- case 'cmliussss-cdn-ali':
- return fetchDoubanRecommends(params, '', false, true);
- case 'cors-anywhere':
- return fetchDoubanRecommends(params, 'https://cors-anywhere.com/');
- case 'custom':
- return fetchDoubanRecommends(params, proxyUrl);
- case 'direct':
- default:
- const response = await fetch(
- `/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
- );
+ const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
+ getDoubanProxyConfig();
+ try {
+ return await requestDoubanWithFallback(
+ { proxyType, proxyUrl },
+ { proxyType: backupProxyType, proxyUrl: backupProxyUrl },
+ async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
+ if (useDirectApi) {
+ const response = await fetch(
+ `/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
+ );
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+ return response.json();
+ }
- return response.json();
+ return fetchDoubanRecommends(
+ params,
+ requestProxyUrl,
+ useTencentCDN,
+ useAliCDN
+ );
+ }
+ );
+ } catch (error) {
+ dispatchDoubanGlobalError('获取豆瓣推荐数据失败');
+ throw error;
}
}
@@ -544,14 +696,6 @@ export async function fetchDoubanDetail(
const doubanData: DoubanDetailApiResponse = await response.json();
return doubanData;
} catch (error) {
- // 触发全局错误提示
- if (typeof window !== 'undefined') {
- window.dispatchEvent(
- new CustomEvent('globalError', {
- detail: { message: '获取豆瓣详情数据失败' },
- })
- );
- }
throw new Error(`获取豆瓣详情数据失败: ${(error as Error).message}`);
}
}
@@ -562,24 +706,26 @@ export async function fetchDoubanDetail(
export async function getDoubanDetail(
id: string
): Promise {
- const { proxyType, proxyUrl } = getDoubanProxyConfig();
- switch (proxyType) {
- case 'cors-proxy-zwei':
- return fetchDoubanDetail(id, 'https://ciao-cors.is-an.org/');
- case 'cmliussss-cdn-tencent':
- return fetchDoubanDetail(id, '', true, false);
- case 'cmliussss-cdn-ali':
- return fetchDoubanDetail(id, '', false, true);
- case 'cors-anywhere':
- return fetchDoubanDetail(id, 'https://cors-anywhere.com/');
- case 'custom':
- return fetchDoubanDetail(id, proxyUrl);
- case 'direct':
- default:
- const response = await fetch(`/api/douban/detail?id=${id}`);
- if (!response.ok) {
- throw new Error(`HTTP error! Status: ${response.status}`);
+ const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
+ getDoubanProxyConfig();
+ try {
+ return await requestDoubanWithFallback(
+ { proxyType, proxyUrl },
+ { proxyType: backupProxyType, proxyUrl: backupProxyUrl },
+ async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
+ if (useDirectApi) {
+ const response = await fetch(`/api/douban/detail?id=${id}`);
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+ return response.json();
+ }
+
+ return fetchDoubanDetail(id, requestProxyUrl, useTencentCDN, useAliCDN);
}
- return response.json();
+ );
+ } catch (error) {
+ dispatchDoubanGlobalError('获取豆瓣详情数据失败');
+ throw error;
}
}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 8035e1f..1b3164e 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -3,8 +3,7 @@ import bs58 from 'bs58';
import he from 'he';
import Hls from 'hls.js';
-function getDoubanImageProxyConfig(): {
- proxyType:
+export type DoubanImageProxyType =
| 'direct'
| 'server'
| 'img3'
@@ -12,13 +11,72 @@ function getDoubanImageProxyConfig(): {
| 'cmliussss-cdn-ali'
| 'baidu'
| 'custom';
+
+function normalizeDoubanImageProxyConfig(
+ proxyType: DoubanImageProxyType,
+ proxyUrl: string
+): {
+ proxyType: DoubanImageProxyType;
proxyUrl: string;
+} {
+ const normalizedProxyUrl = proxyUrl.trim();
+
+ if (proxyType === 'custom' && !normalizedProxyUrl) {
+ return {
+ proxyType: 'server',
+ proxyUrl: '',
+ };
+ }
+
+ return {
+ proxyType,
+ proxyUrl: normalizedProxyUrl,
+ };
+}
+
+function buildDoubanImageUrl(
+ originalUrl: string,
+ proxyType: DoubanImageProxyType,
+ proxyUrl: string
+): string {
+ switch (proxyType) {
+ case 'server':
+ return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
+ case 'img3':
+ return originalUrl.replace(/img\d+\.doubanio\.com/g, 'img3.doubanio.com');
+ case 'cmliussss-cdn-tencent':
+ return originalUrl.replace(
+ /img\d+\.doubanio\.com/g,
+ 'img.doubanio.cmliussss.net'
+ );
+ case 'cmliussss-cdn-ali':
+ return originalUrl.replace(
+ /img\d+\.doubanio\.com/g,
+ 'img.doubanio.cmliussss.com'
+ );
+ case 'baidu':
+ return `https://image.baidu.com/search/down?url=${encodeURIComponent(originalUrl)}`;
+ case 'custom':
+ return proxyUrl ? `${proxyUrl}${encodeURIComponent(originalUrl)}` : originalUrl;
+ case 'direct':
+ default:
+ return originalUrl;
+ }
+}
+
+function getDoubanImageProxyConfig(): {
+ proxyType: DoubanImageProxyType;
+ proxyUrl: string;
+ backupProxyType: DoubanImageProxyType;
+ backupProxyUrl: string;
} {
// 确保在浏览器环境中执行
if (typeof window === 'undefined') {
return {
proxyType: 'cmliussss-cdn-tencent',
proxyUrl: '',
+ backupProxyType: 'server',
+ backupProxyUrl: '',
};
}
@@ -30,12 +88,70 @@ function getDoubanImageProxyConfig(): {
localStorage.getItem('doubanImageProxyUrl') ||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY ||
'';
+ const doubanImageProxyBackupType =
+ (localStorage.getItem('doubanImageProxyTypeBackup') as DoubanImageProxyType | null) ||
+ 'server';
+ const doubanImageProxyBackupUrl =
+ localStorage.getItem('doubanImageProxyUrlBackup') || '';
+ const primaryConfig = normalizeDoubanImageProxyConfig(
+ doubanImageProxyType,
+ doubanImageProxy
+ );
+ const backupConfig = normalizeDoubanImageProxyConfig(
+ doubanImageProxyBackupType,
+ doubanImageProxyBackupUrl
+ );
return {
- proxyType: doubanImageProxyType,
- proxyUrl: doubanImageProxy,
+ proxyType: primaryConfig.proxyType,
+ proxyUrl: primaryConfig.proxyUrl,
+ backupProxyType: backupConfig.proxyType,
+ backupProxyUrl: backupConfig.proxyUrl,
};
}
+export function getDoubanImageFallbackUrl(originalUrl: string): string | null {
+ if (!originalUrl || !originalUrl.includes('doubanio.com')) {
+ return null;
+ }
+
+ const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
+ getDoubanImageProxyConfig();
+ const primaryUrl = buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
+ const backupUrl = buildDoubanImageUrl(
+ originalUrl,
+ backupProxyType,
+ backupProxyUrl
+ );
+
+ if (backupUrl === primaryUrl) {
+ return null;
+ }
+
+ return backupUrl;
+}
+
+export function tryApplyDoubanImageFallback(
+ target: HTMLImageElement,
+ originalUrl: string
+): boolean {
+ if (!originalUrl || !originalUrl.includes('doubanio.com')) {
+ return false;
+ }
+
+ if (target.dataset.doubanBackupTried === 'true') {
+ return false;
+ }
+
+ const fallbackUrl = getDoubanImageFallbackUrl(originalUrl);
+ if (!fallbackUrl || fallbackUrl === target.currentSrc || fallbackUrl === target.src) {
+ return false;
+ }
+
+ target.dataset.doubanBackupTried = 'true';
+ target.src = fallbackUrl;
+ return true;
+}
+
/**
* 处理图片 URL,根据用户设置使用相应的代理
*/
@@ -65,29 +181,7 @@ export function processImageUrl(originalUrl: string): string {
}
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
- switch (proxyType) {
- case 'server':
- return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
- case 'img3':
- return originalUrl.replace(/img\d+\.doubanio\.com/g, 'img3.doubanio.com');
- case 'cmliussss-cdn-tencent':
- return originalUrl.replace(
- /img\d+\.doubanio\.com/g,
- 'img.doubanio.cmliussss.net'
- );
- case 'cmliussss-cdn-ali':
- return originalUrl.replace(
- /img\d+\.doubanio\.com/g,
- 'img.doubanio.cmliussss.com'
- );
- case 'baidu':
- return `https://image.baidu.com/search/down?url=${encodeURIComponent(originalUrl)}`;
- case 'custom':
- return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
- case 'direct':
- default:
- return originalUrl;
- }
+ return buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
}
/**
@@ -406,4 +500,3 @@ export function base58Decode(encoded: string): string {
// 在 Node.js 环境中使用 Buffer
return Buffer.from(bytes).toString('utf-8');
}
-
From 6b4555346d0769ea70cec77eb4a40148101e68b9 Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Thu, 9 Apr 2026 21:44:15 +0800
Subject: [PATCH 16/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dplay=E9=A1=B5=E9=9D=A2?=
=?UTF-8?q?=E7=85=A7=E7=89=87=E5=A2=99=E7=9A=84=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/DetailPanel.tsx | 176 ++++++++++++++++++---------------
1 file changed, 95 insertions(+), 81 deletions(-)
diff --git a/src/components/DetailPanel.tsx b/src/components/DetailPanel.tsx
index 545dcc4..97335c1 100644
--- a/src/components/DetailPanel.tsx
+++ b/src/components/DetailPanel.tsx
@@ -1045,95 +1045,109 @@ const DetailPanel: React.FC = ({
return { visibleItems, totalHeight, usedWidth };
}, [galleryImages, galleryScrollTop, galleryViewportHeight, galleryViewportWidth]);
- const galleryModal = showGallery ? (
+ const galleryBody = (
+
+ {galleryLoading && (
+
+ )}
+
+ {!galleryLoading && galleryError && (
+
{galleryError}
+ )}
+
+ {!galleryLoading && !galleryError && galleryImages.length === 0 && (
+
暂无图片
+ )}
+
+ {!galleryLoading && !galleryError && galleryImages.length > 0 && (
+
+ {virtualGalleryLayout.visibleItems.map((image) => {
+ const imageUrl = getTMDBImageUrl(
+ image.file_path,
+ image.imageType === 'poster' ? 'w500' : 'original'
+ );
+ const thumbUrl = getTMDBImageUrl(
+ image.file_path,
+ image.imageType === 'poster' ? 'w342' : 'w780'
+ );
+
+ return (
+
+
handleImageClick(imageUrl)}
+ >
+
+
+ {image.imageType === 'poster' ? '海报' : '剧照'}
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+
+ const galleryHeader = (
+
+
+
照片墙
+ {!galleryLoading && (
+
+ 共 {galleryTotal} 张
+
+ )}
+
+
setShowGallery(false)}
+ className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
+ aria-label="关闭照片墙"
+ >
+
+
+
+ );
+
+ const galleryModal = showGallery ? (useDrawer ? (
+
+
+ {galleryHeader}
+ {galleryBody}
+
+
+ ) : (
setShowGallery(false)}
/>
-
-
-
照片墙
- {!galleryLoading && (
-
- 共 {galleryTotal} 张
-
- )}
-
-
setShowGallery(false)}
- className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
- aria-label="关闭照片墙"
- >
-
-
-
-
-
- {galleryLoading && (
-
- )}
-
- {!galleryLoading && galleryError && (
-
{galleryError}
- )}
-
- {!galleryLoading && !galleryError && galleryImages.length === 0 && (
-
暂无图片
- )}
-
- {!galleryLoading && !galleryError && galleryImages.length > 0 && (
-
- {virtualGalleryLayout.visibleItems.map((image) => {
- const imageUrl = getTMDBImageUrl(
- image.file_path,
- image.imageType === 'poster' ? 'w500' : 'original'
- );
- const thumbUrl = getTMDBImageUrl(
- image.file_path,
- image.imageType === 'poster' ? 'w342' : 'w780'
- );
-
- return (
-
-
handleImageClick(imageUrl)}
- >
-
-
- {image.imageType === 'poster' ? '海报' : '剧照'}
-
-
-
- );
- })}
-
- )}
-
+ {galleryHeader}
+ {galleryBody}
- ) : null;
+ )) : null;
if (!isVisible || !mounted) return null;
From 106e195183ed3ea2c8dcf654a7a923ea315da82d Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Thu, 9 Apr 2026 21:54:28 +0800
Subject: [PATCH 17/18] =?UTF-8?q?alert=E4=BF=AE=E6=94=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/PansouSearch.tsx | 441 +++++++++++++++++---------------
1 file changed, 233 insertions(+), 208 deletions(-)
diff --git a/src/components/PansouSearch.tsx b/src/components/PansouSearch.tsx
index b9136c5..915b6db 100644
--- a/src/components/PansouSearch.tsx
+++ b/src/components/PansouSearch.tsx
@@ -5,6 +5,7 @@ import { AlertCircle, Copy, ExternalLink, Loader2, RefreshCw } from 'lucide-reac
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
+import Toast, { ToastProps } from '@/components/Toast';
import { PansouLink, PansouSearchResult } from '@/lib/pansou.client';
interface PansouSearchProps {
@@ -60,6 +61,7 @@ export default function PansouSearch({
const [selectedType, setSelectedType] = useState('all'); // 'all' 表示显示全部
const [transferingUrl, setTransferingUrl] = useState(null);
const [playingUrl, setPlayingUrl] = useState(null);
+ const [toast, setToast] = useState(null);
// 提取搜索函数,以便在重试时调用
const searchPansou = useCallback(async () => {
@@ -141,9 +143,17 @@ export default function PansouSearch({
throw new Error(data.error || '转存失败');
}
- window.alert(`转存成功,已保存到:${data.targetPath}`);
+ setToast({
+ message: `转存成功,已保存到:${data.targetPath}`,
+ type: 'success',
+ onClose: () => setToast(null),
+ });
} catch (err: any) {
- window.alert(err?.message || '转存失败');
+ setToast({
+ message: err?.message || '转存失败',
+ type: 'error',
+ onClose: () => setToast(null),
+ });
} finally {
setTransferingUrl(null);
}
@@ -173,230 +183,245 @@ export default function PansouSearch({
`/play?source=quark-temp&id=${encodeURIComponent(data.id)}&title=${encodeURIComponent(data.title || keyword)}`
);
} catch (err: any) {
- window.alert(err?.message || '立即播放失败');
+ setToast({
+ message: err?.message || '立即播放失败',
+ type: 'error',
+ onClose: () => setToast(null),
+ });
} finally {
setPlayingUrl(null);
}
};
- if (loading) {
- return (
-
-
-
-
- 正在搜索网盘资源...
-
+ const renderBody = () => {
+ if (loading) {
+ return (
+
-
- );
- }
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (!results || results.total === 0 || !results.merged_by_type) {
+ return (
+
+ );
+ }
+
+ const cloudTypes = Object.keys(results.merged_by_type || {});
+
+ // 过滤显示的网盘类型
+ const filteredCloudTypes = selectedType === 'all'
+ ? cloudTypes
+ : cloudTypes.filter(type => type === selectedType);
+
+ // 计算每种网盘类型的数量
+ const typeStats = cloudTypes.map(type => ({
+ type,
+ count: results.merged_by_type?.[type]?.length || 0,
+ }));
- if (error) {
return (
-
-
-
-
{error}
+ <>
+ {/* 搜索结果统计 */}
+
+ 找到 {results.total} 个资源
+
+
+ {/* 网盘类型过滤器 */}
+
setSelectedType('all')}
+ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
+ selectedType === 'all'
+ ? 'bg-green-600 text-white dark:bg-green-600'
+ : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
+ }`}
>
-
- 重试
+ 全部 ({results.total})
+ {typeStats.map(({ type, count }) => {
+ const typeName = CLOUD_TYPE_NAMES[type] || type;
+
+ return (
+ setSelectedType(type)}
+ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
+ selectedType === type
+ ? 'bg-green-600 text-white dark:bg-green-600'
+ : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
+ }`}
+ >
+ {typeName} ({count})
+
+ );
+ })}
-
- );
- }
- if (!results || results.total === 0 || !results.merged_by_type) {
- return (
-
- );
- }
+ {/* 按网盘类型分类显示 */}
+ {filteredCloudTypes.map((cloudType) => {
+ const links = results.merged_by_type?.[cloudType];
+ if (!links || links.length === 0) return null;
- const cloudTypes = Object.keys(results.merged_by_type || {});
-
- // 过滤显示的网盘类型
- const filteredCloudTypes = selectedType === 'all'
- ? cloudTypes
- : cloudTypes.filter(type => type === selectedType);
-
- // 计算每种网盘类型的数量
- const typeStats = cloudTypes.map(type => ({
- type,
- count: results.merged_by_type?.[type]?.length || 0,
- }));
-
- return (
-
- {/* 搜索结果统计 */}
-
- 找到 {results.total} 个资源
-
-
- {/* 网盘类型过滤器 */}
-
-
setSelectedType('all')}
- className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
- selectedType === 'all'
- ? 'bg-green-600 text-white dark:bg-green-600'
- : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
- }`}
- >
- 全部 ({results.total})
-
- {typeStats.map(({ type, count }) => {
- const typeName = CLOUD_TYPE_NAMES[type] || type;
+ const typeName = CLOUD_TYPE_NAMES[cloudType] || cloudType;
+ const typeColor = CLOUD_TYPE_COLORS[cloudType] || CLOUD_TYPE_COLORS.others;
return (
-
setSelectedType(type)}
- className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
- selectedType === type
- ? 'bg-green-600 text-white dark:bg-green-600'
- : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
- }`}
- >
- {typeName} ({count})
-
+
+ {/* 网盘类型标题 */}
+
+
+ {typeName}
+
+
+ {links.length} 个链接
+
+
+
+ {/* 链接列表 */}
+
+ {links.map((link: PansouLink, index: number) => (
+
+ {/* 资源标题 */}
+ {link.note && (
+
+ {link.note}
+
+ )}
+
+ {/* 链接和密码 */}
+
+
+
+ {link.url}
+
+ {link.password && (
+
+ 提取码: {link.password}
+
+ )}
+
+
+ {/* 操作按钮 */}
+
+ {cloudType === 'quark' && (
+ <>
+ handleQuarkInstantPlay(link)}
+ disabled={playingUrl === link.url}
+ className='px-2 py-1 rounded-md bg-green-600 hover:bg-green-700 text-white text-xs transition-colors disabled:opacity-60'
+ title='立即播放'
+ >
+ {playingUrl === link.url ? '处理中...' : '立即播放'}
+
+ handleQuarkTransfer(link)}
+ disabled={transferingUrl === link.url}
+ className='px-2 py-1 rounded-md bg-purple-600 hover:bg-purple-700 text-white text-xs transition-colors disabled:opacity-60'
+ title='转存到配置目录'
+ >
+ {transferingUrl === link.url ? '转存中...' : '转存'}
+
+ >
+ )}
+ handleCopy(
+ link.password ? `${link.url}\n提取码: ${link.password}` : link.url,
+ link.url
+ )}
+ className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
+ title='复制链接'
+ >
+ {copiedUrl === link.url ? (
+ 已复制
+ ) : (
+
+ )}
+
+ handleOpenLink(link.url)}
+ className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
+ title='打开链接'
+ >
+
+
+
+
+
+ {/* 来源和时间 */}
+
+ {link.source && (
+ 来源: {link.source}
+ )}
+ {link.datetime && (
+ {new Date(link.datetime).toLocaleDateString()}
+ )}
+
+
+ {/* 图片预览 */}
+ {link.images && link.images.length > 0 && (
+
+ {link.images.map((img, imgIndex) => (
+

+ ))}
+
+ )}
+
+ ))}
+
+
);
})}
+ >
+ );
+ };
+
+ return (
+ <>
+
+ {renderBody()}
-
- {/* 按网盘类型分类显示 */}
- {filteredCloudTypes.map((cloudType) => {
- const links = results.merged_by_type?.[cloudType];
- if (!links || links.length === 0) return null;
-
- const typeName = CLOUD_TYPE_NAMES[cloudType] || cloudType;
- const typeColor = CLOUD_TYPE_COLORS[cloudType] || CLOUD_TYPE_COLORS.others;
-
- return (
-
- {/* 网盘类型标题 */}
-
-
- {typeName}
-
-
- {links.length} 个链接
-
-
-
- {/* 链接列表 */}
-
- {links.map((link: PansouLink, index: number) => (
-
- {/* 资源标题 */}
- {link.note && (
-
- {link.note}
-
- )}
-
- {/* 链接和密码 */}
-
-
-
- {link.url}
-
- {link.password && (
-
- 提取码: {link.password}
-
- )}
-
-
- {/* 操作按钮 */}
-
- {cloudType === 'quark' && (
- <>
- handleQuarkInstantPlay(link)}
- disabled={playingUrl === link.url}
- className='px-2 py-1 rounded-md bg-green-600 hover:bg-green-700 text-white text-xs transition-colors disabled:opacity-60'
- title='立即播放'
- >
- {playingUrl === link.url ? '处理中...' : '立即播放'}
-
- handleQuarkTransfer(link)}
- disabled={transferingUrl === link.url}
- className='px-2 py-1 rounded-md bg-purple-600 hover:bg-purple-700 text-white text-xs transition-colors disabled:opacity-60'
- title='转存到配置目录'
- >
- {transferingUrl === link.url ? '转存中...' : '转存'}
-
- >
- )}
- handleCopy(
- link.password ? `${link.url}\n提取码: ${link.password}` : link.url,
- link.url
- )}
- className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
- title='复制链接'
- >
- {copiedUrl === link.url ? (
- 已复制
- ) : (
-
- )}
-
- handleOpenLink(link.url)}
- className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
- title='打开链接'
- >
-
-
-
-
-
- {/* 来源和时间 */}
-
- {link.source && (
- 来源: {link.source}
- )}
- {link.datetime && (
- {new Date(link.datetime).toLocaleDateString()}
- )}
-
-
- {/* 图片预览 */}
- {link.images && link.images.length > 0 && (
-
- {link.images.map((img, imgIndex) => (
-

- ))}
-
- )}
-
- ))}
-
-
- );
- })}
-
+ {toast &&
}
+ >
);
}
From 0d00fafcb562e14f9c9dc92d50d7c309dbef9a12 Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Fri, 10 Apr 2026 11:49:38 +0800
Subject: [PATCH 18/18] v217
---
CHANGELOG | 17 +++++++++++++++++
VERSION.txt | 3 +--
src/lib/changelog.ts | 20 ++++++++++++++++++++
src/lib/version.ts | 2 +-
4 files changed, 39 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG b/CHANGELOG
index 36fdd2a..86fe0a3 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,20 @@
+## [217.0.0] - 2026-04-10
+### Added
+- 新增注册邀请码功能
+- 增加弹幕内置源
+- 增加netlify部署支持
+- 观影室增加屏幕共享功能
+- 详情面板新增照片墙功能
+- 新增夸克网盘的转存与播放功能
+- 豆瓣数据源增加备用源功能
+
+### Changed
+- 移除音乐功能
+
+### Fixed
+- 修复弹幕选集分组无法鼠标滑轮滚动
+- 修复高级推荐报错无限刷新
+
## [216.0.0] - 2026-03-30
### Added
- 新增视频源脚本
diff --git a/VERSION.txt b/VERSION.txt
index b7ea4d1..2cc7045 100644
--- a/VERSION.txt
+++ b/VERSION.txt
@@ -1,2 +1 @@
-216.0.0
-
+217.0.0
diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts
index b883804..ff57b5b 100644
--- a/src/lib/changelog.ts
+++ b/src/lib/changelog.ts
@@ -10,6 +10,26 @@ export interface ChangelogEntry {
}
export const changelog: ChangelogEntry[] = [
+ {
+ version: "217.0.0",
+ date: "2026-04-10",
+ added: [
+ "新增注册邀请码功能",
+ "增加弹幕内置源",
+ "增加netlify部署支持",
+ "观影室增加屏幕共享功能",
+ "详情面板新增照片墙功能",
+ "新增夸克网盘的转存与播放功能",
+ "豆瓣数据源增加备用源功能"
+ ],
+ changed: [
+ "移除音乐功能"
+ ],
+ fixed: [
+ "修复弹幕选集分组无法鼠标滑轮滚动",
+ "修复高级推荐报错无限刷新"
+ ]
+ },
{
version: "216.0.0",
date: "2026-03-30",
diff --git a/src/lib/version.ts b/src/lib/version.ts
index 795a8b7..638c244 100644
--- a/src/lib/version.ts
+++ b/src/lib/version.ts
@@ -1,6 +1,6 @@
/* eslint-disable no-console */
-const CURRENT_VERSION = '216.0.0';
+const CURRENT_VERSION = '217.0.0';
// 导出当前版本号供其他地方使用
export { CURRENT_VERSION };