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 && ( +
+
+ + setNewCategory((prev) => ({ ...prev, name: e.target.value })) + } + className='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' + /> + + + setNewCategory((prev) => ({ ...prev, query: e.target.value })) + } + className='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' + /> +
+
+ +
+
+ )} + + {/* 分类表格 */} +
+ + + + + + + + + + + + `${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 {