feat: init 2.0.0 config file edit
This commit is contained in:
@@ -55,7 +55,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js
|
||||
# 从构建器中复制 public 和 .next/static 目录
|
||||
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/config.json ./config.json
|
||||
|
||||
# 切换到非特权用户
|
||||
USER nextjs
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm gen:runtime && pnpm gen:manifest && next dev -H 0.0.0.0",
|
||||
"build": "pnpm gen:runtime && pnpm gen:manifest && next build",
|
||||
"dev": "pnpm gen:manifest && next dev -H 0.0.0.0",
|
||||
"build": "pnpm gen:manifest && next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "eslint src --fix && pnpm format",
|
||||
@@ -14,13 +14,11 @@
|
||||
"test": "jest",
|
||||
"format": "prettier -w .",
|
||||
"format:check": "prettier -c .",
|
||||
"gen:runtime": "node scripts/convert-config.js",
|
||||
"gen:manifest": "node scripts/generate-manifest.js",
|
||||
"postbuild": "echo 'Build completed - sitemap generation disabled'",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/next-on-pages": "^1.13.12",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -89,4 +87,4 @@
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.14.0"
|
||||
}
|
||||
}
|
||||
2526
pnpm-lock.yaml
generated
2526
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Settings,
|
||||
Users,
|
||||
@@ -313,9 +314,8 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
||||
</h4>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label
|
||||
className={`text-gray-700 dark:text-gray-300 ${
|
||||
isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
className={`text-gray-700 dark:text-gray-300 ${isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
允许新用户注册
|
||||
{isUpstashStorage && (
|
||||
@@ -330,18 +330,16 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
||||
toggleAllowRegister(!userSettings.enableRegistration)
|
||||
}
|
||||
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 ${
|
||||
userSettings.enableRegistration
|
||||
? 'bg-green-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
} ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
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
|
||||
? 'bg-green-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
} ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
userSettings.enableRegistration
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1'
|
||||
}`}
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${userSettings.enableRegistration
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -521,28 +519,26 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
||||
</td>
|
||||
<td className='px-6 py-4 whitespace-nowrap'>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded-full ${
|
||||
user.role === 'owner'
|
||||
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
|
||||
: user.role === 'admin'
|
||||
className={`px-2 py-1 text-xs rounded-full ${user.role === 'owner'
|
||||
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
|
||||
: user.role === 'admin'
|
||||
? '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'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{user.role === 'owner'
|
||||
? '站长'
|
||||
: user.role === 'admin'
|
||||
? '管理员'
|
||||
: '普通用户'}
|
||||
? '管理员'
|
||||
: '普通用户'}
|
||||
</span>
|
||||
</td>
|
||||
<td className='px-6 py-4 whitespace-nowrap'>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded-full ${
|
||||
!user.banned
|
||||
? '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'
|
||||
}`}
|
||||
className={`px-2 py-1 text-xs rounded-full ${!user.banned
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
{!user.banned ? '正常' : '已封禁'}
|
||||
</span>
|
||||
@@ -793,11 +789,10 @@ const VideoSourceConfig = ({
|
||||
</td>
|
||||
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded-full ${
|
||||
!source.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'
|
||||
}`}
|
||||
className={`px-2 py-1 text-xs rounded-full ${!source.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'
|
||||
}`}
|
||||
>
|
||||
{!source.disabled ? '启用中' : '已禁用'}
|
||||
</span>
|
||||
@@ -805,11 +800,10 @@ const VideoSourceConfig = ({
|
||||
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
|
||||
<button
|
||||
onClick={() => handleToggleEnable(source.key)}
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${
|
||||
!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-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`}
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!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-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`}
|
||||
>
|
||||
{!source.disabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
@@ -1113,9 +1107,8 @@ const CategoryConfig = ({
|
||||
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
|
||||
>
|
||||
<td
|
||||
className={`px-2 py-4 ${
|
||||
isUpstashStorage ? 'text-gray-200' : 'cursor-grab text-gray-400'
|
||||
}`}
|
||||
className={`px-2 py-4 ${isUpstashStorage ? 'text-gray-200' : 'cursor-grab text-gray-400'
|
||||
}`}
|
||||
style={{ touchAction: 'none' }}
|
||||
{...(isUpstashStorage ? {} : { ...attributes, ...listeners })}
|
||||
>
|
||||
@@ -1126,11 +1119,10 @@ const CategoryConfig = ({
|
||||
</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'
|
||||
}`}
|
||||
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>
|
||||
@@ -1143,11 +1135,10 @@ const CategoryConfig = ({
|
||||
</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'
|
||||
}`}
|
||||
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>
|
||||
@@ -1159,13 +1150,12 @@ const CategoryConfig = ({
|
||||
handleToggleEnable(category.query, category.type)
|
||||
}
|
||||
disabled={isUpstashStorage}
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${
|
||||
isUpstashStorage
|
||||
? 'bg-gray-400 cursor-not-allowed text-white'
|
||||
: !category.disabled
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${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`}
|
||||
} transition-colors`}
|
||||
>
|
||||
{!category.disabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
@@ -1205,11 +1195,10 @@ const CategoryConfig = ({
|
||||
<button
|
||||
onClick={() => !isUpstashStorage && setShowAddForm(!showAddForm)}
|
||||
disabled={isUpstashStorage}
|
||||
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
|
||||
isUpstashStorage
|
||||
? 'bg-gray-400 cursor-not-allowed text-white'
|
||||
: 'bg-green-600 hover:bg-green-700 text-white'
|
||||
}`}
|
||||
className={`px-3 py-1 text-sm rounded-lg transition-colors ${isUpstashStorage
|
||||
? 'bg-gray-400 cursor-not-allowed text-white'
|
||||
: 'bg-green-600 hover:bg-green-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{showAddForm ? '取消' : '添加分类'}
|
||||
</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 [siteSettings, setSiteSettings] = useState<SiteConfig>({
|
||||
@@ -1500,9 +1575,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
||||
{/* 站点名称 */}
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||
isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
站点名称
|
||||
{isUpstashStorage && (
|
||||
@@ -1519,18 +1593,16 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
||||
setSiteSettings((prev) => ({ ...prev, SiteName: e.target.value }))
|
||||
}
|
||||
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 ${
|
||||
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
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' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 站点公告 */}
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||
isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
站点公告
|
||||
{isUpstashStorage && (
|
||||
@@ -1550,9 +1622,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
||||
}
|
||||
disabled={isUpstashStorage}
|
||||
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 ${
|
||||
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
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' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1560,9 +1631,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||
isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
豆瓣数据代理
|
||||
{isUpstashStorage && (
|
||||
@@ -1577,9 +1647,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
||||
type='button'
|
||||
onClick={() => setIsDoubanDropdownOpen(!isDoubanDropdownOpen)}
|
||||
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 ${
|
||||
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
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' : ''
|
||||
}`}
|
||||
>
|
||||
{
|
||||
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'>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
|
||||
isDoubanDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1608,11 +1676,10 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
||||
handleDoubanDataSourceChange(option.value);
|
||||
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 ${
|
||||
siteSettings.DoubanProxyType === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
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
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className='truncate'>{option.label}</span>
|
||||
{siteSettings.DoubanProxyType === option.value && (
|
||||
@@ -1653,9 +1720,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
||||
{siteSettings.DoubanProxyType === 'custom' && (
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||
isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
豆瓣代理地址
|
||||
</label>
|
||||
@@ -1671,9 +1737,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
||||
}))
|
||||
}
|
||||
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 ${
|
||||
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
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' : ''
|
||||
}`}
|
||||
/>
|
||||
<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>
|
||||
<label
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||
isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
豆瓣图片代理
|
||||
{isUpstashStorage && (
|
||||
@@ -1707,9 +1771,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
||||
)
|
||||
}
|
||||
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 ${
|
||||
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
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' : ''
|
||||
}`}
|
||||
>
|
||||
{
|
||||
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'>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
|
||||
isDoubanImageProxyDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanImageProxyDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1738,11 +1800,10 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
||||
handleDoubanImageProxyChange(option.value);
|
||||
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 ${
|
||||
siteSettings.DoubanImageProxyType === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
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
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className='truncate'>{option.label}</span>
|
||||
{siteSettings.DoubanImageProxyType === option.value && (
|
||||
@@ -1783,9 +1844,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
||||
{siteSettings.DoubanImageProxyType === 'custom' && (
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||
isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
豆瓣图片代理地址
|
||||
</label>
|
||||
@@ -1801,9 +1861,8 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
||||
}))
|
||||
}
|
||||
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 ${
|
||||
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
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' : ''
|
||||
}`}
|
||||
/>
|
||||
<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 className='flex items-center justify-between'>
|
||||
<label
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||
isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${isUpstashStorage ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
禁用黄色过滤器
|
||||
{isUpstashStorage && (
|
||||
@@ -1875,18 +1933,16 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
||||
}))
|
||||
}
|
||||
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 ${
|
||||
siteSettings.DisableYellowFilter
|
||||
? 'bg-green-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
} ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
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
|
||||
? 'bg-green-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
} ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
siteSettings.DisableYellowFilter
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1'
|
||||
}`}
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${siteSettings.DisableYellowFilter
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1900,11 +1956,10 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className={`px-4 py-2 ${
|
||||
saving
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-green-600 hover:bg-green-700'
|
||||
} text-white rounded-lg transition-colors`}
|
||||
className={`px-4 py-2 ${saving
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-green-600 hover:bg-green-700'
|
||||
} text-white rounded-lg transition-colors`}
|
||||
>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
@@ -1923,6 +1978,7 @@ function AdminPageClient() {
|
||||
videoSource: false,
|
||||
siteConfig: false,
|
||||
categoryConfig: false,
|
||||
configFile: false,
|
||||
});
|
||||
|
||||
// 获取管理员配置
|
||||
@@ -2036,6 +2092,21 @@ function AdminPageClient() {
|
||||
)}
|
||||
</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
|
||||
title='站点配置'
|
||||
|
||||
88
src/app/api/admin/config_file/route.ts
Normal file
88
src/app/api/admin/config_file/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/app/api/config/custom_category/route.ts
Normal file
13
src/app/api/config/custom_category/route.ts
Normal 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);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Suspense } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { GetBangumiCalendarData } from '@/lib/bangumi.client';
|
||||
import { getCustomCategories } from '@/lib/config.client';
|
||||
import {
|
||||
getDoubanCategories,
|
||||
getDoubanList,
|
||||
@@ -80,10 +81,9 @@ function DoubanPageClient() {
|
||||
|
||||
// 获取自定义分类数据
|
||||
useEffect(() => {
|
||||
const runtimeConfig = (window as any).RUNTIME_CONFIG;
|
||||
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) {
|
||||
setCustomCategories(runtimeConfig.CUSTOM_CATEGORIES);
|
||||
}
|
||||
getCustomCategories().then((categories) => {
|
||||
setCustomCategories(categories);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 同步最新参数值到 ref
|
||||
@@ -214,7 +214,7 @@ function DoubanPageClient() {
|
||||
snapshot1.selectedWeekday === snapshot2.selectedWeekday &&
|
||||
snapshot1.currentPage === snapshot2.currentPage &&
|
||||
JSON.stringify(snapshot1.multiLevelSelection) ===
|
||||
JSON.stringify(snapshot2.multiLevelSelection)
|
||||
JSON.stringify(snapshot2.multiLevelSelection)
|
||||
);
|
||||
},
|
||||
[]
|
||||
@@ -686,12 +686,12 @@ function DoubanPageClient() {
|
||||
return type === 'movie'
|
||||
? '电影'
|
||||
: type === 'tv'
|
||||
? '电视剧'
|
||||
: type === 'anime'
|
||||
? '动漫'
|
||||
: type === 'show'
|
||||
? '综艺'
|
||||
: '自定义';
|
||||
? '电视剧'
|
||||
: type === 'anime'
|
||||
? '动漫'
|
||||
: type === 'show'
|
||||
? '综艺'
|
||||
: '自定义';
|
||||
};
|
||||
|
||||
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'>
|
||||
{loading || !selectorsReady
|
||||
? // 显示骨架屏
|
||||
skeletonData.map((index) => <DoubanCardSkeleton key={index} />)
|
||||
skeletonData.map((index) => <DoubanCardSkeleton key={index} />)
|
||||
: // 显示实际数据
|
||||
doubanData.map((item, index) => (
|
||||
<div key={`${item.title}-${index}`} className='w-full'>
|
||||
<VideoCard
|
||||
from='douban'
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
douban_id={Number(item.id)}
|
||||
rate={item.rate}
|
||||
year={item.year}
|
||||
type={type === 'movie' ? 'movie' : ''} // 电影类型严格控制,tv 不控
|
||||
isBangumi={
|
||||
type === 'anime' && primarySelection === '每日放送'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
doubanData.map((item, index) => (
|
||||
<div key={`${item.title}-${index}`} className='w-full'>
|
||||
<VideoCard
|
||||
from='douban'
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
douban_id={Number(item.id)}
|
||||
rate={item.rate}
|
||||
year={item.year}
|
||||
type={type === 'movie' ? 'movie' : ''} // 电影类型严格控制,tv 不控
|
||||
isBangumi={
|
||||
type === 'anime' && primarySelection === '每日放送'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 加载更多指示器 */}
|
||||
|
||||
@@ -7,7 +7,6 @@ import './globals.css';
|
||||
import 'sweetalert2/dist/sweetalert2.min.css';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import RuntimeConfig from '@/lib/runtime';
|
||||
|
||||
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
|
||||
import { SiteProvider } from '../components/SiteProvider';
|
||||
@@ -39,6 +38,8 @@ export default async function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
|
||||
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
|
||||
let announcement =
|
||||
process.env.ANNOUNCEMENT ||
|
||||
@@ -51,13 +52,7 @@ export default async function RootLayout({
|
||||
let doubanImageProxy = process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '';
|
||||
let disableYellowFilter =
|
||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
|
||||
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 !== 'upstash') {
|
||||
if (storageType !== 'upstash' && storageType !== 'localstorage') {
|
||||
const config = await getConfig();
|
||||
siteName = config.SiteConfig.SiteName;
|
||||
announcement = config.SiteConfig.Announcement;
|
||||
@@ -67,13 +62,6 @@ export default async function RootLayout({
|
||||
doubanImageProxyType = config.SiteConfig.DoubanImageProxyType;
|
||||
doubanImageProxy = config.SiteConfig.DoubanImageProxy;
|
||||
disableYellowFilter = config.SiteConfig.DisableYellowFilter;
|
||||
customCategories = config.CustomCategories.filter(
|
||||
(category) => !category.disabled
|
||||
).map((category) => ({
|
||||
name: category.name || '',
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
}));
|
||||
}
|
||||
|
||||
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||
@@ -85,7 +73,6 @@ export default async function RootLayout({
|
||||
DOUBAN_IMAGE_PROXY_TYPE: doubanImageProxyType,
|
||||
DOUBAN_IMAGE_PROXY: doubanImageProxy,
|
||||
DISABLE_YELLOW_FILTER: disableYellowFilter,
|
||||
CUSTOM_CATEGORIES: customCategories,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,6 +7,8 @@ import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getCustomCategories } from '@/lib/config.client';
|
||||
|
||||
interface MobileBottomNavProps {
|
||||
/**
|
||||
* 主动指定当前激活的路径。当未提供时,自动使用 usePathname() 获取的路径。
|
||||
@@ -46,17 +48,18 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const runtimeConfig = (window as any).RUNTIME_CONFIG;
|
||||
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) {
|
||||
setNavItems((prevItems) => [
|
||||
...prevItems,
|
||||
{
|
||||
icon: Star,
|
||||
label: '自定义',
|
||||
href: '/douban?type=custom',
|
||||
},
|
||||
]);
|
||||
}
|
||||
getCustomCategories().then((categories) => {
|
||||
if (categories.length > 0) {
|
||||
setNavItems((prevItems) => [
|
||||
...prevItems,
|
||||
{
|
||||
icon: Star,
|
||||
label: '自定义',
|
||||
href: '/douban?type=custom',
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
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'
|
||||
>
|
||||
<item.icon
|
||||
className={`h-6 w-6 ${
|
||||
active
|
||||
className={`h-6 w-6 ${active
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { getCustomCategories } from '@/lib/config.client';
|
||||
|
||||
import { useSite } from './SiteProvider';
|
||||
|
||||
interface SidebarContextType {
|
||||
@@ -148,17 +150,18 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const runtimeConfig = (window as any).RUNTIME_CONFIG;
|
||||
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) {
|
||||
setMenuItems((prevItems) => [
|
||||
...prevItems,
|
||||
{
|
||||
icon: Star,
|
||||
label: '自定义',
|
||||
href: '/douban?type=custom',
|
||||
},
|
||||
]);
|
||||
}
|
||||
getCustomCategories().then((categories) => {
|
||||
if (categories.length > 0) {
|
||||
setMenuItems((prevItems) => [
|
||||
...prevItems,
|
||||
{
|
||||
icon: Star,
|
||||
label: '自定义',
|
||||
href: '/douban?type=custom',
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -167,9 +170,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
<div className='hidden md:flex'>
|
||||
<aside
|
||||
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 ${
|
||||
isCollapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
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'
|
||||
}`}
|
||||
style={{
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
@@ -179,9 +181,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
{/* 顶部 Logo 区域 */}
|
||||
<div className='relative h-16'>
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${
|
||||
isCollapsed ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${isCollapsed ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<div className='w-[calc(100%-4rem)] flex justify-center'>
|
||||
{!isCollapsed && <Logo />}
|
||||
@@ -189,9 +190,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
</div>
|
||||
<button
|
||||
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 ${
|
||||
isCollapsed ? 'left-1/2 -translate-x-1/2' : 'right-2'
|
||||
}`}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<Menu className='h-4 w-4' />
|
||||
</button>
|
||||
@@ -203,9 +203,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
href='/'
|
||||
onClick={() => setActive('/')}
|
||||
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 ${
|
||||
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
|
||||
} gap-3 justify-start`}
|
||||
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'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<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' />
|
||||
@@ -224,9 +223,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
setActive('/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 ${
|
||||
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
|
||||
} gap-3 justify-start`}
|
||||
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'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<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' />
|
||||
@@ -261,9 +259,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
href={item.href}
|
||||
onClick={() => setActive(item.href)}
|
||||
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 ${
|
||||
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
|
||||
} gap-3 justify-start`}
|
||||
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'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<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' />
|
||||
@@ -281,9 +278,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
</div>
|
||||
</aside>
|
||||
<div
|
||||
className={`transition-all duration-300 sidebar-offset ${
|
||||
isCollapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
className={`transition-all duration-300 sidebar-offset ${isCollapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface AdminConfig {
|
||||
ConfigFile: string;
|
||||
SiteConfig: {
|
||||
SiteName: string;
|
||||
Announcement: string;
|
||||
|
||||
17
src/lib/config.client.ts
Normal file
17
src/lib/config.client.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import { getStorage } from '@/lib/db';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import runtimeConfig from './runtime';
|
||||
|
||||
export interface ApiSite {
|
||||
key: string;
|
||||
@@ -14,7 +13,7 @@ export interface ApiSite {
|
||||
|
||||
interface ConfigFileStruct {
|
||||
cache_time?: number;
|
||||
api_site: {
|
||||
api_site?: {
|
||||
[key: string]: ApiSite;
|
||||
};
|
||||
custom_category?: {
|
||||
@@ -48,244 +47,267 @@ export const API_CONFIG = {
|
||||
let fileConfig: ConfigFileStruct;
|
||||
let cachedConfig: AdminConfig;
|
||||
|
||||
async function initConfig() {
|
||||
if (cachedConfig) {
|
||||
return;
|
||||
export function refineConfig(adminConfig: AdminConfig): AdminConfig {
|
||||
try {
|
||||
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') {
|
||||
// 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 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]) => ({
|
||||
apiSiteEntries.forEach(([key, site]) => {
|
||||
const existingSource = sourceConfigMap.get(key);
|
||||
if (existingSource) {
|
||||
// 如果已存在,只覆盖 name、api、detail 和 from
|
||||
existingSource.name = site.name;
|
||||
existingSource.api = site.api;
|
||||
existingSource.detail = site.detail;
|
||||
existingSource.from = 'config';
|
||||
} else {
|
||||
// 如果不存在,创建新条目
|
||||
sourceConfigMap.set(key, {
|
||||
key,
|
||||
name: site.name,
|
||||
api: site.api,
|
||||
detail: site.detail,
|
||||
from: 'config',
|
||||
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,
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
from: 'config',
|
||||
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> {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (process.env.DOCKER_ENV === 'true' || storageType === 'localstorage') {
|
||||
if (process.env.DOCKER_ENV === 'true') {
|
||||
await initConfig();
|
||||
return cachedConfig;
|
||||
}
|
||||
@@ -320,9 +342,15 @@ export async function getConfig(): Promise<AdminConfig> {
|
||||
adminConfig.SiteConfig.DisableYellowFilter =
|
||||
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(
|
||||
(adminConfig.SourceConfig || []).map((s) => [s.key, s])
|
||||
);
|
||||
@@ -398,8 +426,19 @@ export async function getConfig(): Promise<AdminConfig> {
|
||||
}
|
||||
|
||||
export async function resetConfig() {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
let originConfig: AdminConfig | null = null;
|
||||
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
|
||||
let userNames: string[] = [];
|
||||
if (storage && typeof (storage as any).getAllUsers === 'function') {
|
||||
@@ -410,22 +449,7 @@ export async function resetConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.DOCKER_ENV === 'true') {
|
||||
// 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 apiSiteEntries = Object.entries(fileConfig.api_site || []);
|
||||
const customCategories = fileConfig.custom_category || [];
|
||||
let allUsers = userNames.map((uname) => ({
|
||||
username: uname,
|
||||
@@ -440,6 +464,7 @@ export async function resetConfig() {
|
||||
});
|
||||
}
|
||||
const adminConfig = {
|
||||
ConfigFile: originConfig?.ConfigFile || '',
|
||||
SiteConfig: {
|
||||
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV',
|
||||
Announcement:
|
||||
@@ -471,12 +496,12 @@ export async function resetConfig() {
|
||||
CustomCategories:
|
||||
storageType === 'redis'
|
||||
? customCategories?.map((category) => ({
|
||||
name: category.name,
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
})) || []
|
||||
name: category.name,
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
})) || []
|
||||
: [],
|
||||
} as AdminConfig;
|
||||
|
||||
@@ -487,10 +512,11 @@ export async function resetConfig() {
|
||||
// serverless 环境,直接使用 adminConfig
|
||||
cachedConfig = adminConfig;
|
||||
}
|
||||
cachedConfig.ConfigFile = adminConfig.ConfigFile;
|
||||
cachedConfig.SiteConfig = adminConfig.SiteConfig;
|
||||
cachedConfig.UserConfig = adminConfig.UserConfig;
|
||||
cachedConfig.SourceConfig = adminConfig.SourceConfig;
|
||||
cachedConfig.CustomCategories = adminConfig.CustomCategories;
|
||||
cachedConfig.CustomCategories = adminConfig.CustomCategories || [];
|
||||
}
|
||||
|
||||
export async function getCacheTime(): Promise<number> {
|
||||
|
||||
Reference in New Issue
Block a user