diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..2d1feac
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,2 @@
+.env
+.env*.local
\ No newline at end of file
diff --git a/README.md b/README.md
index afea800..2bebcba 100644
--- a/README.md
+++ b/README.md
@@ -207,20 +207,20 @@ networks:
## 环境变量
-| 变量 | 说明 | 可选值 | 默认值 |
-| --------------------------- | ----------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
-| USERNAME | redis 部署时的管理员账号 | 任意字符串 | (空) |
-| PASSWORD | 默认部署时为唯一访问密码,redis 部署时为管理员密码 | 任意字符串 | (空) |
-| SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
-| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
-| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | localstorage、redis、d1、upstash | localstorage |
-| REDIS_URL | redis 连接 url,若 NEXT_PUBLIC_STORAGE_TYPE 为 redis 则必填 | 连接 url | 空 |
-| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
-| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
-| NEXT_PUBLIC_ENABLE_REGISTER | 是否开放注册,仅在非 localstorage 部署时生效 | true / false | false |
-| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
-| NEXT_PUBLIC_IMAGE_PROXY | 默认的浏览器端图片代理 | url prefix | (空) |
-| NEXT_PUBLIC_DOUBAN_PROXY | 默认的浏览器端豆瓣数据代理 | url prefix | (空) |
+| 变量 | 说明 | 可选值 | 默认值 |
+| --------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
+| USERNAME | 非 localstorage 部署时的管理员账号 | 任意字符串 | (空) |
+| PASSWORD | 非 localstorage 部署时为管理员密码 | 任意字符串 | (空) |
+| SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
+| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
+| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | localstorage、redis、d1、upstash | localstorage |
+| REDIS_URL | redis 连接 url | 连接 url | 空 |
+| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
+| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
+| NEXT_PUBLIC_ENABLE_REGISTER | 是否开放注册,仅在非 localstorage 部署时生效 | true / false | false |
+| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
+| NEXT_PUBLIC_IMAGE_PROXY | 默认的浏览器端图片代理 | url prefix | (空) |
+| NEXT_PUBLIC_DOUBAN_PROXY | 默认的浏览器端豆瓣数据代理 | url prefix | (空) |
## 配置说明
@@ -236,7 +236,14 @@ networks:
"detail": "http://caiji.dyttzyapi.com"
}
// ...更多站点
- }
+ },
+ "custom_category": [
+ {
+ "name": "华语",
+ "type": "movie",
+ "query": "华语"
+ }
+ ]
}
```
@@ -246,6 +253,17 @@ networks:
- `api`:资源站提供的 `vod` JSON API 根地址。
- `name`:在人机界面中展示的名称。
- `detail`:(可选)部分无法通过 API 获取剧集详情的站点,需要提供网页详情根 URL,用于爬取。
+- `custom_category`:自定义分类配置,用于在导航中添加个性化的影视分类。以 type + query 作为唯一标识。支持以下字段:
+ - `name`:分类显示名称(可选,如不提供则使用 query 作为显示名)
+ - `type`:分类类型,支持 `movie`(电影)或 `tv`(电视剧)
+ - `query`:搜索关键词,用于在豆瓣 API 中搜索相关内容
+
+custom_category 支持的自定义分类已知如下:
+
+- movie:热门、最新、经典、豆瓣高分、冷门佳片、华语、欧美、韩国、日本、动作、喜剧、爱情、科幻、悬疑、恐怖治愈
+- tv:热门、美剧、英剧、韩剧、日剧、国产剧、港剧、日本动画、综艺、纪录片
+
+也可输入如 "哈利波特" 效果等同于豆瓣搜索
MoonTV 支持标准的苹果 CMS V10 API 格式。
diff --git a/VERSION.txt b/VERSION.txt
index 4e4540e..4ae5835 100644
--- a/VERSION.txt
+++ b/VERSION.txt
@@ -1 +1 @@
-20250730221204
\ No newline at end of file
+20250731010759
\ No newline at end of file
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 84aa149..83d7467 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -21,7 +21,14 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
-import { ChevronDown, ChevronUp, Settings, Users, Video } from 'lucide-react';
+import {
+ ChevronDown,
+ ChevronUp,
+ FolderOpen,
+ Settings,
+ Users,
+ Video,
+} from 'lucide-react';
import { GripVertical } from 'lucide-react';
import { Suspense, useCallback, useEffect, useState } from 'react';
import Swal from 'sweetalert2';
@@ -64,6 +71,15 @@ interface DataSource {
from: 'config' | 'custom';
}
+// 自定义分类数据类型
+interface CustomCategory {
+ name?: string;
+ type: 'movie' | 'tv';
+ query: string;
+ disabled?: boolean;
+ from: 'config' | 'custom';
+}
+
// 可折叠标签组件
interface CollapsibleTabProps {
title: string;
@@ -955,6 +971,382 @@ const VideoSourceConfig = ({
);
};
+// 分类配置组件
+const CategoryConfig = ({
+ config,
+ refreshConfig,
+}: {
+ config: AdminConfig | null;
+ refreshConfig: () => Promise;
+}) => {
+ const [categories, setCategories] = useState([]);
+ const [showAddForm, setShowAddForm] = useState(false);
+ const [orderChanged, setOrderChanged] = useState(false);
+ const [newCategory, setNewCategory] = useState({
+ name: '',
+ type: 'movie',
+ query: '',
+ disabled: false,
+ from: 'config',
+ });
+
+ // 检测存储类型是否为 d1 或 upstash
+ const isD1Storage =
+ typeof window !== 'undefined' &&
+ (window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'd1';
+ const isUpstashStorage =
+ typeof window !== 'undefined' &&
+ (window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash';
+
+ // dnd-kit 传感器
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 5, // 轻微位移即可触发
+ },
+ }),
+ useSensor(TouchSensor, {
+ activationConstraint: {
+ delay: 150, // 长按 150ms 后触发,避免与滚动冲突
+ tolerance: 5,
+ },
+ })
+ );
+
+ // 初始化
+ useEffect(() => {
+ if (config?.CustomCategories) {
+ setCategories(config.CustomCategories);
+ // 进入时重置 orderChanged
+ setOrderChanged(false);
+ }
+ }, [config]);
+
+ // 通用 API 请求
+ const callCategoryApi = async (body: Record) => {
+ try {
+ const resp = await fetch('/api/admin/category', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...body }),
+ });
+
+ if (!resp.ok) {
+ const data = await resp.json().catch(() => ({}));
+ throw new Error(data.error || `操作失败: ${resp.status}`);
+ }
+
+ // 成功后刷新配置
+ await refreshConfig();
+ } catch (err) {
+ showError(err instanceof Error ? err.message : '操作失败');
+ throw err; // 向上抛出方便调用处判断
+ }
+ };
+
+ const handleToggleEnable = (query: string, type: 'movie' | 'tv') => {
+ const target = categories.find((c) => c.query === query && c.type === type);
+ if (!target) return;
+ const action = target.disabled ? 'enable' : 'disable';
+ callCategoryApi({ action, query, type }).catch(() => {
+ console.error('操作失败', action, query, type);
+ });
+ };
+
+ const handleDelete = (query: string, type: 'movie' | 'tv') => {
+ callCategoryApi({ action: 'delete', query, type }).catch(() => {
+ console.error('操作失败', 'delete', query, type);
+ });
+ };
+
+ const handleAddCategory = () => {
+ if (!newCategory.name || !newCategory.query) return;
+ callCategoryApi({
+ action: 'add',
+ name: newCategory.name,
+ type: newCategory.type,
+ query: newCategory.query,
+ })
+ .then(() => {
+ setNewCategory({
+ name: '',
+ type: 'movie',
+ query: '',
+ disabled: false,
+ from: 'custom',
+ });
+ setShowAddForm(false);
+ })
+ .catch(() => {
+ console.error('操作失败', 'add', newCategory);
+ });
+ };
+
+ const handleDragEnd = (event: any) => {
+ if (isD1Storage || isUpstashStorage) return;
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+ const oldIndex = categories.findIndex(
+ (c) => `${c.query}:${c.type}` === active.id
+ );
+ const newIndex = categories.findIndex(
+ (c) => `${c.query}:${c.type}` === over.id
+ );
+ setCategories((prev) => arrayMove(prev, oldIndex, newIndex));
+ setOrderChanged(true);
+ };
+
+ const handleSaveOrder = () => {
+ const order = categories.map((c) => `${c.query}:${c.type}`);
+ callCategoryApi({ action: 'sort', order })
+ .then(() => {
+ setOrderChanged(false);
+ })
+ .catch(() => {
+ console.error('操作失败', 'sort', order);
+ });
+ };
+
+ // 可拖拽行封装 (dnd-kit)
+ const DraggableRow = ({ category }: { category: CustomCategory }) => {
+ const { attributes, listeners, setNodeRef, transform, transition } =
+ useSortable({ id: `${category.query}:${category.type}` });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ } as React.CSSProperties;
+
+ return (
+
+ |
+
+ |
+
+ {category.name || '-'}
+ |
+
+
+ {category.type === 'movie' ? '电影' : '电视剧'}
+
+ |
+
+ {category.query}
+ |
+
+
+ {!category.disabled ? '启用中' : '已禁用'}
+
+ |
+
+
+ {category.from !== 'config' && !isD1Storage && !isUpstashStorage && (
+
+ )}
+ |
+
+ );
+ };
+
+ if (!config) {
+ return (
+
+ 加载中...
+
+ );
+ }
+
+ return (
+
+ {/* 添加分类表单 */}
+
+
+ 自定义分类列表
+ {isD1Storage && (
+
+ (D1 环境下请通过配置文件修改)
+
+ )}
+ {isUpstashStorage && (
+
+ (Upstash 环境下请通过配置文件修改)
+
+ )}
+
+
+
+
+ {showAddForm && !isD1Storage && !isUpstashStorage && (
+
+ )}
+
+ {/* 分类表格 */}
+
+
+
+
+ |
+
+ 分类名称
+ |
+
+ 类型
+ |
+
+ 搜索关键词
+ |
+
+ 状态
+ |
+
+ 操作
+ |
+
+
+
+ `${c.query}:${c.type}`)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {categories.map((category) => (
+
+ ))}
+
+
+
+
+
+
+ {/* 保存排序按钮 */}
+ {orderChanged && !isD1Storage && !isUpstashStorage && (
+
+
+
+ )}
+
+ );
+};
+
// 新增站点配置组件
const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
const [siteSettings, setSiteSettings] = useState({
@@ -1245,6 +1637,7 @@ function AdminPageClient() {
userConfig: false,
videoSource: false,
siteConfig: false,
+ categoryConfig: false,
});
// 获取管理员配置
@@ -1401,6 +1794,21 @@ function AdminPageClient() {
>
+
+ {/* 分类配置标签 */}
+
+ }
+ isExpanded={expandedTabs.categoryConfig}
+ onToggle={() => toggleTab('categoryConfig')}
+ >
+
+
diff --git a/src/app/api/admin/category/route.ts b/src/app/api/admin/category/route.ts
new file mode 100644
index 0000000..2c1a0d7
--- /dev/null
+++ b/src/app/api/admin/category/route.ts
@@ -0,0 +1,209 @@
+/* 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 { getStorage } from '@/lib/db';
+import { IStorage } from '@/lib/types';
+
+export const runtime = 'edge';
+
+// 支持的操作类型
+type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort';
+
+interface BaseBody {
+ action?: Action;
+}
+
+export async function POST(request: NextRequest) {
+ const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
+ if (storageType === 'localstorage') {
+ return NextResponse.json(
+ {
+ error: '不支持本地存储进行管理员配置',
+ },
+ { status: 400 }
+ );
+ }
+ if (storageType === 'd1' || storageType === 'upstash') {
+ return NextResponse.json(
+ {
+ error: 'D1 和 Upstash 实例请通过配置文件调整',
+ },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const body = (await request.json()) as BaseBody & Record;
+ const { action } = body;
+
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo || !authInfo.username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+ const username = authInfo.username;
+
+ // 基础校验
+ const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort'];
+ if (!username || !action || !ACTIONS.includes(action)) {
+ return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
+ }
+
+ // 获取配置与存储
+ const adminConfig = await getConfig();
+ const storage: IStorage | null = getStorage();
+
+ // 权限与身份校验
+ if (username !== process.env.USERNAME) {
+ const userEntry = adminConfig.UserConfig.Users.find(
+ (u) => u.username === username
+ );
+ if (!userEntry || userEntry.role !== 'admin') {
+ return NextResponse.json({ error: '权限不足' }, { status: 401 });
+ }
+ }
+
+ switch (action) {
+ case 'add': {
+ const { name, type, query } = body as {
+ name?: string;
+ type?: 'movie' | 'tv';
+ query?: string;
+ };
+ if (!name || !type || !query) {
+ return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
+ }
+ // 检查是否已存在相同的查询和类型组合
+ if (
+ adminConfig.CustomCategories.some(
+ (c) => c.query === query && c.type === type
+ )
+ ) {
+ return NextResponse.json({ error: '该分类已存在' }, { status: 400 });
+ }
+ adminConfig.CustomCategories.push({
+ name,
+ type,
+ query,
+ from: 'custom',
+ disabled: false,
+ });
+ break;
+ }
+ case 'disable': {
+ const { query, type } = body as {
+ query?: string;
+ type?: 'movie' | 'tv';
+ };
+ if (!query || !type)
+ return NextResponse.json(
+ { error: '缺少 query 或 type 参数' },
+ { status: 400 }
+ );
+ const entry = adminConfig.CustomCategories.find(
+ (c) => c.query === query && c.type === type
+ );
+ if (!entry)
+ return NextResponse.json({ error: '分类不存在' }, { status: 404 });
+ entry.disabled = true;
+ break;
+ }
+ case 'enable': {
+ const { query, type } = body as {
+ query?: string;
+ type?: 'movie' | 'tv';
+ };
+ if (!query || !type)
+ return NextResponse.json(
+ { error: '缺少 query 或 type 参数' },
+ { status: 400 }
+ );
+ const entry = adminConfig.CustomCategories.find(
+ (c) => c.query === query && c.type === type
+ );
+ if (!entry)
+ return NextResponse.json({ error: '分类不存在' }, { status: 404 });
+ entry.disabled = false;
+ break;
+ }
+ case 'delete': {
+ const { query, type } = body as {
+ query?: string;
+ type?: 'movie' | 'tv';
+ };
+ if (!query || !type)
+ return NextResponse.json(
+ { error: '缺少 query 或 type 参数' },
+ { status: 400 }
+ );
+ const idx = adminConfig.CustomCategories.findIndex(
+ (c) => c.query === query && c.type === type
+ );
+ if (idx === -1)
+ return NextResponse.json({ error: '分类不存在' }, { status: 404 });
+ const entry = adminConfig.CustomCategories[idx];
+ if (entry.from === 'config') {
+ return NextResponse.json(
+ { error: '该分类不可删除' },
+ { status: 400 }
+ );
+ }
+ adminConfig.CustomCategories.splice(idx, 1);
+ break;
+ }
+ case 'sort': {
+ const { order } = body as { order?: string[] };
+ if (!Array.isArray(order)) {
+ return NextResponse.json(
+ { error: '排序列表格式错误' },
+ { status: 400 }
+ );
+ }
+ const map = new Map(
+ adminConfig.CustomCategories.map((c) => [`${c.query}:${c.type}`, c])
+ );
+ const newList: typeof adminConfig.CustomCategories = [];
+ order.forEach((key) => {
+ const item = map.get(key);
+ if (item) {
+ newList.push(item);
+ map.delete(key);
+ }
+ });
+ // 未在 order 中的保持原顺序
+ adminConfig.CustomCategories.forEach((item) => {
+ if (map.has(`${item.query}:${item.type}`)) newList.push(item);
+ });
+ adminConfig.CustomCategories = newList;
+ break;
+ }
+ default:
+ return NextResponse.json({ error: '未知操作' }, { status: 400 });
+ }
+
+ // 持久化到存储
+ if (storage && typeof (storage as any).setAdminConfig === 'function') {
+ await (storage as any).setAdminConfig(adminConfig);
+ }
+
+ return NextResponse.json(
+ { ok: true },
+ {
+ headers: {
+ 'Cache-Control': 'no-store',
+ },
+ }
+ );
+ } catch (error) {
+ console.error('分类管理操作失败:', error);
+ return NextResponse.json(
+ {
+ error: '分类管理操作失败',
+ details: (error as Error).message,
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/douban/page.tsx b/src/app/douban/page.tsx
index 8f5c18e..8e99d6e 100644
--- a/src/app/douban/page.tsx
+++ b/src/app/douban/page.tsx
@@ -6,8 +6,8 @@ import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
-import { getDoubanCategories } from '@/lib/douban.client';
-import { DoubanItem } from '@/lib/types';
+import { getDoubanCategories, getDoubanList } from '@/lib/douban.client';
+import { DoubanItem, DoubanResult } from '@/lib/types';
import DoubanCardSkeleton from '@/components/DoubanCardSkeleton';
import DoubanSelector from '@/components/DoubanSelector';
@@ -27,6 +27,9 @@ function DoubanPageClient() {
const debounceTimeoutRef = useRef(null);
const type = searchParams.get('type') || 'movie';
+ const tag = searchParams.get('tag') || '';
+ const custom = searchParams.get('custom') === 'true';
+ const name = searchParams.get('name') || '';
// 选择器状态 - 完全独立,不依赖URL参数
const [primarySelection, setPrimarySelection] = useState(() => {
@@ -53,7 +56,7 @@ function DoubanPageClient() {
useEffect(() => {
setSelectorsReady(false);
setLoading(true); // 立即显示loading状态
- }, [type]);
+ }, [type, tag]);
// 当type变化时重置选择器状态
useEffect(() => {
@@ -78,7 +81,7 @@ function DoubanPageClient() {
}, 50);
return () => clearTimeout(timer);
- }, [type]);
+ }, [type, tag, custom]);
// 生成骨架屏数据
const skeletonData = Array.from({ length: 25 }, (_, index) => index);
@@ -113,7 +116,17 @@ function DoubanPageClient() {
const loadInitialData = useCallback(async () => {
try {
setLoading(true);
- const data = await getDoubanCategories(getRequestParams(0));
+ let data: DoubanResult;
+ if (custom) {
+ data = await getDoubanList({
+ tag,
+ type,
+ pageLimit: 25,
+ pageStart: 0,
+ });
+ } else {
+ data = await getDoubanCategories(getRequestParams(0));
+ }
if (data.code === 200) {
setDoubanData(data.list);
@@ -125,12 +138,19 @@ function DoubanPageClient() {
} catch (err) {
console.error(err);
}
- }, [type, primarySelection, secondarySelection, getRequestParams]);
+ }, [
+ type,
+ tag,
+ custom,
+ primarySelection,
+ secondarySelection,
+ getRequestParams,
+ ]);
// 只在选择器准备好后才加载数据
useEffect(() => {
// 只有在选择器准备好时才开始加载
- if (!selectorsReady) {
+ if (!selectorsReady && !custom) {
return;
}
@@ -159,6 +179,8 @@ function DoubanPageClient() {
}, [
selectorsReady,
type,
+ tag,
+ custom,
primarySelection,
secondarySelection,
loadInitialData,
@@ -171,9 +193,19 @@ function DoubanPageClient() {
try {
setIsLoadingMore(true);
- const data = await getDoubanCategories(
- getRequestParams(currentPage * 25)
- );
+ let data: DoubanResult;
+ if (custom) {
+ data = await getDoubanList({
+ tag,
+ type,
+ pageLimit: 25,
+ pageStart: currentPage * 25,
+ });
+ } else {
+ data = await getDoubanCategories(
+ getRequestParams(currentPage * 25)
+ );
+ }
if (data.code === 200) {
setDoubanData((prev) => [...prev, ...data.list]);
@@ -190,7 +222,7 @@ function DoubanPageClient() {
fetchMoreData();
}
- }, [currentPage, type, primarySelection, secondarySelection]);
+ }, [currentPage, type, tag, custom, primarySelection, secondarySelection]);
// 设置滚动监听
useEffect(() => {
@@ -248,12 +280,19 @@ function DoubanPageClient() {
const getPageTitle = () => {
// 根据 type 生成标题
+ if (name) {
+ return name;
+ }
+ if (custom) {
+ return tag;
+ }
return type === 'movie' ? '电影' : type === 'tv' ? '电视剧' : '综艺';
};
const getActivePath = () => {
const params = new URLSearchParams();
if (type) params.set('type', type);
+ if (tag) params.set('tag', tag);
const queryString = params.toString();
const activePath = `/douban${queryString ? `?${queryString}` : ''}`;
@@ -275,23 +314,25 @@ function DoubanPageClient() {
- {/* 选择器组件 */}
-
-
-
+ {/* 选择器组件 - custom 模式下不显示 */}
+ {!custom && (
+
+
+
+ )}
{/* 内容展示区域 */}
{/* 内容网格 */}
- {loading || !selectorsReady
+ {loading || (!selectorsReady && !custom)
? // 显示骨架屏
skeletonData.map((index) =>
)
: // 显示实际数据
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 03c7c0f..bc6cbf2 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,3 +1,5 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
@@ -5,6 +7,7 @@ import './globals.css';
import 'sweetalert2/dist/sweetalert2.min.css';
import { getConfig } from '@/lib/config';
+import RuntimeConfig from '@/lib/runtime';
import { SiteProvider } from '../components/SiteProvider';
import { ThemeProvider } from '../components/ThemeProvider';
@@ -46,6 +49,12 @@ export default async function RootLayout({
let enableRegister = process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
let imageProxy = process.env.NEXT_PUBLIC_IMAGE_PROXY || '';
let doubanProxy = process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
+ let customCategories =
+ (RuntimeConfig as any).custom_category?.map((category: any) => ({
+ name: 'name' in category ? category.name : '',
+ type: category.type,
+ query: category.query,
+ })) || ([] as Array<{ name: string; type: 'movie' | 'tv'; query: string }>);
if (
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1' &&
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'upstash'
@@ -56,6 +65,13 @@ export default async function RootLayout({
enableRegister = config.UserConfig.AllowRegister;
imageProxy = config.SiteConfig.ImageProxy;
doubanProxy = config.SiteConfig.DoubanProxy;
+ customCategories = config.CustomCategories.filter(
+ (category) => !category.disabled
+ ).map((category) => ({
+ name: category.name || '',
+ type: category.type,
+ query: category.query,
+ }));
}
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
@@ -64,6 +80,7 @@ export default async function RootLayout({
ENABLE_REGISTER: enableRegister,
IMAGE_PROXY: imageProxy,
DOUBAN_PROXY: doubanProxy,
+ CUSTOM_CATEGORIES: customCategories,
};
return (
diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx
index 91a2905..8a03e2b 100644
--- a/src/components/MobileBottomNav.tsx
+++ b/src/components/MobileBottomNav.tsx
@@ -1,8 +1,11 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
'use client';
-import { Clover, Film, Home, Search, Tv } from 'lucide-react';
+import { Clover, Film, Home, Search, Star, Tv } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
+import { useEffect, useState } from 'react';
interface MobileBottomNavProps {
/**
@@ -17,7 +20,7 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
// 当前激活路径:优先使用传入的 activePath,否则回退到浏览器地址
const currentActive = activePath ?? pathname;
- const navItems = [
+ const [navItems, setNavItems] = useState([
{ icon: Home, label: '首页', href: '/' },
{ icon: Search, label: '搜索', href: '/search' },
{
@@ -35,10 +38,27 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
label: '综艺',
href: '/douban?type=show',
},
- ];
+ ]);
+
+ useEffect(() => {
+ const runtimeConfig = (window as any).RUNTIME_CONFIG;
+ if (runtimeConfig?.CUSTOM_CATEGORIES) {
+ setNavItems((prevItems) => [
+ ...prevItems,
+ ...runtimeConfig.CUSTOM_CATEGORIES.map((category: any) => ({
+ icon: Star,
+ label: category.name || category.query,
+ href: `/douban?type=${category.type}&tag=${category.query}${
+ category.name ? `&name=${category.name}` : ''
+ }&custom=true`,
+ })),
+ ]);
+ }
+ }, []);
const isActive = (href: string) => {
const typeMatch = href.match(/type=([^&]+)/)?.[1];
+ const tagMatch = href.match(/tag=([^&]+)/)?.[1];
// 解码URL以进行正确的比较
const decodedActive = decodeURIComponent(currentActive);
@@ -47,7 +67,9 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
return (
decodedActive === decodedItemHref ||
(decodedActive.startsWith('/douban') &&
- decodedActive.includes(`type=${typeMatch}`))
+ decodedActive.includes(`type=${typeMatch}`) &&
+ tagMatch &&
+ decodedActive.includes(`tag=${tagMatch}`))
);
};
@@ -61,11 +83,11 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
minHeight: 'calc(3.5rem + env(safe-area-inset-bottom))',
}}
>
-
+
{navItems.map((item) => {
const active = isActive(item.href);
return (
- -
+
-
{
isCollapsed,
};
- const menuItems = [
+ const [menuItems, setMenuItems] = useState([
{
icon: Film,
label: '电影',
@@ -138,7 +140,23 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
label: '综艺',
href: '/douban?type=show',
},
- ];
+ ]);
+
+ useEffect(() => {
+ const runtimeConfig = (window as any).RUNTIME_CONFIG;
+ if (runtimeConfig?.CUSTOM_CATEGORIES) {
+ setMenuItems((prevItems) => [
+ ...prevItems,
+ ...runtimeConfig.CUSTOM_CATEGORIES.map((category: any) => ({
+ icon: Star,
+ label: category.name || category.query,
+ href: `/douban?type=${category.type}&tag=${category.query}${
+ category.name ? `&name=${category.name}` : ''
+ }&custom=true`,
+ })),
+ ]);
+ }
+ }, []);
return (
diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts
index 56633b8..11d77e2 100644
--- a/src/lib/admin.types.ts
+++ b/src/lib/admin.types.ts
@@ -23,6 +23,13 @@ export interface AdminConfig {
from: 'config' | 'custom';
disabled?: boolean;
}[];
+ CustomCategories: {
+ name?: string;
+ type: 'movie' | 'tv';
+ query: string;
+ from: 'config' | 'custom';
+ disabled?: boolean;
+ }[];
}
export interface AdminConfigResult {
diff --git a/src/lib/config.ts b/src/lib/config.ts
index f800258..44d2490 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -17,6 +17,11 @@ interface ConfigFileStruct {
api_site: {
[key: string]: ApiSite;
};
+ custom_category?: {
+ name?: string;
+ type: 'movie' | 'tv';
+ query: string;
+ }[];
}
export const API_CONFIG = {
@@ -86,6 +91,7 @@ async function initConfig() {
// 从文件中获取源信息,用于补全源
const apiSiteEntries = Object.entries(fileConfig.api_site);
+ const customCategories = fileConfig.custom_category || [];
if (adminConfig) {
// 补全 SourceConfig
@@ -113,6 +119,37 @@ async function initConfig() {
}
});
+ // 确保 CustomCategories 被初始化
+ if (!adminConfig.CustomCategories) {
+ adminConfig.CustomCategories = [];
+ }
+
+ // 补全 CustomCategories
+ const existedCustomCategories = new Set(
+ adminConfig.CustomCategories.map((c) => c.query + c.type)
+ );
+ customCategories.forEach((category) => {
+ if (!existedCustomCategories.has(category.query + category.type)) {
+ adminConfig!.CustomCategories.push({
+ name: category.name,
+ type: category.type,
+ query: category.query,
+ from: 'config',
+ disabled: false,
+ });
+ }
+ });
+
+ // 检查现有 CustomCategories 是否在 fileConfig.custom_category 中,如果不在则标记为 custom
+ const customCategoriesKeys = new Set(
+ customCategories.map((c) => c.query + c.type)
+ );
+ adminConfig.CustomCategories.forEach((category) => {
+ if (!customCategoriesKeys.has(category.query + category.type)) {
+ category.from = 'custom';
+ }
+ });
+
const existedUsers = new Set(
(adminConfig.UserConfig.Users || []).map((u) => u.username)
);
@@ -173,6 +210,13 @@ async function initConfig() {
from: 'config',
disabled: false,
})),
+ CustomCategories: customCategories.map((category) => ({
+ name: category.name,
+ type: category.type,
+ query: category.query,
+ from: 'config',
+ disabled: false,
+ })),
};
}
@@ -212,6 +256,14 @@ async function initConfig() {
from: 'config',
disabled: false,
})),
+ CustomCategories:
+ fileConfig.custom_category?.map((category) => ({
+ name: category.name,
+ type: category.type,
+ query: category.query,
+ from: 'config',
+ disabled: false,
+ })) || [],
} as AdminConfig;
}
}
@@ -229,6 +281,11 @@ export async function getConfig(): Promise {
adminConfig = await (storage as any).getAdminConfig();
}
if (adminConfig) {
+ // 确保 CustomCategories 被初始化
+ if (!adminConfig.CustomCategories) {
+ adminConfig.CustomCategories = [];
+ }
+
// 合并一些环境变量配置
adminConfig.SiteConfig.SiteName = process.env.SITE_NAME || 'MoonTV';
adminConfig.SiteConfig.Announcement =
@@ -266,6 +323,33 @@ export async function getConfig(): Promise {
}
});
+ // 补全 CustomCategories
+ const customCategories = fileConfig.custom_category || [];
+ const existedCustomCategories = new Set(
+ adminConfig.CustomCategories.map((c) => c.query + c.type)
+ );
+ customCategories.forEach((category) => {
+ if (!existedCustomCategories.has(category.query + category.type)) {
+ adminConfig!.CustomCategories.push({
+ name: category.name,
+ type: category.type,
+ query: category.query,
+ from: 'config',
+ disabled: false,
+ });
+ }
+ });
+
+ // 检查现有 CustomCategories 是否在 fileConfig.custom_categories 中,如果不在则标记为 custom
+ const customCategoriesKeys = new Set(
+ customCategories.map((c) => c.query + c.type)
+ );
+ adminConfig.CustomCategories.forEach((category) => {
+ if (!customCategoriesKeys.has(category.query + category.type)) {
+ category.from = 'custom';
+ }
+ });
+
const ownerUser = process.env.USERNAME || '';
// 检查配置中的站长用户是否和 USERNAME 匹配,如果不匹配则降级为普通用户
let containOwner = false;
@@ -295,6 +379,7 @@ export async function getConfig(): Promise {
}
export async function resetConfig() {
+ const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
const storage = getStorage();
// 获取所有用户名,用于补全 Users
let userNames: string[] = [];
@@ -323,6 +408,7 @@ export async function resetConfig() {
// 从文件中获取源信息,用于补全源
const apiSiteEntries = Object.entries(fileConfig.api_site);
+ const customCategories = fileConfig.custom_category || [];
let allUsers = userNames.map((uname) => ({
username: uname,
role: 'user',
@@ -359,6 +445,16 @@ export async function resetConfig() {
from: 'config',
disabled: false,
})),
+ CustomCategories:
+ storageType === 'redis'
+ ? customCategories?.map((category) => ({
+ name: category.name,
+ type: category.type,
+ query: category.query,
+ from: 'config',
+ disabled: false,
+ })) || []
+ : [],
} as AdminConfig;
if (storage && typeof (storage as any).setAdminConfig === 'function') {
@@ -371,6 +467,7 @@ export async function resetConfig() {
cachedConfig.SiteConfig = adminConfig.SiteConfig;
cachedConfig.UserConfig = adminConfig.UserConfig;
cachedConfig.SourceConfig = adminConfig.SourceConfig;
+ cachedConfig.CustomCategories = adminConfig.CustomCategories;
}
export async function getCacheTime(): Promise {
diff --git a/src/lib/douban.client.ts b/src/lib/douban.client.ts
index 8c78194..92a8b96 100644
--- a/src/lib/douban.client.ts
+++ b/src/lib/douban.client.ts
@@ -146,3 +146,82 @@ export async function getDoubanCategories(
return response.json();
}
}
+
+interface DoubanListParams {
+ tag: string;
+ type: string;
+ pageLimit?: number;
+ pageStart?: number;
+}
+
+export async function getDoubanList(
+ params: DoubanListParams
+): Promise {
+ const { tag, type, pageLimit = 20, pageStart = 0 } = params;
+ if (shouldUseDoubanClient()) {
+ // 使用客户端代理获取(当设置了代理 URL 时)
+ return fetchDoubanList(params);
+ } else {
+ const response = await fetch(
+ `/api/douban?tag=${tag}&type=${type}&limit=${pageLimit}&start=${pageStart}`
+ );
+
+ if (!response.ok) {
+ throw new Error('获取豆瓣列表数据失败');
+ }
+
+ return response.json();
+ }
+}
+
+export async function fetchDoubanList(
+ params: DoubanListParams
+): Promise {
+ const { tag, type, pageLimit = 20, pageStart = 0 } = params;
+
+ // 验证参数
+ if (!tag || !type) {
+ throw new Error('tag 和 type 参数不能为空');
+ }
+
+ if (!['tv', 'movie'].includes(type)) {
+ throw new Error('type 参数必须是 tv 或 movie');
+ }
+
+ if (pageLimit < 1 || pageLimit > 100) {
+ throw new Error('pageLimit 必须在 1-100 之间');
+ }
+
+ if (pageStart < 0) {
+ throw new Error('pageStart 不能小于 0');
+ }
+
+ const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`;
+
+ try {
+ const response = await fetchWithTimeout(target);
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ const doubanData: DoubanCategoryApiResponse = await response.json();
+
+ // 转换数据格式
+ const list: DoubanItem[] = doubanData.items.map((item) => ({
+ id: item.id,
+ title: item.title,
+ poster: item.pic?.normal || item.pic?.large || '',
+ rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
+ year: item.card_subtitle?.match(/(\d{4})/)?.[1] || '',
+ }));
+
+ return {
+ code: 200,
+ message: '获取成功',
+ list: list,
+ };
+ } catch (error) {
+ throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
+ }
+}
diff --git a/src/lib/version.ts b/src/lib/version.ts
index 172c823..233976b 100644
--- a/src/lib/version.ts
+++ b/src/lib/version.ts
@@ -2,7 +2,7 @@
'use client';
-const CURRENT_VERSION = '20250730221204';
+const CURRENT_VERSION = '20250731010759';
// 版本检查结果枚举
export enum UpdateStatus {