diff --git a/package.json b/package.json
index e35cf07..9435622 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,10 @@
},
"dependencies": {
"@cloudflare/next-on-pages": "^1.13.12",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/modifiers": "^9.0.0",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0",
"@upstash/redis": "^1.25.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9e0ff3f..f3a3560 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,18 @@ importers:
'@cloudflare/next-on-pages':
specifier: ^1.13.12
version: 1.13.12(vercel@44.2.7(rollup@2.79.2))(wrangler@4.22.0)
+ '@dnd-kit/core':
+ specifier: ^6.3.1
+ version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@dnd-kit/modifiers':
+ specifier: ^9.0.0
+ version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
+ '@dnd-kit/sortable':
+ specifier: ^10.0.0
+ version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
+ '@dnd-kit/utilities':
+ specifier: ^3.2.2
+ version: 3.2.2(react@18.3.1)
'@headlessui/react':
specifier: ^2.2.4
version: 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -951,6 +963,34 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
+ '@dnd-kit/accessibility@3.1.1':
+ resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
+ peerDependencies:
+ react: '>=16.8.0'
+
+ '@dnd-kit/core@6.3.1':
+ resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@dnd-kit/modifiers@9.0.0':
+ resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.3.0
+ react: '>=16.8.0'
+
+ '@dnd-kit/sortable@10.0.0':
+ resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.3.0
+ react: '>=16.8.0'
+
+ '@dnd-kit/utilities@3.2.2':
+ resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
+ peerDependencies:
+ react: '>=16.8.0'
+
'@edge-runtime/format@2.2.1':
resolution: {integrity: sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g==}
engines: {node: '>=16'}
@@ -7306,6 +7346,38 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
+ '@dnd-kit/accessibility@3.1.1(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ tslib: 2.8.1
+
+ '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@dnd-kit/accessibility': 3.1.1(react@18.3.1)
+ '@dnd-kit/utilities': 3.2.2(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ tslib: 2.8.1
+
+ '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@dnd-kit/utilities': 3.2.2(react@18.3.1)
+ react: 18.3.1
+ tslib: 2.8.1
+
+ '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@dnd-kit/utilities': 3.2.2(react@18.3.1)
+ react: 18.3.1
+ tslib: 2.8.1
+
+ '@dnd-kit/utilities@3.2.2(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ tslib: 2.8.1
+
'@edge-runtime/format@2.2.1': {}
'@edge-runtime/node-utils@2.3.0': {}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
new file mode 100644
index 0000000..bbecc0e
--- /dev/null
+++ b/src/app/admin/page.tsx
@@ -0,0 +1,910 @@
+/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
+
+'use client';
+
+import {
+ closestCenter,
+ DndContext,
+ PointerSensor,
+ TouchSensor,
+ useSensor,
+ useSensors,
+} from '@dnd-kit/core';
+import {
+ restrictToParentElement,
+ restrictToVerticalAxis,
+} from '@dnd-kit/modifiers';
+import {
+ arrayMove,
+ SortableContext,
+ useSortable,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { ChevronDown, ChevronUp, Settings, Users, Video } from 'lucide-react';
+import { GripVertical } from 'lucide-react';
+import { useEffect, useState } from 'react';
+
+import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
+
+import PageLayout from '@/components/PageLayout';
+
+// 新增站点配置类型
+interface SiteConfig {
+ SiteName: string;
+ Announcement: string;
+ SearchDownstreamMaxPage: number;
+ SiteInterfaceCacheTime: number;
+ SearchResultDefaultAggregate: boolean;
+}
+
+// 视频源数据类型
+interface DataSource {
+ name: string;
+ key: string;
+ api: string;
+ detail?: string;
+ disabled?: boolean;
+ from: 'config' | 'custom';
+}
+
+// 可折叠标签组件
+interface CollapsibleTabProps {
+ title: string;
+ icon?: React.ReactNode;
+ isExpanded: boolean;
+ onToggle: () => void;
+ children: React.ReactNode;
+}
+
+const CollapsibleTab = ({
+ title,
+ icon,
+ isExpanded,
+ onToggle,
+ children,
+}: CollapsibleTabProps) => {
+ return (
+
+
+
+ {isExpanded &&
{children}
}
+
+ );
+};
+
+// 用户配置组件
+const UserConfig = ({ config }: { config: AdminConfig | null }) => {
+ const [userSettings, setUserSettings] = useState({
+ enableRegistration: false,
+ });
+ const [showAddUserForm, setShowAddUserForm] = useState(false);
+ const [newUser, setNewUser] = useState({
+ username: '',
+ password: '',
+ });
+
+ useEffect(() => {
+ if (config?.UserConfig) {
+ setUserSettings({
+ enableRegistration: config.UserConfig.AllowRegister,
+ });
+ }
+ }, [config]);
+
+ const handleBanUser = (username: string) => {
+ // 这里应该调用API来封禁用户
+ console.log('封禁用户:', username);
+ };
+
+ const handleUnbanUser = (username: string) => {
+ // 这里应该调用API来解封用户
+ console.log('解封用户:', username);
+ };
+
+ const handleSetAdmin = (username: string) => {
+ // 这里应该调用API来设为管理员
+ console.log('设为管理员:', username);
+ };
+
+ const handleRemoveAdmin = (username: string) => {
+ // 这里应该调用API来取消管理员
+ console.log('取消管理员:', username);
+ };
+
+ const handleAddUser = () => {
+ // 这里应该调用API来添加用户,默认角色为 user
+ console.log('添加用户:', { ...newUser, role: 'user' });
+ setNewUser({ username: '', password: '' });
+ setShowAddUserForm(false);
+ };
+
+ if (!config) {
+ return (
+
+ 加载中...
+
+ );
+ }
+
+ return (
+
+ {/* 用户统计 */}
+
+
+ 用户统计
+
+
+
+ {config.UserConfig.Users.length}
+
+
+ 总用户数
+
+
+
+
+ {/* 注册设置 */}
+
+
+ 注册设置
+
+
+
+
+
+
+
+ {/* 用户列表 */}
+
+
+
+ 用户列表
+
+
+
+
+ {/* 添加用户表单 */}
+ {showAddUserForm && (
+
+ )}
+
+ {/* 用户列表 */}
+
+
+
+
+ |
+ 用户名
+ |
+
+ 角色
+ |
+
+ 状态
+ |
+
+ 操作
+ |
+
+
+
+ {config.UserConfig.Users.map((user) => (
+
+ |
+ {user.username}
+ |
+
+
+ {user.role === 'owner'
+ ? '站长'
+ : user.role === 'admin'
+ ? '管理员'
+ : '普通用户'}
+
+ |
+
+
+ {!user.banned ? '正常' : '已封禁'}
+
+ |
+
+ {user.role === 'user' ? (
+
+ ) : user.role === 'admin' ? (
+
+ ) : null}
+ {user.role !== 'owner' &&
+ (!user.banned ? (
+
+ ) : (
+
+ ))}
+ |
+
+ ))}
+
+
+
+
+
+ );
+};
+
+// 视频源配置组件
+const VideoSourceConfig = ({ config }: { config: AdminConfig | null }) => {
+ const [sources, setSources] = useState([]);
+ const [showAddForm, setShowAddForm] = useState(false);
+ const [orderChanged, setOrderChanged] = useState(false);
+ const [newSource, setNewSource] = useState({
+ name: '',
+ key: '',
+ api: '',
+ detail: '',
+ disabled: false,
+ from: 'config',
+ });
+
+ // dnd-kit 传感器
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 5, // 轻微位移即可触发
+ },
+ }),
+ useSensor(TouchSensor, {
+ activationConstraint: {
+ delay: 150, // 长按 150ms 后触发,避免与滚动冲突
+ tolerance: 5,
+ },
+ })
+ );
+
+ // 初始化
+ useEffect(() => {
+ if (config?.SourceConfig) {
+ setSources(config.SourceConfig);
+ }
+ }, [config]);
+
+ const handleToggleEnable = (key: string) => {
+ setSources((prev) =>
+ prev.map((source) =>
+ source.key === key ? { ...source, disabled: !source.disabled } : source
+ )
+ );
+ };
+
+ const handleDelete = (key: string) => {
+ setSources((prev) => prev.filter((source) => source.key !== key));
+ };
+
+ const handleAddSource = () => {
+ if (!newSource.name || !newSource.key || !newSource.api) return;
+ setSources((prev) => [...prev, newSource]);
+ setNewSource({
+ name: '',
+ key: '',
+ api: '',
+ detail: '',
+ disabled: false,
+ from: 'custom',
+ });
+ setShowAddForm(false);
+ setOrderChanged(true);
+ };
+
+ const handleDragEnd = (event: any) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+ const oldIndex = sources.findIndex((s) => s.key === active.id);
+ const newIndex = sources.findIndex((s) => s.key === over.id);
+ setSources((prev) => arrayMove(prev, oldIndex, newIndex));
+ setOrderChanged(true);
+ };
+
+ const handleSaveOrder = () => {
+ console.log('保存排序:', sources);
+ // TODO: 调用 API 保存排序
+ setOrderChanged(false);
+ };
+
+ // 可拖拽行封装 (dnd-kit)
+ const DraggableRow = ({ source }: { source: DataSource }) => {
+ const { attributes, listeners, setNodeRef, transform, transition } =
+ useSortable({ id: source.key });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ } as React.CSSProperties;
+
+ return (
+
+ |
+
+ |
+
+ {source.name}
+ |
+
+ {source.key}
+ |
+
+ {source.api}
+ |
+
+ {source.detail || '-'}
+ |
+
+
+ {!source.disabled ? '启用中' : '已禁用'}
+
+ |
+
+
+ {source.from !== 'config' && (
+
+ )}
+ |
+
+ );
+ };
+
+ if (!config) {
+ return (
+
+ 加载中...
+
+ );
+ }
+
+ return (
+
+ {/* 添加数据源表单 */}
+
+
+ 数据源列表
+
+
+
+
+ {showAddForm && (
+
+ )}
+
+ {/* 数据源表格 */}
+
+
+
+
+ |
+
+ 名称
+ |
+
+ Key
+ |
+
+ API 地址
+ |
+
+ Detail 地址
+ |
+
+ 状态
+ |
+
+ 操作
+ |
+
+
+
+ s.key)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {sources.map((source) => (
+
+ ))}
+
+
+
+
+
+
+ {/* 保存排序按钮 */}
+ {orderChanged && (
+
+
+
+ )}
+
+ );
+};
+
+// 新增站点配置组件
+const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
+ const [siteSettings, setSiteSettings] = useState({
+ SiteName: '',
+ Announcement: '',
+ SearchDownstreamMaxPage: 1,
+ SiteInterfaceCacheTime: 7200,
+ SearchResultDefaultAggregate: false,
+ });
+
+ useEffect(() => {
+ if (config?.SiteConfig) {
+ setSiteSettings(config.SiteConfig);
+ }
+ }, [config]);
+
+ if (!config) {
+ return (
+
+ 加载中...
+
+ );
+ }
+
+ return (
+
+ {/* 站点名称 */}
+
+
+
+ setSiteSettings((prev) => ({ ...prev, SiteName: 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'
+ />
+
+
+ {/* 站点公告 */}
+
+
+
+
+ {/* 搜索接口可拉取最大页数 */}
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ SearchDownstreamMaxPage: Number(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'
+ />
+
+
+ {/* 站点接口缓存时间 */}
+
+
+
+ setSiteSettings((prev) => ({
+ ...prev,
+ SiteInterfaceCacheTime: Number(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'
+ />
+
+
+ {/* 默认按标题和年份聚合 */}
+
+
+
+
+
+ {/* 操作按钮 */}
+
+
+
+
+ );
+};
+
+export default function AdminPage() {
+ const [config, setConfig] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({
+ userConfig: false,
+ videoSource: false,
+ siteConfig: false,
+ });
+
+ // 获取管理员配置
+ useEffect(() => {
+ const fetchConfig = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch('/api/admin/config');
+
+ if (!response.ok) {
+ throw new Error(`获取配置失败: ${response.status}`);
+ }
+
+ const data = (await response.json()) as AdminConfigResult;
+ setConfig(data.Config);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : '获取配置失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchConfig();
+ }, []);
+
+ // 切换标签展开状态
+ const toggleTab = (tabKey: string) => {
+ setExpandedTabs((prev) => ({
+ ...prev,
+ [tabKey]: !prev[tabKey],
+ }));
+ };
+
+ if (loading) {
+ return (
+
+
+
+
+ 管理员设置
+
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ 管理员设置
+
+
+ {/* 站点配置标签 */}
+
+ }
+ isExpanded={expandedTabs.siteConfig}
+ onToggle={() => toggleTab('siteConfig')}
+ >
+
+
+
+
+ {/* 用户配置标签 */}
+
+ }
+ isExpanded={expandedTabs.userConfig}
+ onToggle={() => toggleTab('userConfig')}
+ >
+
+
+
+ {/* 视频源配置标签 */}
+
+ }
+ isExpanded={expandedTabs.videoSource}
+ onToggle={() => toggleTab('videoSource')}
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/api/admin/config/route.ts b/src/app/api/admin/config/route.ts
new file mode 100644
index 0000000..1dd871d
--- /dev/null
+++ b/src/app/api/admin/config/route.ts
@@ -0,0 +1,60 @@
+/* eslint-disable no-console */
+
+import { NextResponse } from 'next/server';
+
+import { AdminConfigResult } from '@/lib/admin.types';
+import { getConfig } from '@/lib/config';
+
+export const runtime = 'edge';
+
+export async function GET() {
+ try {
+ const config = getConfig();
+ const result: AdminConfigResult = {
+ Role: 'owner',
+ Config: config,
+ };
+
+ return NextResponse.json(result, {
+ headers: {
+ 'Cache-Control': 'no-store', // 管理员配置不缓存
+ },
+ });
+ } catch (error) {
+ console.error('获取管理员配置失败:', error);
+ return NextResponse.json(
+ {
+ error: '获取管理员配置失败',
+ details: (error as Error).message,
+ },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const updateData = await request.json();
+
+ // 在实际应用中,这里应该验证用户权限并更新配置
+ console.log('更新管理员配置:', updateData);
+
+ // 模拟配置更新
+ // 在实际应用中,这里应该将配置保存到数据库或配置文件
+
+ return NextResponse.json({
+ success: true,
+ message: '配置更新成功',
+ timestamp: new Date().toISOString(),
+ });
+ } catch (error) {
+ console.error('更新管理员配置失败:', error);
+ return NextResponse.json(
+ {
+ error: '更新管理员配置失败',
+ details: (error as Error).message,
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts
index 2e55f9f..4e5552f 100644
--- a/src/app/api/detail/route.ts
+++ b/src/app/api/detail/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
-import { getApiSites, getCacheTime } from '@/lib/config';
+import { getAvailableApiSites, getCacheTime } from '@/lib/config';
import { getDetailFromApi } from '@/lib/downstream';
export const runtime = 'edge';
@@ -19,7 +19,7 @@ export async function GET(request: Request) {
}
try {
- const apiSites = getApiSites();
+ const apiSites = getAvailableApiSites();
const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) {
diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts
index 91eccbb..105827a 100644
--- a/src/app/api/register/route.ts
+++ b/src/app/api/register/route.ts
@@ -1,6 +1,7 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
+import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'edge';
@@ -20,8 +21,9 @@ export async function POST(req: NextRequest) {
);
}
+ const config = getConfig();
// 校验是否开放注册
- if (process.env.NEXT_PUBLIC_ENABLE_REGISTER !== 'true') {
+ if (!config.UserConfig.AllowRegister) {
return NextResponse.json({ error: '当前未开放注册' }, { status: 400 });
}
diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts
index 9f0e7f0..9039a42 100644
--- a/src/app/api/search/route.ts
+++ b/src/app/api/search/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
-import { getApiSites, getCacheTime } from '@/lib/config';
+import { getAvailableApiSites, getCacheTime } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
export const runtime = 'edge';
@@ -21,7 +21,7 @@ export async function GET(request: Request) {
);
}
- const apiSites = getApiSites();
+ const apiSites = getAvailableApiSites();
const searchPromises = apiSites.map((site) => searchFromApi(site, query));
try {
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index dbaba9a..5f279a8 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -4,14 +4,18 @@ import '../lib/cron';
import './globals.css';
+import { getConfig } from '@/lib/config';
+
import AuthProvider from '../components/AuthProvider';
import { SiteProvider } from '../components/SiteProvider';
import { ThemeProvider } from '../components/ThemeProvider';
const inter = Inter({ subsets: ['latin'] });
+const config = getConfig();
+
export const metadata: Metadata = {
- title: process.env.SITE_NAME || 'MoonTV',
+ title: config.SiteConfig.SiteName,
description: '影视聚合',
manifest: '/manifest.json',
};
@@ -29,15 +33,14 @@ export default function RootLayout({
}: {
children: React.ReactNode;
}) {
- const siteName = process.env.SITE_NAME || 'MoonTV';
- const announcement =
- process.env.ANNOUNCEMENT ||
- '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
+ const siteName = config.SiteConfig.SiteName;
+ const announcement = config.SiteConfig.Announcement;
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
const runtimeConfig = {
STORAGE_TYPE: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
- ENABLE_REGISTER: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
+ ENABLE_REGISTER: config.UserConfig.AllowRegister,
+ AGGREGATE_SEARCH_RESULT: config.SiteConfig.SearchResultDefaultAggregate,
};
return (
diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx
index 1449f49..80325af 100644
--- a/src/app/search/page.tsx
+++ b/src/app/search/page.tsx
@@ -1,4 +1,4 @@
-/* eslint-disable react-hooks/exhaustive-deps */
+/* eslint-disable react-hooks/exhaustive-deps, @typescript-eslint/no-explicit-any */
'use client';
import { Search, X } from 'lucide-react';
@@ -29,11 +29,12 @@ function SearchPageClient() {
const [searchResults, setSearchResults] = useState([]);
// 视图模式:聚合(agg) 或 全部(all),默认值由环境变量 NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT 决定
+ const defaultAggregate =
+ typeof window !== 'undefined' &&
+ Boolean((window as any).RUNTIME_CONFIG?.AGGREGATE_SEARCH_RESULT);
+
const [viewMode, setViewMode] = useState<'agg' | 'all'>(
- process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT === 'false' ||
- process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT === '0'
- ? 'all'
- : 'agg'
+ defaultAggregate ? 'agg' : 'all'
);
// 聚合后的结果(按标题和年份分组)
diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts
new file mode 100644
index 0000000..f761e92
--- /dev/null
+++ b/src/lib/admin.types.ts
@@ -0,0 +1,30 @@
+export interface AdminConfig {
+ SiteConfig: {
+ SiteName: string;
+ Announcement: string;
+ SearchDownstreamMaxPage: number;
+ SiteInterfaceCacheTime: number;
+ SearchResultDefaultAggregate: boolean;
+ };
+ UserConfig: {
+ AllowRegister: boolean;
+ Users: {
+ username: string;
+ role: 'user' | 'admin' | 'owner';
+ banned?: boolean;
+ }[];
+ };
+ SourceConfig: {
+ key: string;
+ name: string;
+ api: string;
+ detail?: string;
+ from: 'config' | 'custom';
+ disabled?: boolean;
+ }[];
+}
+
+export interface AdminConfigResult {
+ Role: 'owner' | 'admin';
+ Config: AdminConfig;
+}
diff --git a/src/lib/config.ts b/src/lib/config.ts
index f10032c..f5eca3e 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -1,5 +1,8 @@
-/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
+/* eslint-disable @typescript-eslint/no-explicit-any, no-console, @typescript-eslint/no-non-null-assertion */
+import { getStorage } from '@/lib/db';
+
+import { AdminConfig } from './admin.types';
import runtimeConfig from './runtime';
export interface ApiSite {
@@ -9,23 +12,11 @@ export interface ApiSite {
detail?: string;
}
-export interface StorageConfig {
- type: 'localstorage' | 'database';
- database?: {
- host?: string;
- port?: number;
- username?: string;
- password?: string;
- database?: string;
- };
-}
-
-export interface Config {
+interface ConfigFileStruct {
cache_time?: number;
api_site: {
[key: string]: ApiSite;
};
- storage?: StorageConfig;
}
export const API_CONFIG = {
@@ -49,7 +40,8 @@ export const API_CONFIG = {
};
// 在模块加载时根据环境决定配置来源
-let cachedConfig: Config;
+let fileConfig: ConfigFileStruct;
+let cachedConfig: AdminConfig;
if (process.env.DOCKER_ENV === 'true') {
// 这里用 eval("require") 避开静态分析,防止 Edge Runtime 打包时报 "Can't resolve 'fs'"
@@ -61,26 +53,171 @@ if (process.env.DOCKER_ENV === 'true') {
const configPath = path.join(process.cwd(), 'config.json');
const raw = fs.readFileSync(configPath, 'utf-8');
- cachedConfig = JSON.parse(raw) as Config;
+ fileConfig = JSON.parse(raw) as ConfigFileStruct;
console.log('load dynamic config success');
} else {
// 默认使用编译时生成的配置
- cachedConfig = runtimeConfig as unknown as Config;
+ fileConfig = runtimeConfig as unknown as ConfigFileStruct;
+}
+const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
+if (storageType !== 'localstorage') {
+ // 数据库存储,读取并补全管理员配置
+ const storage = getStorage();
+ (async () => {
+ try {
+ // 尝试从数据库获取管理员配置
+ let adminConfig: AdminConfig | null = null;
+ if (storage && typeof (storage as any).getAdminConfig === 'function') {
+ adminConfig = await (storage as any).getAdminConfig();
+ }
+
+ // 新增:获取所有用户名,用于补全 Users
+ let userNames: string[] = [];
+ if (storage && typeof (storage as any).getAllUsers === 'function') {
+ try {
+ userNames = await (storage as any).getAllUsers();
+ } catch (e) {
+ console.error('获取用户列表失败:', e);
+ }
+ }
+
+ const apiSiteEntries = Object.entries(fileConfig.api_site);
+
+ if (adminConfig) {
+ // 补全 SourceConfig
+ const existed = new Set(
+ (adminConfig.SourceConfig || []).map((s) => s.key)
+ );
+ apiSiteEntries.forEach(([key, site]) => {
+ if (!existed.has(key)) {
+ adminConfig!.SourceConfig.push({
+ key,
+ name: site.name,
+ api: site.api,
+ detail: site.detail,
+ from: 'config',
+ disabled: false,
+ });
+ }
+ });
+ const existedUsers = new Set(
+ (adminConfig.UserConfig.Users || []).map((u) => u.username)
+ );
+ userNames.forEach((uname) => {
+ if (!existedUsers.has(uname)) {
+ adminConfig!.UserConfig.Users.push({
+ username: uname,
+ role: 'user',
+ });
+ }
+ });
+ // 管理员
+ const adminUser = process.env.USERNAME;
+ if (adminUser) {
+ adminConfig!.UserConfig.Users = adminConfig!.UserConfig.Users.filter(
+ (u) => u.username !== adminUser
+ );
+ adminConfig!.UserConfig.Users.unshift({
+ username: adminUser,
+ role: 'owner',
+ });
+ }
+ } else {
+ // 数据库中没有配置,创建新的管理员配置
+ let allUsers = userNames.map((uname) => ({
+ username: uname,
+ role: 'user',
+ }));
+ const adminUser = process.env.USERNAME;
+ if (adminUser) {
+ allUsers = allUsers.filter((u) => u.username !== adminUser);
+ allUsers.unshift({
+ username: adminUser,
+ role: 'owner',
+ });
+ }
+ adminConfig = {
+ SiteConfig: {
+ SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV',
+ Announcement:
+ process.env.NEXT_PUBLIC_ANNOUNCEMENT ||
+ '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
+ SearchDownstreamMaxPage:
+ Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
+ SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
+ SearchResultDefaultAggregate:
+ process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false',
+ },
+ UserConfig: {
+ AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
+ Users: allUsers as any,
+ },
+ SourceConfig: apiSiteEntries.map(([key, site]) => ({
+ key,
+ name: site.name,
+ api: site.api,
+ detail: site.detail,
+ from: 'config',
+ disabled: false,
+ })),
+ };
+ }
+
+ // 写回数据库(更新/创建)
+ if (storage && typeof (storage as any).setAdminConfig === 'function') {
+ await (storage as any).setAdminConfig(adminConfig);
+ }
+
+ // 更新缓存
+ cachedConfig = adminConfig;
+ } catch (err) {
+ console.error('加载管理员配置失败:', err);
+ }
+ })();
+} else {
+ // 本地存储直接使用文件配置
+ cachedConfig = {
+ SiteConfig: {
+ SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV',
+ Announcement:
+ process.env.NEXT_PUBLIC_ANNOUNCEMENT ||
+ '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
+ SearchDownstreamMaxPage:
+ Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
+ SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
+ SearchResultDefaultAggregate:
+ process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false',
+ },
+ UserConfig: {
+ AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
+ Users: [],
+ },
+ SourceConfig: Object.entries(fileConfig.api_site).map(([key, site]) => ({
+ key,
+ name: site.name,
+ api: site.api,
+ detail: site.detail,
+ from: 'config',
+ disabled: false,
+ })),
+ } as AdminConfig;
}
-export function getConfig(): Config {
+export function getConfig(): AdminConfig {
return cachedConfig;
}
export function getCacheTime(): number {
const config = getConfig();
- return config.cache_time || 300; // 默认5分钟缓存
+ return config.SiteConfig.SiteInterfaceCacheTime || 7200;
}
-export function getApiSites(): ApiSite[] {
+export function getAvailableApiSites(): ApiSite[] {
const config = getConfig();
- return Object.entries(config.api_site).map(([key, site]) => ({
- ...site,
- key,
+ return config.SourceConfig.filter((s) => !s.disabled).map((s) => ({
+ key: s.key,
+ name: s.name,
+ api: s.api,
+ detail: s.detail,
}));
}
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 3ce966a..00c0939 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -1,5 +1,7 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
+import { AdminConfig } from './admin.types';
+
// storage type 常量: 'localstorage' | 'database',默认 'localstorage'
const STORAGE_TYPE =
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
@@ -64,6 +66,10 @@ export interface IStorage {
// 用户列表
getAllUsers(): Promise;
+
+ // 管理员配置相关
+ getAdminConfig(): Promise;
+ setAdminConfig(config: AdminConfig): Promise;
}
// ---------------- Redis 实现 ----------------
@@ -221,6 +227,20 @@ class RedisStorage implements IStorage {
})
.filter((u): u is string => typeof u === 'string');
}
+
+ // ---------- 管理员配置 ----------
+ private adminConfigKey() {
+ return 'admin:config';
+ }
+
+ async getAdminConfig(): Promise {
+ const val = await this.client.get(this.adminConfigKey());
+ return val ? (JSON.parse(val) as AdminConfig) : null;
+ }
+
+ async setAdminConfig(config: AdminConfig): Promise {
+ await this.client.set(this.adminConfigKey(), JSON.stringify(config));
+ }
}
// 创建存储实例
@@ -394,6 +414,20 @@ export class DbManager {
}
return [];
}
+
+ // ---------- 管理员配置 ----------
+ async getAdminConfig(): Promise {
+ if (typeof (this.storage as any).getAdminConfig === 'function') {
+ return (this.storage as any).getAdminConfig();
+ }
+ return null;
+ }
+
+ async saveAdminConfig(config: AdminConfig): Promise {
+ if (typeof (this.storage as any).setAdminConfig === 'function') {
+ await (this.storage as any).setAdminConfig(config);
+ }
+ }
}
// 导出默认实例
diff --git a/src/lib/downstream.ts b/src/lib/downstream.ts
index 4d6c9ed..fbb70fa 100644
--- a/src/lib/downstream.ts
+++ b/src/lib/downstream.ts
@@ -1,10 +1,9 @@
-import { API_CONFIG, ApiSite } from '@/lib/config';
+import { API_CONFIG, ApiSite, getConfig } from '@/lib/config';
import { SearchResult, VideoDetail } from '@/lib/types';
import { cleanHtmlTags } from '@/lib/utils';
-// 根据环境变量决定最大搜索页数,默认 5
-const MAX_SEARCH_PAGES: number =
- Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5;
+const config = getConfig();
+const MAX_SEARCH_PAGES: number = config.SiteConfig.SearchDownstreamMaxPage;
interface ApiSearchItem {
vod_id: string;
diff --git a/src/lib/fetchVideoDetail.ts b/src/lib/fetchVideoDetail.ts
index d3131ae..2dbd075 100644
--- a/src/lib/fetchVideoDetail.ts
+++ b/src/lib/fetchVideoDetail.ts
@@ -1,4 +1,4 @@
-import { getApiSites } from '@/lib/config';
+import { getAvailableApiSites } from '@/lib/config';
import { SearchResult } from '@/lib/types';
import { getDetailFromApi, searchFromApi } from './downstream';
@@ -34,7 +34,7 @@ export async function fetchVideoDetail({
fallbackTitle = '',
}: FetchVideoDetailOptions): Promise {
// 优先通过搜索接口查找精确匹配
- const apiSites = getApiSites();
+ const apiSites = getAvailableApiSites();
const apiSite = apiSites.find((site) => site.key === source);
if (!apiSite) {
throw new Error('无效的API来源');
diff --git a/src/lib/runtime.ts b/src/lib/runtime.ts
index b18368e..68e754e 100644
--- a/src/lib/runtime.ts
+++ b/src/lib/runtime.ts
@@ -4,19 +4,19 @@
export const config = {
cache_time: 7200,
api_site: {
- dyttzy: {
- api: 'http://caiji.dyttzyapi.com/api.php/provide/vod',
- name: '电影天堂资源',
- detail: 'http://caiji.dyttzyapi.com',
+ heimuer: {
+ api: 'https://json.heimuer.xyz/api.php/provide/vod',
+ name: '黑木耳',
+ detail: 'https://heimuer.tv',
},
ruyi: {
api: 'https://cj.rycjapi.com/api.php/provide/vod',
name: '如意资源',
},
- heimuer: {
- api: 'https://json.heimuer.xyz/api.php/provide/vod',
- name: '黑木耳',
- detail: 'https://heimuer.tv',
+ dyttzy: {
+ api: 'http://caiji.dyttzyapi.com/api.php/provide/vod',
+ name: '电影天堂资源',
+ detail: 'http://caiji.dyttzyapi.com',
},
bfzy: {
api: 'https://bfzyapi.com/api.php/provide/vod',