feat: init 2.0.0 config file edit

This commit is contained in:
shinya
2025-08-13 00:30:31 +08:00
parent b4ed660eca
commit b6d9810ee8
14 changed files with 671 additions and 3060 deletions

View File

@@ -55,7 +55,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js
# 从构建器中复制 public 和 .next/static 目录 # 从构建器中复制 public 和 .next/static 目录
COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/config.json ./config.json
# 切换到非特权用户 # 切换到非特权用户
USER nextjs USER nextjs

View File

@@ -3,8 +3,8 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm gen:runtime && pnpm gen:manifest && next dev -H 0.0.0.0", "dev": "pnpm gen:manifest && next dev -H 0.0.0.0",
"build": "pnpm gen:runtime && pnpm gen:manifest && next build", "build": "pnpm gen:manifest && next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"lint:fix": "eslint src --fix && pnpm format", "lint:fix": "eslint src --fix && pnpm format",
@@ -14,13 +14,11 @@
"test": "jest", "test": "jest",
"format": "prettier -w .", "format": "prettier -w .",
"format:check": "prettier -c .", "format:check": "prettier -c .",
"gen:runtime": "node scripts/convert-config.js",
"gen:manifest": "node scripts/generate-manifest.js", "gen:manifest": "node scripts/generate-manifest.js",
"postbuild": "echo 'Build completed - sitemap generation disabled'", "postbuild": "echo 'Build completed - sitemap generation disabled'",
"prepare": "husky install" "prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"@cloudflare/next-on-pages": "^1.13.12",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -89,4 +87,4 @@
] ]
}, },
"packageManager": "pnpm@10.14.0" "packageManager": "pnpm@10.14.0"
} }

2526
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,61 +0,0 @@
#!/usr/bin/env node
/* eslint-disable */
// AUTO-GENERATED SCRIPT: Converts config.json to TypeScript definition.
// Usage: node scripts/convert-config.js
const fs = require('fs');
const path = require('path');
// Resolve project root (one level up from scripts folder)
const projectRoot = path.resolve(__dirname, '..');
// Paths
const configPath = path.join(projectRoot, 'config.json');
const libDir = path.join(projectRoot, 'src', 'lib');
const oldRuntimePath = path.join(libDir, 'runtime.ts');
const newRuntimePath = path.join(libDir, 'runtime.ts');
// Delete the old runtime.ts file if it exists
if (fs.existsSync(oldRuntimePath)) {
fs.unlinkSync(oldRuntimePath);
console.log('旧的 runtime.ts 已删除');
}
// Read and parse config.json
let rawConfig;
try {
rawConfig = fs.readFileSync(configPath, 'utf8');
} catch (err) {
console.error(`无法读取 ${configPath}:`, err);
process.exit(1);
}
let config;
try {
config = JSON.parse(rawConfig);
} catch (err) {
console.error('config.json 不是有效的 JSON:', err);
process.exit(1);
}
// Prepare TypeScript file content
const tsContent =
`// 该文件由 scripts/convert-config.js 自动生成,请勿手动修改\n` +
`/* eslint-disable */\n\n` +
`export const config = ${JSON.stringify(config, null, 2)} as const;\n\n` +
`export type RuntimeConfig = typeof config;\n\n` +
`export default config;\n`;
// Ensure lib directory exists
if (!fs.existsSync(libDir)) {
fs.mkdirSync(libDir, { recursive: true });
}
// Write to runtime.ts
try {
fs.writeFileSync(newRuntimePath, tsContent, 'utf8');
console.log('已生成 src/lib/runtime.ts');
} catch (err) {
console.error('写入 runtime.ts 失败:', err);
process.exit(1);
}

View File

