feat: implement custom config

This commit is contained in:
shinya
2025-07-31 01:07:59 +08:00
parent fc24055bdc
commit 7b6867ed58
13 changed files with 967 additions and 49 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
.env
.env*.local

View File

@@ -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 格式。

View File

@@ -1 +1 @@
20250730221204
20250731010759

View File

@@ -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<void>;
}) => {
const [categories, setCategories] = useState<CustomCategory[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [orderChanged, setOrderChanged] = useState(false);
const [newCategory, setNewCategory] = useState<CustomCategory>({
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<string, any>) => {
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 (
<tr
ref={setNodeRef}
style={style}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
>
<td
className={`px-2 py-4 ${
isD1Storage || isUpstashStorage
? 'text-gray-200'
: 'cursor-grab text-gray-400'
}`}
style={{ touchAction: 'none' }}
{...(isD1Storage || isUpstashStorage
? {}
: { ...attributes, ...listeners })}
>
<GripVertical size={16} />
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{category.name || '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
<span
className={`px-2 py-1 text-xs rounded-full ${
category.type === 'movie'
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300'
: 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300'
}`}
>
{category.type === 'movie' ? '电影' : '电视剧'}
</span>
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[12rem] truncate'
title={category.query}
>
{category.query}
</td>
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
<span
className={`px-2 py-1 text-xs rounded-full ${
!category.disabled
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{!category.disabled ? '启用中' : '已禁用'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button
onClick={() =>
!isD1Storage &&
!isUpstashStorage &&
handleToggleEnable(category.query, category.type)
}
disabled={isD1Storage || isUpstashStorage}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${
isD1Storage || isUpstashStorage
? 'bg-gray-400 cursor-not-allowed text-white'
: !category.disabled
? 'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/60'
: 'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/60'
} transition-colors`}
>
{!category.disabled ? '禁用' : '启用'}
</button>
{category.from !== 'config' && !isD1Storage && !isUpstashStorage && (
<button
onClick={() => handleDelete(category.query, category.type)}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors'
>
</button>
)}
</td>
</tr>
);
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
{/* 添加分类表单 */}
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
{isD1Storage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(D1 )
</span>
)}
{isUpstashStorage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</h4>
<button
onClick={() =>
!isD1Storage && !isUpstashStorage && setShowAddForm(!showAddForm)
}
disabled={isD1Storage || isUpstashStorage}
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
isD1Storage || isUpstashStorage
? 'bg-gray-400 cursor-not-allowed text-white'
: 'bg-green-600 hover:bg-green-700 text-white'
}`}
>
{showAddForm ? '取消' : '添加分类'}
</button>
</div>
{showAddForm && !isD1Storage && !isUpstashStorage && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<input
type='text'
placeholder='分类名称'
value={newCategory.name}
onChange={(e) =>
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'
/>
<select
value={newCategory.type}
onChange={(e) =>
setNewCategory((prev) => ({
...prev,
type: e.target.value as 'movie' | 'tv',
}))
}
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'
>
<option value='movie'></option>
<option value='tv'></option>
</select>
<input
type='text'
placeholder='搜索关键词'
value={newCategory.query}
onChange={(e) =>
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'
/>
</div>
<div className='flex justify-end'>
<button
onClick={handleAddCategory}
disabled={!newCategory.name || !newCategory.query}
className='w-full sm:w-auto px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg transition-colors'
>
</button>
</div>
</div>
)}
{/* 分类表格 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto'>
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900'>
<tr>
<th className='w-8' />
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
</tr>
</thead>
<DndContext
sensors={isD1Storage || isUpstashStorage ? [] : sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
autoScroll={false}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={categories.map((c) => `${c.query}:${c.type}`)}
strategy={verticalListSortingStrategy}
>
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{categories.map((category) => (
<DraggableRow
key={`${category.query}:${category.type}`}
category={category}
/>
))}
</tbody>
</SortableContext>
</DndContext>
</table>
</div>
{/* 保存排序按钮 */}
{orderChanged && !isD1Storage && !isUpstashStorage && (
<div className='flex justify-end'>
<button
onClick={handleSaveOrder}
className='px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors'
>
</button>
</div>
)}
</div>
);
};
// 新增站点配置组件
const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
const [siteSettings, setSiteSettings] = useState<SiteConfig>({
@@ -1245,6 +1637,7 @@ function AdminPageClient() {
userConfig: false,
videoSource: false,
siteConfig: false,
categoryConfig: false,
});
// 获取管理员配置
@@ -1401,6 +1794,21 @@ function AdminPageClient() {
>
<VideoSourceConfig config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 分类配置标签 */}
<CollapsibleTab
title='分类配置'
icon={
<FolderOpen
size={20}
className='text-gray-600 dark:text-gray-400'
/>
}
isExpanded={expandedTabs.categoryConfig}
onToggle={() => toggleTab('categoryConfig')}
>
<CategoryConfig config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
</div>
</div>
</div>

View File

@@ -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<string, any>;
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 }
);
}
}

View File

@@ -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<NodeJS.Timeout | null>(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<string>(() => {
@@ -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() {
</p>
</div>
{/* 选择器组件 */}
<div className='bg-white/60 dark:bg-gray-800/40 rounded-2xl p-4 sm:p-6 border border-gray-200/30 dark:border-gray-700/30 backdrop-blur-sm'>
<DoubanSelector
type={type as 'movie' | 'tv' | 'show'}
primarySelection={primarySelection}
secondarySelection={secondarySelection}
onPrimaryChange={handlePrimaryChange}
onSecondaryChange={handleSecondaryChange}
/>
</div>
{/* 选择器组件 - custom 模式下不显示 */}
{!custom && (
<div className='bg-white/60 dark:bg-gray-800/40 rounded-2xl p-4 sm:p-6 border border-gray-200/30 dark:border-gray-700/30 backdrop-blur-sm'>
<DoubanSelector
type={type as 'movie' | 'tv' | 'show'}
primarySelection={primarySelection}
secondarySelection={secondarySelection}
onPrimaryChange={handlePrimaryChange}
onSecondaryChange={handleSecondaryChange}
/>
</div>
)}
</div>
{/* 内容展示区域 */}
<div className='max-w-[95%] mx-auto mt-8 overflow-visible'>
{/* 内容网格 */}
<div className='grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fit,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'>
{loading || !selectorsReady
{loading || (!selectorsReady && !custom)
? // 显示骨架屏
skeletonData.map((index) => <DoubanCardSkeleton key={index} />)
: // 显示实际数据

View File

@@ -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 (

View File

@@ -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))',
}}
>
<ul className='flex items-center'>
<ul className='flex items-center overflow-x-auto scrollbar-hide'>
{navItems.map((item) => {
const active = isActive(item.href);
return (
<li key={item.href} className='flex-shrink-0 w-1/5'>
<li key={item.href} className='flex-shrink-0 w-20 min-w-20'>
<Link
href={item.href}
className='flex flex-col items-center justify-center w-full h-14 gap-1 text-xs'

View File

@@ -1,6 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { Clover, Film, Home, Menu, Search, Tv } from 'lucide-react';
import { Clover, Film, Home, Menu, Search, Star, Tv } from 'lucide-react';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
@@ -122,7 +124,7 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
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 (
<SidebarContext.Provider value={contextValue}>

View File

@@ -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 {

View File

@@ -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> {
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<AdminConfig> {
}
});
// 补全 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<AdminConfig> {
}
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<number> {

View File

@@ -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<DoubanResult> {
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<DoubanResult> {
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}`);
}
}

View File

@@ -2,7 +2,7 @@
'use client';
const CURRENT_VERSION = '20250730221204';
const CURRENT_VERSION = '20250731010759';
// 版本检查结果枚举
export enum UpdateStatus {