@@ -26,6 +26,7 @@ import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
ExternalLink, ExternalLink,
FileText,
FolderOpen, FolderOpen,
Settings, Settings,
Users, Users,
@@ -313,9 +314,8 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
</h4> </h4>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<label <label
className={`text-gray-700 dark:text-gray-300 ${ className={`text-gray-700 dark:text-gray-300 ${isUpstashStorage ? 'opacity-50' : ''
isUpstashStorage ? 'opacity-50' : '' }`}
}`}
> >
{isUpstashStorage && ( {isUpstashStorage && (
@@ -330,18 +330,16 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
toggleAllowRegister(!userSettings.enableRegistration) toggleAllowRegister(!userSettings.enableRegistration)
} }
disabled={isUpstashStorage} disabled={isUpstashStorage}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${userSettings.enableRegistration
userSettings.enableRegistration ? 'bg-green-600'
? 'bg-green-600' : 'bg-gray-200 dark:bg-gray-700'
: 'bg-gray-200 dark:bg-gray-700' } ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''}`}
} ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
<span <span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${ className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${userSettings.enableRegistration
userSettings.enableRegistration ? 'translate-x-6'
? 'translate-x-6' : 'translate-x-1'
: 'translate-x-1' }`}
}`}
/> />
</button> </button>
</div> </div>
@@ -521,28 +519,26 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
</td> </td>
<td className='px-6 py-4 whitespace-nowrap'> <td className='px-6 py-4 whitespace-nowrap'>
<span <span
className={`px-2 py-1 text-xs rounded-full ${ className={`px-2 py-1 text-xs rounded-full ${user.role === 'owner'
user.role === 'owner' ? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300' : user.role === 'admin'
: user.role === 'admin'
? 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300' ? 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`} }`}
> >
{user.role === 'owner' {user.role === 'owner'
? '站长' ? '站长'
: user.role === 'admin' : user.role === 'admin'
? '管理员' ? '管理员'
: '普通用户'} : '普通用户'}
</span> </span>
</td> </td>
<td className='px-6 py-4 whitespace-nowrap'> <td className='px-6 py-4 whitespace-nowrap'>
<span <span
className={`px-2 py-1 text-xs rounded-full ${ className={`px-2 py-1 text-xs rounded-full ${!user.banned
!user.banned ? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
? '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'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300' }`}
}`}
> >
{!user.banned ? '正常' : '已封禁'} {!user.banned ? '正常' : '已封禁'}
</span> </span>
@@ -793,11 +789,10 @@ const VideoSourceConfig = ({
</td> </td>
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'> <td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
<span <span
className={`px-2 py-1 text-xs rounded-full ${ className={`px-2 py-1 text-xs rounded-full ${!source.disabled
!source.disabled ? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
? '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'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300' }`}
}`}
> >
{!source.disabled ? '启用中' : '已禁用'} {!source.disabled ? '启用中' : '已禁用'}
</span> </span>
@@ -805,11 +800,10 @@ const VideoSourceConfig = ({
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'> <td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button <button
onClick={() => handleToggleEnable(source.key)} onClick={() => handleToggleEnable(source.key)}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${ className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!source.disabled
!source.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-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'
: '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`}
} transition-colors`}
> >
{!source.disabled ? '禁用' : '启用'} {!source.disabled ? '禁用' : '启用'}
</button> </button>
@@ -1113,9 +1107,8 @@ const CategoryConfig = ({
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none' className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
> >
<td <td
className={`px-2 py-4 ${ className={`px-2 py-4 ${isUpstashStorage ? 'text-gray-200' : 'cursor-grab text-gray-400'
isUpstashStorage ? 'text-gray-200' : 'cursor-grab text-gray-400' }`}
}`}
style={{ touchAction: 'none' }} style={{ touchAction: 'none' }}
{...(isUpstashStorage ? {} : { ...attributes, ...listeners })} {...(isUpstashStorage ? {} : { ...attributes, ...listeners })}
> >
@@ -1126,11 +1119,10 @@ const CategoryConfig = ({
</td> </td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'> <td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
<span <span
className={`px-2 py-1 text-xs rounded-full ${ className={`px-2 py-1 text-xs rounded-full ${category.type === 'movie'
category.type === 'movie' ? 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300'
? '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'
: 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300' }`}
}`}
> >
{category.type === 'movie' ? '电影' : '电视剧'} {category.type === 'movie' ? '电影' : '电视剧'}
</span> </span>
@@ -1143,11 +1135,10 @@ const CategoryConfig = ({
</td> </td>
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'> <td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
<span <span
className={`px-2 py-1 text-xs rounded-full ${ className={`px-2 py-1 text-xs rounded-full ${!category.disabled
!category.disabled ? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
? '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'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300' }`}
}`}
> >
{!category.disabled ? '启用中' : '已禁用'} {!category.disabled ? '启用中' : '已禁用'}
</span> </span>
@@ -1159,13 +1150,12 @@ const CategoryConfig = ({
handleToggleEnable(category.query, category.type) handleToggleEnable(category.query, category.type)
} }
disabled={isUpstashStorage} disabled={isUpstashStorage}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${ className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${isUpstashStorage
isUpstashStorage ? 'bg-gray-400 cursor-not-allowed text-white'
? 'bg-gray-400 cursor-not-allowed text-white' : !category.disabled
: !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-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' : '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`} } transition-colors`}
> >
{!category.disabled ? '禁用' : '启用'} {!category.disabled ? '禁用' : '启用'}
</button> </button>
@@ -1205,11 +1195,10 @@ const CategoryConfig = ({
<button <button
onClick={() => !isUpstashStorage && setShowAddForm(!showAddForm)} onClick={() => !isUpstashStorage && setShowAddForm(!showAddForm)}
disabled={isUpstashStorage} disabled={isUpstashStorage}
className={`px-3 py-1 text-sm rounded-lg transition-colors ${ className={`px-3 py-1 text-sm rounded-lg transition-colors ${isUpstashStorage
isUpstashStorage ? 'bg-gray-400 cursor-not-allowed text-white'
? 'bg-gray-400 cursor-not-allowed text-white' : 'bg-green-600 hover:bg-green-700 text-white'
: 'bg-green-600 hover:bg-green-700 text-white' }`}
}`}
> >
{showAddForm ? '取消' : '添加分类'} {showAddForm ? '取消' : '添加分类'}
</button> </button>
@@ -1324,6 +1313,92 @@ const CategoryConfig = ({
); );
}; };
// 新增配置文件组件
const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise<void> }) => {
const [configContent, setConfigContent] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (config?.ConfigFile) {
setConfigContent(config.ConfigFile);
}
}, [config]);
// 保存配置文件
const handleSave = async () => {
try {
setSaving(true);
const resp = await fetch('/api/admin/config_file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ configFile: configContent }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `保存失败: ${resp.status}`);
}
showSuccess('配置文件保存成功');
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '保存失败');
} finally {
setSaving(false);
}
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-4'>
{/* 配置文件编辑区域 */}
<div className='space-y-4'>
<div className='relative'>
<textarea
value={configContent}
onChange={(e) => setConfigContent(e.target.value)}
rows={20}
placeholder='请输入配置文件内容JSON 格式)...'
className='w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm leading-relaxed resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 hover:border-gray-400 dark:hover:border-gray-500'
style={{
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace'
}}
spellCheck={false}
data-gramm={false}
/>
</div>
<div className='flex items-center justify-between'>
<div className='text-xs text-gray-500 dark:text-gray-400'>
JSON
</div>
<button
onClick={handleSave}
disabled={saving}
className={`px-4 py-2 rounded-lg transition-colors ${saving
? 'bg-gray-400 cursor-not-allowed text-white'
: 'bg-blue-600 hover:bg-blue-700 text-white'
}`}
>
{saving ? '保存中…' : '保存配置文件'}
</button>
</div>
</div>
</div>
);
};
// 新增站点配置组件 // 新增站点配置组件
const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => { const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
const [siteSettings, setSiteSettings] = useState<SiteConfig>({ const [siteSettings, setSiteSettings] = useState<SiteConfig>({
@@ -1500,9 +1575,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
{/* 站点名称 */} {/* 站点名称 */}
<div> <div>
<label <label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${ className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${isUpstashStorage ? 'opacity-50' : ''
isUpstashStorage ? 'opacity-50' : '' }`}
}`}
> >
{isUpstashStorage && ( {isUpstashStorage && (
@@ -1519,18 +1593,16 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
setSiteSettings((prev) => ({ ...prev, SiteName: e.target.value })) setSiteSettings((prev) => ({ ...prev, SiteName: e.target.value }))
} }
disabled={isUpstashStorage} disabled={isUpstashStorage}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${ className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : '' }`}
}`}
/> />
</div> </div>
{/* 站点公告 */} {/* 站点公告 */}
<div> <div>
<label <label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${ className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${isUpstashStorage ? 'opacity-50' : ''
isUpstashStorage ? 'opacity-50' : '' }`}
}`}
> >
{isUpstashStorage && ( {isUpstashStorage && (
@@ -1550,9 +1622,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
} }
disabled={isUpstashStorage} disabled={isUpstashStorage}
rows={3} rows={3}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${ className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : '' }`}
}`}
/> />
</div> </div>
@@ -1560,9 +1631,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
<div className='space-y-3'> <div className='space-y-3'>
<div> <div>
<label <label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${ className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${isUpstashStorage ? 'opacity-50' : ''
isUpstashStorage ? 'opacity-50' : '' }`}
}`}
> >
{isUpstashStorage && ( {isUpstashStorage && (
@@ -1577,9 +1647,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
type='button' type='button'
onClick={() => setIsDoubanDropdownOpen(!isDoubanDropdownOpen)} onClick={() => setIsDoubanDropdownOpen(!isDoubanDropdownOpen)}
disabled={isUpstashStorage} disabled={isUpstashStorage}
className={`w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left ${ className={`w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : '' }`}
}`}
> >
{ {
doubanDataSourceOptions.find( doubanDataSourceOptions.find(
@@ -1591,9 +1660,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
{/* 下拉箭头 */} {/* 下拉箭头 */}
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'> <div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown <ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${ className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanDropdownOpen ? 'rotate-180' : ''
isDoubanDropdownOpen ? 'rotate-180' : '' }`}
}`}
/> />
</div> </div>
@@ -1608,11 +1676,10 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
handleDoubanDataSourceChange(option.value); handleDoubanDataSourceChange(option.value);
setIsDoubanDropdownOpen(false); setIsDoubanDropdownOpen(false);
}} }}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${ className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${siteSettings.DoubanProxyType === option.value
siteSettings.DoubanProxyType === option.value ? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400' : 'text-gray-900 dark:text-gray-100'
: 'text-gray-900 dark:text-gray-100' }`}
}`}
> >
<span className='truncate'>{option.label}</span> <span className='truncate'>{option.label}</span>
{siteSettings.DoubanProxyType === option.value && ( {siteSettings.DoubanProxyType === option.value && (
@@ -1653,9 +1720,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
{siteSettings.DoubanProxyType === 'custom' && ( {siteSettings.DoubanProxyType === 'custom' && (
<div> <div>
<label <label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${ className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${isUpstashStorage ? 'opacity-50' : ''
isUpstashStorage ? 'opacity-50' : '' }`}
}`}
> >
</label> </label>
@@ -1671,9 +1737,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
})) }))
} }
disabled={isUpstashStorage} disabled={isUpstashStorage}
className={`w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 ${ className={`w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : '' }`}
}`}
/> />
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
@@ -1686,9 +1751,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
<div className='space-y-3'> <div className='space-y-3'>
<div> <div>
<label <label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${ className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${isUpstashStorage ? 'opacity-50' : ''
isUpstashStorage ? 'opacity-50' : '' }`}
}`}
> >
{isUpstashStorage && ( {isUpstashStorage && (
@@ -1707,9 +1771,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
) )
} }
disabled={isUpstashStorage} disabled={isUpstashStorage}
className={`w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left ${ className={`w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : '' }`}
}`}
> >
{ {
doubanImageProxyTypeOptions.find( doubanImageProxyTypeOptions.find(
@@ -1721,9 +1784,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
{/* 下拉箭头 */} {/* 下拉箭头 */}
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'> <div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown <ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${ className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanImageProxyDropdownOpen ? 'rotate-180' : ''
isDoubanImageProxyDropdownOpen ? 'rotate-180' : '' }`}
}`}
/> />
</div> </div>
@@ -1738,11 +1800,10 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
handleDoubanImageProxyChange(option.value); handleDoubanImageProxyChange(option.value);
setIsDoubanImageProxyDropdownOpen(false); setIsDoubanImageProxyDropdownOpen(false);
}} }}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${ className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${siteSettings.DoubanImageProxyType === option.value
siteSettings.DoubanImageProxyType === option.value ? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400' : 'text-gray-900 dark:text-gray-100'
: 'text-gray-900 dark:text-gray-100' }`}
}`}
> >
<span className='truncate'>{option.label}</span> <span className='truncate'>{option.label}</span>
{siteSettings.DoubanImageProxyType === option.value && ( {siteSettings.DoubanImageProxyType === option.value && (
@@ -1783,9 +1844,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
{siteSettings.DoubanImageProxyType === 'custom' && ( {siteSettings.DoubanImageProxyType === 'custom' && (
<div> <div>
<label <label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${ className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${isUpstashStorage ? 'opacity-50' : ''
isUpstashStorage ? 'opacity-50' : '' }`}
}`}
> >
</label> </label>
@@ -1801,9 +1861,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
})) }))
} }
disabled={isUpstashStorage} disabled={isUpstashStorage}
className={`w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 ${ className={`w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : '' }`}
}`}
/> />
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
@@ -1854,9 +1913,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
<div> <div>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<label <label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${ className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${isUpstashStorage ? 'opacity-50' : ''
isUpstashStorage ? 'opacity-50' : '' }`}
}`}
> >
{isUpstashStorage && ( {isUpstashStorage && (
@@ -1875,18 +1933,16 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
})) }))
} }
disabled={isUpstashStorage} disabled={isUpstashStorage}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${siteSettings.DisableYellowFilter
siteSettings.DisableYellowFilter ? 'bg-green-600'
? 'bg-green-600' : 'bg-gray-200 dark:bg-gray-700'
: 'bg-gray-200 dark:bg-gray-700' } ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''}`}
} ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
<span <span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${ className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${siteSettings.DisableYellowFilter
siteSettings.DisableYellowFilter ? 'translate-x-6'
? 'translate-x-6' : 'translate-x-1'
: 'translate-x-1' }`}
}`}
/> />
</button> </button>
</div> </div>
@@ -1900,11 +1956,10 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
className={`px-4 py-2 ${ className={`px-4 py-2 ${saving
saving ? 'bg-gray-400 cursor-not-allowed'
? 'bg-gray-400 cursor-not-allowed' : 'bg-green-600 hover:bg-green-700'
: 'bg-green-600 hover:bg-green-700' } text-white rounded-lg transition-colors`}
} text-white rounded-lg transition-colors`}
> >
{saving ? '保存中…' : '保存'} {saving ? '保存中…' : '保存'}
</button> </button>
@@ -1923,6 +1978,7 @@ function AdminPageClient() {
videoSource: false, videoSource: false,
siteConfig: false, siteConfig: false,
categoryConfig: false, categoryConfig: false,
configFile: false,
}); });
// 获取管理员配置 // 获取管理员配置
@@ -2036,6 +2092,21 @@ function AdminPageClient() {
)} )}
</div> </div>
{/* 配置文件标签 */}
<CollapsibleTab
title='配置文件'
icon={
<FileText
size={20}
className='text-gray-600 dark:text-gray-400'
/>
}
isExpanded={expandedTabs.configFile}
onToggle={() => toggleTab('configFile')}
>
<ConfigFileComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 站点配置标签 */} {/* 站点配置标签 */}
<CollapsibleTab <CollapsibleTab
title='站点配置' title='站点配置'

View File

@@ -0,0 +1,88 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig, refineConfig } from '@/lib/config';
import { getStorage } from '@/lib/db';
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
{ status: 400 }
);
}
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
try {
// 检查用户权限
let adminConfig = await getConfig();
const storage = getStorage();
if (username !== process.env.USERNAME) {
const user = adminConfig.UserConfig.Users.find((u) => u.username === username);
if (!user || user.role !== 'admin' || user.banned) {
return NextResponse.json(
{ error: '权限不足,只有管理员可以修改配置文件' },
{ status: 403 }
);
}
}
// 获取请求体
const body = await request.json();
const { configFile } = body;
if (!configFile || typeof configFile !== 'string') {
return NextResponse.json(
{ error: '配置文件内容不能为空' },
{ status: 400 }
);
}
// 验证 JSON 格式
try {
JSON.parse(configFile);
} catch (e) {
return NextResponse.json(
{ error: '配置文件格式错误,请检查 JSON 语法' },
{ status: 400 }
);
}
adminConfig.ConfigFile = configFile;
adminConfig = refineConfig(adminConfig);
// 更新配置文件
if (storage && typeof (storage as any).setAdminConfig === 'function') {
await (storage as any).setAdminConfig(adminConfig);
return NextResponse.json({
success: true,
message: '配置文件更新成功',
});
} else {
return NextResponse.json(
{ error: '存储服务不可用' },
{ status: 500 }
);
}
} catch (error) {
console.error('更新配置文件失败:', error);
return NextResponse.json(
{
error: '更新配置文件失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,13 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from "next/server";
import { getConfig } from "@/lib/config";
export const runtime = 'edge';
export async function GET(req: NextRequest) {
console.log('custom_category', req.url);
const config = await getConfig();
return NextResponse.json(config.CustomCategories);
}

View File

@@ -7,6 +7,7 @@ import { Suspense } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { GetBangumiCalendarData } from '@/lib/bangumi.client'; import { GetBangumiCalendarData } from '@/lib/bangumi.client';
import { getCustomCategories } from '@/lib/config.client';
import { import {
getDoubanCategories, getDoubanCategories,
getDoubanList, getDoubanList,
@@ -80,10 +81,9 @@ function DoubanPageClient() {
// 获取自定义分类数据 // 获取自定义分类数据
useEffect(() => { useEffect(() => {
const runtimeConfig = (window as any).RUNTIME_CONFIG; getCustomCategories().then((categories) => {
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) { setCustomCategories(categories);
setCustomCategories(runtimeConfig.CUSTOM_CATEGORIES); });
}
}, []); }, []);
// 同步最新参数值到 ref // 同步最新参数值到 ref
@@ -214,7 +214,7 @@ function DoubanPageClient() {
snapshot1.selectedWeekday === snapshot2.selectedWeekday && snapshot1.selectedWeekday === snapshot2.selectedWeekday &&
snapshot1.currentPage === snapshot2.currentPage && snapshot1.currentPage === snapshot2.currentPage &&
JSON.stringify(snapshot1.multiLevelSelection) === JSON.stringify(snapshot1.multiLevelSelection) ===
JSON.stringify(snapshot2.multiLevelSelection) JSON.stringify(snapshot2.multiLevelSelection)
); );
}, },
[] []
@@ -686,12 +686,12 @@ function DoubanPageClient() {
return type === 'movie' return type === 'movie'
? '电影' ? '电影'
: type === 'tv' : type === 'tv'
? '电视剧' ? '电视剧'
: type === 'anime' : type === 'anime'
? '动漫' ? '动漫'
: type === 'show' : type === 'show'
? '综艺' ? '综艺'
: '自定义'; : '自定义';
}; };
const getPageDescription = () => { const getPageDescription = () => {
@@ -757,24 +757,24 @@ function DoubanPageClient() {
<div className='justify-start grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'> <div className='justify-start grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'>
{loading || !selectorsReady {loading || !selectorsReady
? // 显示骨架屏 ? // 显示骨架屏
skeletonData.map((index) => <DoubanCardSkeleton key={index} />) skeletonData.map((index) => <DoubanCardSkeleton key={index} />)
: // 显示实际数据 : // 显示实际数据
doubanData.map((item, index) => ( doubanData.map((item, index) => (
<div key={`${item.title}-${index}`} className='w-full'> <div key={`${item.title}-${index}`} className='w-full'>
<VideoCard <VideoCard
from='douban' from='douban'
title={item.title} title={item.title}
poster={item.poster} poster={item.poster}
douban_id={Number(item.id)} douban_id={Number(item.id)}
rate={item.rate} rate={item.rate}
year={item.year} year={item.year}
type={type === 'movie' ? 'movie' : ''} // 电影类型严格控制tv 不控 type={type === 'movie' ? 'movie' : ''} // 电影类型严格控制tv 不控
isBangumi={ isBangumi={
type === 'anime' && primarySelection === '每日放送' type === 'anime' && primarySelection === '每日放送'
} }
/> />
</div> </div>
))} ))}
</div> </div>
{/* 加载更多指示器 */} {/* 加载更多指示器 */}

View File

@@ -7,7 +7,6 @@ import './globals.css';
import 'sweetalert2/dist/sweetalert2.min.css'; import 'sweetalert2/dist/sweetalert2.min.css';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import RuntimeConfig from '@/lib/runtime';
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator'; import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
import { SiteProvider } from '../components/SiteProvider'; import { SiteProvider } from '../components/SiteProvider';
@@ -39,6 +38,8 @@ export default async function RootLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV'; let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
let announcement = let announcement =
process.env.ANNOUNCEMENT || process.env.ANNOUNCEMENT ||
@@ -51,13 +52,7 @@ export default async function RootLayout({
let doubanImageProxy = process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || ''; let doubanImageProxy = process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '';
let disableYellowFilter = let disableYellowFilter =
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true'; process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
let customCategories = if (storageType !== 'upstash' && storageType !== 'localstorage') {
(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 !== 'upstash') {
const config = await getConfig(); const config = await getConfig();
siteName = config.SiteConfig.SiteName; siteName = config.SiteConfig.SiteName;
announcement = config.SiteConfig.Announcement; announcement = config.SiteConfig.Announcement;
@@ -67,13 +62,6 @@ export default async function RootLayout({
doubanImageProxyType = config.SiteConfig.DoubanImageProxyType; doubanImageProxyType = config.SiteConfig.DoubanImageProxyType;
doubanImageProxy = config.SiteConfig.DoubanImageProxy; doubanImageProxy = config.SiteConfig.DoubanImageProxy;
disableYellowFilter = config.SiteConfig.DisableYellowFilter; disableYellowFilter = config.SiteConfig.DisableYellowFilter;
customCategories = config.CustomCategories.filter(
(category) => !category.disabled
).map((category) => ({
name: category.name || '',
type: category.type,
query: category.query,
}));
} }
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取 // 将运行时配置注入到全局 window 对象,供客户端在运行时读取
@@ -85,7 +73,6 @@ export default async function RootLayout({
DOUBAN_IMAGE_PROXY_TYPE: doubanImageProxyType, DOUBAN_IMAGE_PROXY_TYPE: doubanImageProxyType,
DOUBAN_IMAGE_PROXY: doubanImageProxy, DOUBAN_IMAGE_PROXY: doubanImageProxy,
DISABLE_YELLOW_FILTER: disableYellowFilter, DISABLE_YELLOW_FILTER: disableYellowFilter,
CUSTOM_CATEGORIES: customCategories,
}; };
return ( return (

View File

@@ -7,6 +7,8 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getCustomCategories } from '@/lib/config.client';
interface MobileBottomNavProps { interface MobileBottomNavProps {
/** /**
* 主动指定当前激活的路径。当未提供时,自动使用 usePathname() 获取的路径。 * 主动指定当前激活的路径。当未提供时,自动使用 usePathname() 获取的路径。
@@ -46,17 +48,18 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
]); ]);
useEffect(() => { useEffect(() => {
const runtimeConfig = (window as any).RUNTIME_CONFIG; getCustomCategories().then((categories) => {
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) { if (categories.length > 0) {
setNavItems((prevItems) => [ setNavItems((prevItems) => [
...prevItems, ...prevItems,
{ {
icon: Star, icon: Star,
label: '自定义', label: '自定义',
href: '/douban?type=custom', href: '/douban?type=custom',
}, },
]); ]);
} }
});
}, []); }, []);
const isActive = (href: string) => { const isActive = (href: string) => {
@@ -97,11 +100,10 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
className='flex flex-col items-center justify-center w-full h-14 gap-1 text-xs' className='flex flex-col items-center justify-center w-full h-14 gap-1 text-xs'
> >
<item.icon <item.icon
className={`h-6 w-6 ${ className={`h-6 w-6 ${active
active
? 'text-green-600 dark:text-green-400' ? 'text-green-600 dark:text-green-400'
: 'text-gray-500 dark:text-gray-400' : 'text-gray-500 dark:text-gray-400'
}`} }`}
/> />
<span <span
className={ className={

View File

@@ -14,6 +14,8 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { getCustomCategories } from '@/lib/config.client';
import { useSite } from './SiteProvider'; import { useSite } from './SiteProvider';
interface SidebarContextType { interface SidebarContextType {
@@ -148,17 +150,18 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
]); ]);
useEffect(() => { useEffect(() => {
const runtimeConfig = (window as any).RUNTIME_CONFIG; getCustomCategories().then((categories) => {
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) { if (categories.length > 0) {
setMenuItems((prevItems) => [ setMenuItems((prevItems) => [
...prevItems, ...prevItems,
{ {
icon: Star, icon: Star,
label: '自定义', label: '自定义',
href: '/douban?type=custom', href: '/douban?type=custom',
}, },
]); ]);
} }
});
}, []); }, []);
return ( return (
@@ -167,9 +170,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
<div className='hidden md:flex'> <div className='hidden md:flex'>
<aside <aside
data-sidebar data-sidebar
className={`fixed top-0 left-0 h-screen bg-white/40 backdrop-blur-xl transition-all duration-300 border-r border-gray-200/50 z-10 shadow-lg dark:bg-gray-900/70 dark:border-gray-700/50 ${ className={`fixed top-0 left-0 h-screen bg-white/40 backdrop-blur-xl transition-all duration-300 border-r border-gray-200/50 z-10 shadow-lg dark:bg-gray-900/70 dark:border-gray-700/50 ${isCollapsed ? 'w-16' : 'w-64'
isCollapsed ? 'w-16' : 'w-64' }`}
}`}
style={{ style={{
backdropFilter: 'blur(20px)', backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
@@ -179,9 +181,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
{/* 顶部 Logo 区域 */} {/* 顶部 Logo 区域 */}
<div className='relative h-16'> <div className='relative h-16'>
<div <div
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${ className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${isCollapsed ? 'opacity-0' : 'opacity-100'
isCollapsed ? 'opacity-0' : 'opacity-100' }`}
}`}
> >
<div className='w-[calc(100%-4rem)] flex justify-center'> <div className='w-[calc(100%-4rem)] flex justify-center'>
{!isCollapsed && <Logo />} {!isCollapsed && <Logo />}
@@ -189,9 +190,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
</div> </div>
<button <button
onClick={handleToggle} onClick={handleToggle}
className={`absolute top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100/50 transition-colors duration-200 z-10 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-700/50 ${ className={`absolute top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100/50 transition-colors duration-200 z-10 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-700/50 ${isCollapsed ? 'left-1/2 -translate-x-1/2' : 'right-2'
isCollapsed ? 'left-1/2 -translate-x-1/2' : 'right-2' }`}
}`}
> >
<Menu className='h-4 w-4' /> <Menu className='h-4 w-4' />
</button> </button>
@@ -203,9 +203,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
href='/' href='/'
onClick={() => setActive('/')} onClick={() => setActive('/')}
data-active={active === '/'} data-active={active === '/'}
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${ className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0' } gap-3 justify-start`}
} gap-3 justify-start`}
> >
<div className='w-4 h-4 flex items-center justify-center'> <div className='w-4 h-4 flex items-center justify-center'>
<Home className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' /> <Home className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' />
@@ -224,9 +223,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
setActive('/search'); setActive('/search');
}} }}
data-active={active === '/search'} data-active={active === '/search'}
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${ className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0' } gap-3 justify-start`}
} gap-3 justify-start`}
> >
<div className='w-4 h-4 flex items-center justify-center'> <div className='w-4 h-4 flex items-center justify-center'>
<Search className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' /> <Search className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' />
@@ -261,9 +259,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
href={item.href} href={item.href}
onClick={() => setActive(item.href)} onClick={() => setActive(item.href)}
data-active={isActive} data-active={isActive}
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-sm text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${ className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-sm text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0' } gap-3 justify-start`}
} gap-3 justify-start`}
> >
<div className='w-4 h-4 flex items-center justify-center'> <div className='w-4 h-4 flex items-center justify-center'>
<Icon className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' /> <Icon className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' />
@@ -281,9 +278,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
</div> </div>
</aside> </aside>
<div <div
className={`transition-all duration-300 sidebar-offset ${ className={`transition-all duration-300 sidebar-offset ${isCollapsed ? 'w-16' : 'w-64'
isCollapsed ? 'w-16' : 'w-64' }`}
}`}
></div> ></div>
</div> </div>
</SidebarContext.Provider> </SidebarContext.Provider>

View File

@@ -1,4 +1,5 @@
export interface AdminConfig { export interface AdminConfig {
ConfigFile: string;
SiteConfig: { SiteConfig: {
SiteName: string; SiteName: string;
Announcement: string; Announcement: string;

17
src/lib/config.client.ts Normal file
View File

@@ -0,0 +1,17 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
export async function getCustomCategories(): Promise<{
name: string;
type: 'movie' | 'tv';
query: string;
}[]> {
const res = await fetch('/api/config/custom_category');
const data = await res.json();
return data.filter((item: any) => !item.disabled).map((category: any) => ({
name: category.name || '',
type: category.type,
query: category.query,
}));
}

View File

@@ -3,7 +3,6 @@
import { getStorage } from '@/lib/db'; import { getStorage } from '@/lib/db';
import { AdminConfig } from './admin.types'; import { AdminConfig } from './admin.types';
import runtimeConfig from './runtime';
export interface ApiSite { export interface ApiSite {
key: string; key: string;
@@ -14,7 +13,7 @@ export interface ApiSite {
interface ConfigFileStruct { interface ConfigFileStruct {
cache_time?: number; cache_time?: number;
api_site: { api_site?: {
[key: string]: ApiSite; [key: string]: ApiSite;
}; };
custom_category?: { custom_category?: {
@@ -48,244 +47,267 @@ export const API_CONFIG = {
let fileConfig: ConfigFileStruct; let fileConfig: ConfigFileStruct;
let cachedConfig: AdminConfig; let cachedConfig: AdminConfig;
async function initConfig() { export function refineConfig(adminConfig: AdminConfig): AdminConfig {
if (cachedConfig) { try {
return; fileConfig = JSON.parse(adminConfig.ConfigFile) as ConfigFileStruct;
} catch (e) {
fileConfig = {} as ConfigFileStruct;
} }
// 合并文件中的源信息
const apiSiteEntries = Object.entries(fileConfig.api_site || []);
const sourceConfigMap = new Map(
(adminConfig.SourceConfig || []).map((s) => [s.key, s])
);
if (process.env.DOCKER_ENV === 'true') { apiSiteEntries.forEach(([key, site]) => {
// eslint-disable-next-line @typescript-eslint/no-implied-eval const existingSource = sourceConfigMap.get(key);
const _require = eval('require') as NodeRequire; if (existingSource) {
const fs = _require('fs') as typeof import('fs'); // 如果已存在,只覆盖 name、api、detail 和 from
const path = _require('path') as typeof import('path'); existingSource.name = site.name;
existingSource.api = site.api;
const configPath = path.join(process.cwd(), 'config.json'); existingSource.detail = site.detail;
const raw = fs.readFileSync(configPath, 'utf-8'); existingSource.from = 'config';
fileConfig = JSON.parse(raw) as ConfigFileStruct; } else {
console.log('load dynamic config success'); // 如果不存在,创建新条目
} else { sourceConfigMap.set(key, {
// 默认使用编译时生成的配置
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
}
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType !== 'localstorage') {
// 数据库存储,读取并补全管理员配置
const storage = getStorage();
try {
// 尝试从数据库获取管理员配置
let adminConfig: AdminConfig | null = null;
if (storage && typeof (storage as any).getAdminConfig === 'function') {
adminConfig = await (storage as any).getAdminConfig();
}
// 获取所有用户名,用于补全 Users
let userNames: string[] = [];
if (storage && typeof (storage as any).getAllUsers === 'function') {
try {
userNames = await (storage as any).getAllUsers();
} catch (e) {
console.error('获取用户列表失败:', e);
}
}
// 从文件中获取源信息,用于补全源
const apiSiteEntries = Object.entries(fileConfig.api_site);
const customCategories = fileConfig.custom_category || [];
if (adminConfig) {
// 补全 SourceConfig
const sourceConfigMap = new Map(
(adminConfig.SourceConfig || []).map((s) => [s.key, s])
);
apiSiteEntries.forEach(([key, site]) => {
sourceConfigMap.set(key, {
key,
name: site.name,
api: site.api,
detail: site.detail,
from: 'config',
disabled: false,
});
});
// 将 Map 转换回数组
adminConfig.SourceConfig = Array.from(sourceConfigMap.values());
// 检查现有源是否在 fileConfig.api_site 中,如果不在则标记为 custom
const apiSiteKeys = new Set(apiSiteEntries.map(([key]) => key));
adminConfig.SourceConfig.forEach((source) => {
if (!apiSiteKeys.has(source.key)) {
source.from = 'custom';
}
});
// 确保 CustomCategories 被初始化
if (!adminConfig.CustomCategories) {
adminConfig.CustomCategories = [];
}
// 补全 CustomCategories
const customCategoriesMap = new Map(
adminConfig.CustomCategories.map((c) => [c.query + c.type, c])
);
customCategories.forEach((category) => {
customCategoriesMap.set(category.query + category.type, {
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)
);
customCategoriesMap.forEach((category) => {
if (!customCategoriesKeys.has(category.query + category.type)) {
category.from = 'custom';
}
});
// 将 Map 转换回数组
adminConfig.CustomCategories = Array.from(customCategoriesMap.values());
const existedUsers = new Set(
(adminConfig.UserConfig.Users || []).map((u) => u.username)
);
userNames.forEach((uname) => {
if (!existedUsers.has(uname)) {
adminConfig!.UserConfig.Users.push({
username: uname,
role: 'user',
});
}
});
// 站长
const ownerUser = process.env.USERNAME;
if (ownerUser) {
adminConfig!.UserConfig.Users = adminConfig!.UserConfig.Users.filter(
(u) => u.username !== ownerUser
);
adminConfig!.UserConfig.Users.unshift({
username: ownerUser,
role: 'owner',
});
}
} else {
// 数据库中没有配置,创建新的管理员配置
let allUsers = userNames.map((uname) => ({
username: uname,
role: 'user',
}));
const ownerUser = process.env.USERNAME;
if (ownerUser) {
allUsers = allUsers.filter((u) => u.username !== ownerUser);
allUsers.unshift({
username: ownerUser,
role: 'owner',
});
}
adminConfig = {
SiteConfig: {
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV',
Announcement:
process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
SearchDownstreamMaxPage:
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
DoubanProxyType:
process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct',
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
DoubanImageProxyType:
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct',
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
DisableYellowFilter:
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
},
UserConfig: {
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
Users: allUsers as any,
},
SourceConfig: apiSiteEntries.map(([key, site]) => ({
key,
name: site.name,
api: site.api,
detail: site.detail,
from: 'config',
disabled: false,
})),
CustomCategories: customCategories.map((category) => ({
name: category.name,
type: category.type,
query: category.query,
from: 'config',
disabled: false,
})),
};
}
// 写回数据库(更新/创建)
if (storage && typeof (storage as any).setAdminConfig === 'function') {
await (storage as any).setAdminConfig(adminConfig);
}
// 更新缓存
cachedConfig = adminConfig;
} catch (err) {
console.error('加载管理员配置失败:', err);
}
} else {
// 本地存储直接使用文件配置
cachedConfig = {
SiteConfig: {
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV',
Announcement:
process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
SearchDownstreamMaxPage:
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
DoubanProxyType: process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct',
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
DoubanImageProxyType:
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct',
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
DisableYellowFilter:
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
},
UserConfig: {
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
Users: [],
},
SourceConfig: Object.entries(fileConfig.api_site).map(([key, site]) => ({
key, key,
name: site.name, name: site.name,
api: site.api, api: site.api,
detail: site.detail, detail: site.detail,
from: 'config', from: 'config',
disabled: false, disabled: false,
})), });
CustomCategories: }
fileConfig.custom_category?.map((category) => ({ });
// 检查现有源是否在 fileConfig.api_site 中,如果不在则标记为 custom
const apiSiteKeys = new Set(apiSiteEntries.map(([key]) => key));
sourceConfigMap.forEach((source) => {
if (!apiSiteKeys.has(source.key)) {
source.from = 'custom';
}
});
// 将 Map 转换回数组
adminConfig.SourceConfig = Array.from(sourceConfigMap.values());
// 覆盖 CustomCategories
const customCategories = fileConfig.custom_category || [];
const customCategoriesMap = new Map(
(adminConfig.CustomCategories || []).map((c) => [c.query + c.type, c])
);
customCategories.forEach((category) => {
const key = category.query + category.type;
const existedCategory = customCategoriesMap.get(key);
if (existedCategory) {
existedCategory.name = category.name;
existedCategory.query = category.query;
existedCategory.type = category.type;
existedCategory.from = 'config';
} else {
customCategoriesMap.set(key, {
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)
);
customCategoriesMap.forEach((category) => {
if (!customCategoriesKeys.has(category.query + category.type)) {
category.from = 'custom';
}
});
// 将 Map 转换回数组
adminConfig.CustomCategories = Array.from(customCategoriesMap.values());
return adminConfig;
}
async function initConfig() {
if (cachedConfig) {
// 自检补全配置
cachedConfig = refineConfig(cachedConfig);
return;
}
// 数据库存储,读取并补全管理员配置
const storage = getStorage();
try {
// 尝试从数据库获取管理员配置
let adminConfig: AdminConfig | null = null;
if (storage && typeof (storage as any).getAdminConfig === 'function') {
adminConfig = await (storage as any).getAdminConfig();
}
// 获取所有用户名,用于补全 Users
let userNames: string[] = [];
if (storage && typeof (storage as any).getAllUsers === 'function') {
try {
userNames = await (storage as any).getAllUsers();
} catch (e) {
console.error('获取用户列表失败:', e);
}
}
if (adminConfig) {
try {
fileConfig = JSON.parse(adminConfig.ConfigFile) as ConfigFileStruct;
} catch (e) {
console.error('解析配置文件失败:', e);
fileConfig = {} as ConfigFileStruct;
}
const apiSiteEntries = Object.entries(fileConfig.api_site || []);
const customCategories = fileConfig.custom_category || [];
// 补全 SourceConfig
const sourceConfigMap = new Map(
(adminConfig.SourceConfig || []).map((s) => [s.key, s])
);
apiSiteEntries.forEach(([key, site]) => {
sourceConfigMap.set(key, {
key,
name: site.name,
api: site.api,
detail: site.detail,
from: 'config',
disabled: false,
});
});
// 将 Map 转换回数组
adminConfig.SourceConfig = Array.from(sourceConfigMap.values());
// 检查现有源是否在 fileConfig.api_site 中,如果不在则标记为 custom
const apiSiteKeys = new Set(apiSiteEntries.map(([key]) => key));
adminConfig.SourceConfig.forEach((source) => {
if (!apiSiteKeys.has(source.key)) {
source.from = 'custom';
}
});
// 确保 CustomCategories 被初始化
if (!adminConfig.CustomCategories) {
adminConfig.CustomCategories = [];
}
// 补全 CustomCategories
const customCategoriesMap = new Map(
adminConfig.CustomCategories.map((c) => [c.query + c.type, c])
);
customCategories.forEach((category) => {
customCategoriesMap.set(category.query + category.type, {
name: category.name, name: category.name,
type: category.type, type: category.type,
query: category.query, query: category.query,
from: 'config', from: 'config',
disabled: false, disabled: false,
})) || [], });
} as AdminConfig; });
// 检查现有 CustomCategories 是否在 fileConfig.custom_category 中,如果不在则标记为 custom
const customCategoriesKeys = new Set(
customCategories.map((c) => c.query + c.type)
);
customCategoriesMap.forEach((category) => {
if (!customCategoriesKeys.has(category.query + category.type)) {
category.from = 'custom';
}
});
// 将 Map 转换回数组
adminConfig.CustomCategories = Array.from(customCategoriesMap.values());
const existedUsers = new Set(
(adminConfig.UserConfig.Users || []).map((u) => u.username)
);
userNames.forEach((uname) => {
if (!existedUsers.has(uname)) {
adminConfig!.UserConfig.Users.push({
username: uname,
role: 'user',
});
}
});
// 站长
const ownerUser = process.env.USERNAME;
if (ownerUser) {
adminConfig!.UserConfig.Users = adminConfig!.UserConfig.Users.filter(
(u) => u.username !== ownerUser
);
adminConfig!.UserConfig.Users.unshift({
username: ownerUser,
role: 'owner',
});
}
} else {
fileConfig = {} as ConfigFileStruct;
// 数据库中没有配置,创建新的管理员配置
let allUsers = userNames.map((uname) => ({
username: uname,
role: 'user',
}));
const ownerUser = process.env.USERNAME;
if (ownerUser) {
allUsers = allUsers.filter((u) => u.username !== ownerUser);
allUsers.unshift({
username: ownerUser,
role: 'owner',
});
}
adminConfig = {
ConfigFile: '',
SiteConfig: {
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV',
Announcement:
process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
SearchDownstreamMaxPage:
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
DoubanProxyType:
process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct',
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
DoubanImageProxyType:
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct',
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
DisableYellowFilter:
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
},
UserConfig: {
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
Users: allUsers as any,
},
SourceConfig: [],
CustomCategories: [],
};
}
// 写回数据库(更新/创建)
if (storage && typeof (storage as any).setAdminConfig === 'function') {
await (storage as any).setAdminConfig(adminConfig);
}
// 更新缓存
cachedConfig = adminConfig;
} catch (err) {
console.error('加载管理员配置失败:', err);
} }
} }
export async function getConfig(): Promise<AdminConfig> { export async function getConfig(): Promise<AdminConfig> {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; if (process.env.DOCKER_ENV === 'true') {
if (process.env.DOCKER_ENV === 'true' || storageType === 'localstorage') {
await initConfig(); await initConfig();
return cachedConfig; return cachedConfig;
} }
@@ -320,9 +342,15 @@ export async function getConfig(): Promise<AdminConfig> {
adminConfig.SiteConfig.DisableYellowFilter = adminConfig.SiteConfig.DisableYellowFilter =
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true'; process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
try {
fileConfig = JSON.parse(adminConfig.ConfigFile) as ConfigFileStruct;
} catch (e) {
console.error('解析配置文件失败:', e);
fileConfig = {} as ConfigFileStruct;
}
// 合并文件中的源信息 // 合并文件中的源信息
fileConfig = runtimeConfig as unknown as ConfigFileStruct; const apiSiteEntries = Object.entries(fileConfig.api_site || []);
const apiSiteEntries = Object.entries(fileConfig.api_site);
const sourceConfigMap = new Map( const sourceConfigMap = new Map(
(adminConfig.SourceConfig || []).map((s) => [s.key, s]) (adminConfig.SourceConfig || []).map((s) => [s.key, s])
); );
@@ -398,8 +426,19 @@ export async function getConfig(): Promise<AdminConfig> {
} }
export async function resetConfig() { export async function resetConfig() {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; let originConfig: AdminConfig | null = null;
const storage = getStorage(); const storage = getStorage();
if (storage && typeof (storage as any).getAdminConfig === 'function') {
originConfig = await (storage as any).getAdminConfig();
}
if (originConfig) {
fileConfig = JSON.parse(originConfig.ConfigFile) as ConfigFileStruct;
} else {
fileConfig = {} as ConfigFileStruct;
}
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE;
// 获取所有用户名,用于补全 Users // 获取所有用户名,用于补全 Users
let userNames: string[] = []; let userNames: string[] = [];
if (storage && typeof (storage as any).getAllUsers === 'function') { if (storage && typeof (storage as any).getAllUsers === 'function') {
@@ -410,22 +449,7 @@ export async function resetConfig() {
} }
} }
if (process.env.DOCKER_ENV === 'true') { const apiSiteEntries = Object.entries(fileConfig.api_site || []);
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const _require = eval('require') as NodeRequire;
const fs = _require('fs') as typeof import('fs');
const path = _require('path') as typeof import('path');
const configPath = path.join(process.cwd(), 'config.json');
const raw = fs.readFileSync(configPath, 'utf-8');
fileConfig = JSON.parse(raw) as ConfigFileStruct;
console.log('load dynamic config success');
} else {
// 默认使用编译时生成的配置
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
}
const apiSiteEntries = Object.entries(fileConfig.api_site);
const customCategories = fileConfig.custom_category || []; const customCategories = fileConfig.custom_category || [];
let allUsers = userNames.map((uname) => ({ let allUsers = userNames.map((uname) => ({
username: uname, username: uname,
@@ -440,6 +464,7 @@ export async function resetConfig() {
}); });
} }
const adminConfig = { const adminConfig = {
ConfigFile: originConfig?.ConfigFile || '',
SiteConfig: { SiteConfig: {
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV', SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV',
Announcement: Announcement:
@@ -471,12 +496,12 @@ export async function resetConfig() {
CustomCategories: CustomCategories:
storageType === 'redis' storageType === 'redis'
? customCategories?.map((category) => ({ ? customCategories?.map((category) => ({
name: category.name, name: category.name,
type: category.type, type: category.type,
query: category.query, query: category.query,
from: 'config', from: 'config',
disabled: false, disabled: false,
})) || [] })) || []
: [], : [],
} as AdminConfig; } as AdminConfig;
@@ -487,10 +512,11 @@ export async function resetConfig() {
// serverless 环境,直接使用 adminConfig // serverless 环境,直接使用 adminConfig
cachedConfig = adminConfig; cachedConfig = adminConfig;
} }
cachedConfig.ConfigFile = adminConfig.ConfigFile;
cachedConfig.SiteConfig = adminConfig.SiteConfig; cachedConfig.SiteConfig = adminConfig.SiteConfig;
cachedConfig.UserConfig = adminConfig.UserConfig; cachedConfig.UserConfig = adminConfig.UserConfig;
cachedConfig.SourceConfig = adminConfig.SourceConfig; cachedConfig.SourceConfig = adminConfig.SourceConfig;
cachedConfig.CustomCategories = adminConfig.CustomCategories; cachedConfig.CustomCategories = adminConfig.CustomCategories || [];
} }
export async function getCacheTime(): Promise<number> { export async function getCacheTime(): Promise<number> {