feat: admin config subscription

This commit is contained in:
shinya
2025-08-13 22:07:28 +08:00
parent f580f8d205
commit 82c86fc6a0
14 changed files with 227 additions and 259 deletions

View File

@@ -28,6 +28,7 @@
"@upstash/redis": "^1.25.0",
"@vidstack/react": "^1.12.13",
"artplayer": "^5.2.5",
"bs58": "^6.0.0",
"clsx": "^2.0.0",
"framer-motion": "^12.18.1",
"he": "^1.2.0",
@@ -54,6 +55,7 @@
"@tailwindcss/forms": "^0.5.10",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^15.0.7",
"@types/bs58": "^5.0.0",
"@types/he": "^1.2.3",
"@types/node": "24.0.3",
"@types/react": "^18.3.18",

26
pnpm-lock.yaml generated
View File

@@ -35,6 +35,9 @@ importers:
artplayer:
specifier: ^5.2.5
version: 5.2.5
bs58:
specifier: ^6.0.0
version: 6.0.0
clsx:
specifier: ^2.0.0
version: 2.1.1
@@ -108,6 +111,9 @@ importers:
'@testing-library/react':
specifier: ^15.0.7
version: 15.0.7(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/bs58':
specifier: ^5.0.0
version: 5.0.0
'@types/he':
specifier: ^1.2.3
version: 1.2.3
@@ -1456,6 +1462,10 @@ packages:
'@types/babel__traverse@7.20.7':
resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==}
'@types/bs58@5.0.0':
resolution: {integrity: sha512-cAw/jKBzo98m6Xz1X5ETqymWfIMbXbu6nK15W4LQYjeHJkVqSmM5PO8Bd9KVHQJ/F4rHcSso9LcjtgCW6TGu2w==}
deprecated: This is a stub types definition. bs58 provides its own type definitions, so you do not need this installed.
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -2030,6 +2040,9 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base-x@5.0.1:
resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==}
big.js@5.2.2:
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
@@ -2063,6 +2076,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
bs58@6.0.0:
resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==}
bser@2.1.1:
resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==}
@@ -6747,6 +6763,10 @@ snapshots:
dependencies:
'@babel/types': 7.27.6
'@types/bs58@5.0.0':
dependencies:
bs58: 6.0.0
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
@@ -7387,6 +7407,8 @@ snapshots:
balanced-match@1.0.2: {}
base-x@5.0.1: {}
big.js@5.2.2: {}
binary-extensions@2.3.0: {}
@@ -7422,6 +7444,10 @@ snapshots:
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.2)
bs58@6.0.0:
dependencies:
base-x: 5.0.1
bser@2.1.1:
dependencies:
node-int64: 0.4.0

View File

@@ -149,11 +149,6 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
// 当前登录用户名
const currentUsername = getAuthInfoFromBrowserCookie()?.username || null;
// 检测存储类型是否为 upstash
const isUpstashStorage =
typeof window !== 'undefined' &&
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash';
useEffect(() => {
if (config?.UserConfig) {
setUserSettings({
@@ -314,26 +309,19 @@ 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 && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<button
onClick={() =>
!isUpstashStorage &&
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' : ''}`}
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${userSettings.enableRegistration
@@ -976,11 +964,6 @@ const CategoryConfig = ({
from: 'config',
});
// 检测存储类型是否为 upstash
const isUpstashStorage =
typeof window !== 'undefined' &&
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash';
// dnd-kit 传感器
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -1066,7 +1049,6 @@ const CategoryConfig = ({
};
const handleDragEnd = (event: any) => {
if (isUpstashStorage) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = categories.findIndex(
@@ -1107,10 +1089,9 @@ 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 cursor-grab text-gray-400"
style={{ touchAction: 'none' }}
{...(isUpstashStorage ? {} : { ...attributes, ...listeners })}
{...{ ...attributes, ...listeners }}
>
<GripVertical size={16} />
</td>
@@ -1146,20 +1127,16 @@ const CategoryConfig = ({
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button
onClick={() =>
!isUpstashStorage &&
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
? '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'
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!category.disabled
? 'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/60'
: 'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/60'
} transition-colors`}
>
{!category.disabled ? '禁用' : '启用'}
</button>
{category.from !== 'config' && !isUpstashStorage && (
{category.from !== 'config' && (
<button
onClick={() => handleDelete(category.query, category.type)}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors'
@@ -1186,25 +1163,16 @@ const CategoryConfig = ({
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
{isUpstashStorage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</h4>
<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'
}`}
onClick={() => setShowAddForm(!showAddForm)}
className="px-3 py-1 text-sm rounded-lg transition-colors bg-green-600 hover:bg-green-700 text-white"
>
{showAddForm ? '取消' : '添加分类'}
</button>
</div>
{showAddForm && !isUpstashStorage && (
{showAddForm && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<input
@@ -1275,7 +1243,7 @@ const CategoryConfig = ({
</tr>
</thead>
<DndContext
sensors={isUpstashStorage ? [] : sensors}
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
autoScroll={false}
@@ -1299,7 +1267,7 @@ const CategoryConfig = ({
</div>
{/* 保存排序按钮 */}
{orderChanged && !isUpstashStorage && (
{orderChanged && (
<div className='flex justify-end'>
<button
onClick={handleSaveOrder}
@@ -1419,7 +1387,7 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
</h3>
<div className='text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700 px-3 py-1.5 rounded-full'>
<div className='text-sm text-gray-500 dark:text-gray-400 px-3 py-1.5 rounded-full'>
: {lastCheckTime ? new Date(lastCheckTime).toLocaleString('zh-CN') : '从未更新'}
</div>
</div>
@@ -1438,10 +1406,31 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
className='w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all duration-200 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
/>
<p className='mt-2 text-xs text-gray-500 dark:text-gray-400'>
JSON格式
JSON 使 Base58
</p>
</div>
{/* 拉取配置按钮 */}
<div className='pt-2'>
<button
onClick={handleFetchConfig}
disabled={fetching || !subscriptionUrl.trim()}
className={`w-full px-6 py-3 rounded-lg font-medium transition-all duration-200 ${fetching || !subscriptionUrl.trim()
? 'bg-gray-300 dark:bg-gray-600 cursor-not-allowed text-gray-500 dark:text-gray-400'
: 'bg-green-600 hover:bg-green-700 text-white shadow-sm hover:shadow-md transform hover:-translate-y-0.5'
}`}
>
{fetching ? (
<div className='flex items-center justify-center gap-2'>
<div className='w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin'></div>
</div>
) : (
'拉取配置'
)}
</button>
</div>
{/* 自动更新开关 */}
<div className='flex items-center justify-between'>
<div>
@@ -1468,27 +1457,6 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
/>
</button>
</div>
{/* 拉取配置按钮 */}
<div className='pt-2'>
<button
onClick={handleFetchConfig}
disabled={fetching || !subscriptionUrl.trim()}
className={`w-full px-6 py-3 rounded-lg font-medium transition-all duration-200 ${fetching || !subscriptionUrl.trim()
? 'bg-gray-300 dark:bg-gray-600 cursor-not-allowed text-gray-500 dark:text-gray-400'
: 'bg-green-600 hover:bg-green-700 text-white shadow-sm hover:shadow-md transform hover:-translate-y-0.5'
}`}
>
{fetching ? (
<div className='flex items-center justify-center gap-2'>
<div className='w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin'></div>
</div>
) : (
'拉取配置'
)}
</button>
</div>
</div>
</div>
@@ -1521,7 +1489,7 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
: 'bg-green-600 hover:bg-green-700 text-white'
}`}
>
{saving ? '保存中…' : '保存配置文件'}
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
@@ -1530,7 +1498,7 @@ const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig |
};
// 新增站点配置组件
const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise<void> }) => {
const [siteSettings, setSiteSettings] = useState<SiteConfig>({
SiteName: '',
Announcement: '',
@@ -1595,11 +1563,6 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
}
};
// 检测存储类型是否为 upstash
const isUpstashStorage =
typeof window !== 'undefined' &&
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash';
useEffect(() => {
if (config?.SiteConfig) {
setSiteSettings({
@@ -1651,22 +1614,18 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
// 处理豆瓣数据源变化
const handleDoubanDataSourceChange = (value: string) => {
if (!isUpstashStorage) {
setSiteSettings((prev) => ({
...prev,
DoubanProxyType: value,
}));
}
setSiteSettings((prev) => ({
...prev,
DoubanProxyType: value,
}));
};
// 处理豆瓣图片代理变化
const handleDoubanImageProxyChange = (value: string) => {
if (!isUpstashStorage) {
setSiteSettings((prev) => ({
...prev,
DoubanImageProxyType: value,
}));
}
setSiteSettings((prev) => ({
...prev,
DoubanImageProxyType: value,
}));
};
// 保存站点配置
@@ -1685,6 +1644,7 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
}
showSuccess('保存成功, 请刷新页面');
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '保存失败');
} finally {
@@ -1705,55 +1665,37 @@ 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 && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<input
type='text'
value={siteSettings.SiteName}
onChange={(e) =>
!isUpstashStorage &&
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"
/>
</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 && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<textarea
value={siteSettings.Announcement}
onChange={(e) =>
!isUpstashStorage &&
setSiteSettings((prev) => ({
...prev,
Announcement: e.target.value,
}))
}
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"
/>
</div>
@@ -1761,24 +1703,16 @@ 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 && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<div className='relative' data-dropdown='douban-datasource'>
{/* 自定义下拉选择框 */}
<button
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"
>
{
doubanDataSourceOptions.find(
@@ -1796,7 +1730,7 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
</div>
{/* 下拉选项列表 */}
{isDoubanDropdownOpen && !isUpstashStorage && (
{isDoubanDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanDataSourceOptions.map((option) => (
<button
@@ -1850,8 +1784,7 @@ 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"
>
</label>
@@ -1860,15 +1793,12 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
placeholder='例如: https://proxy.example.com/fetch?url='
value={siteSettings.DoubanProxy}
onChange={(e) =>
!isUpstashStorage &&
setSiteSettings((prev) => ({
...prev,
DoubanProxy: e.target.value,
}))
}
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"
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
@@ -1881,15 +1811,9 @@ 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 && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<div className='relative' data-dropdown='douban-image-proxy'>
{/* 自定义下拉选择框 */}
@@ -1900,9 +1824,7 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
!isDoubanImageProxyDropdownOpen
)
}
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"
>
{
doubanImageProxyTypeOptions.find(
@@ -1920,7 +1842,7 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
</div>
{/* 下拉选项列表 */}
{isDoubanImageProxyDropdownOpen && !isUpstashStorage && (
{isDoubanImageProxyDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanImageProxyTypeOptions.map((option) => (
<button
@@ -1974,8 +1896,7 @@ 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"
>
</label>
@@ -1984,15 +1905,12 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
placeholder='例如: https://proxy.example.com/fetch?url='
value={siteSettings.DoubanImageProxy}
onChange={(e) =>
!isUpstashStorage &&
setSiteSettings((prev) => ({
...prev,
DoubanImageProxy: e.target.value,
}))
}
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"
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
@@ -2043,30 +1961,22 @@ 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 && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<button
type='button'
onClick={() =>
!isUpstashStorage &&
setSiteSettings((prev) => ({
...prev,
DisableYellowFilter: !prev.DisableYellowFilter,
}))
}
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' : ''}`}
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${siteSettings.DisableYellowFilter
@@ -2171,6 +2081,7 @@ function AdminPageClient() {
throw new Error(`重置失败: ${response.status}`);
}
showSuccess('重置成功,请刷新页面!');
await fetchConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '重置失败');
}
@@ -2249,7 +2160,7 @@ function AdminPageClient() {
isExpanded={expandedTabs.siteConfig}
onToggle={() => toggleTab('siteConfig')}
>
<SiteConfigComponent config={config} />
<SiteConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
<div className='space-y-4'>

View File

@@ -60,6 +60,13 @@ export async function POST(request: NextRequest) {
}
adminConfig.ConfigFile = configFile;
if (!adminConfig.ConfigSubscribtion) {
adminConfig.ConfigSubscribtion = {
URL: '',
AutoUpdate: false,
LastCheck: '',
};
}
// 更新订阅配置
if (subscriptionUrl !== undefined) {
@@ -68,7 +75,6 @@ export async function POST(request: NextRequest) {
if (autoUpdate !== undefined) {
adminConfig.ConfigSubscribtion.AutoUpdate = autoUpdate;
}
// 更新最后检查时间 - 使用前端传递的时间或当前时间
adminConfig.ConfigSubscribtion.LastCheck = lastCheckTime || '';
adminConfig = refineConfig(adminConfig);

View File

@@ -20,9 +20,20 @@ export async function POST(request: NextRequest) {
const configContent = await response.text();
// 对 configContent 进行 base58 解码
let decodedContent;
try {
const bs58 = (await import('bs58')).default;
const decodedBytes = bs58.decode(configContent);
decodedContent = new TextDecoder().decode(decodedBytes);
} catch (decodeError) {
console.warn('Base58 解码失败,返回原始内容:', decodeError);
throw decodeError;
}
return NextResponse.json({
success: true,
configContent,
configContent: decodedContent,
message: '配置拉取成功'
});

View File

@@ -1,13 +0,0 @@
/* 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

@@ -2,9 +2,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { db, getStorage } from '@/lib/db';
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
import { SearchResult } from '@/lib/types';
import { getConfig, refineConfig } from '@/lib/config';
export const runtime = 'edge';
@@ -13,7 +14,7 @@ export async function GET(request: NextRequest) {
try {
console.log('Cron job triggered:', new Date().toISOString());
refreshRecordAndFavorites();
cronJob();
return NextResponse.json({
success: true,
@@ -35,14 +36,54 @@ export async function GET(request: NextRequest) {
}
}
async function refreshRecordAndFavorites() {
if (
(process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage') === 'localstorage'
) {
console.log('跳过刷新:当前使用 localstorage 存储模式');
return;
}
async function cronJob() {
await refreshConfig();
await refreshRecordAndFavorites();
}
async function refreshConfig() {
let config = await getConfig();
if (config && config.ConfigSubscribtion && config.ConfigSubscribtion.URL && config.ConfigSubscribtion.AutoUpdate) {
try {
const response = await fetch(config.ConfigSubscribtion.URL);
if (!response.ok) {
throw new Error(`请求失败: ${response.status} ${response.statusText}`);
}
const configContent = await response.text();
// 对 configContent 进行 base58 解码
let decodedContent;
try {
const bs58 = (await import('bs58')).default;
const decodedBytes = bs58.decode(configContent);
decodedContent = new TextDecoder().decode(decodedBytes);
} catch (decodeError) {
console.warn('Base58 解码失败:', decodeError);
throw decodeError;
}
try {
JSON.parse(decodedContent);
} catch (e) {
throw new Error('配置文件格式错误,请检查 JSON 语法');
}
config.ConfigFile = decodedContent;
config = refineConfig(config);
const storage = getStorage();
if (storage && typeof (storage as any).setAdminConfig === 'function') {
await (storage as any).setAdminConfig(config);
}
} catch (e) {
console.error('刷新配置失败:', e);
}
} else {
console.log('跳过刷新:未配置订阅地址或自动更新');
}
}
async function refreshRecordAndFavorites() {
try {
const users = await db.getAllUsers();
if (process.env.USERNAME && !users.includes(process.env.USERNAME)) {

View File

@@ -7,7 +7,6 @@ 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,
@@ -81,9 +80,10 @@ function DoubanPageClient() {
// 获取自定义分类数据
useEffect(() => {
getCustomCategories().then((categories) => {
setCustomCategories(categories);
});
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) {
setCustomCategories(runtimeConfig.CUSTOM_CATEGORIES);
}
}, []);
// 同步最新参数值到 ref

View File

@@ -16,9 +16,10 @@ const inter = Inter({ subsets: ['latin'] });
// 动态生成 metadata支持配置更新后的标题变化
export async function generateMetadata(): Promise<Metadata> {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
const config = await getConfig();
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
if (process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'upstash') {
const config = await getConfig();
if (storageType !== 'localstorage') {
siteName = config.SiteConfig.SiteName;
}
@@ -52,7 +53,12 @@ export default async function RootLayout({
let doubanImageProxy = process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '';
let disableYellowFilter =
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
if (storageType !== 'upstash' && storageType !== 'localstorage') {
let customCategories = [] as {
name: string;
type: 'movie' | 'tv';
query: string;
}[];
if (storageType !== 'localstorage') {
const config = await getConfig();
siteName = config.SiteConfig.SiteName;
announcement = config.SiteConfig.Announcement;
@@ -62,6 +68,13 @@ 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 对象,供客户端在运行时读取
@@ -73,6 +86,7 @@ export default async function RootLayout({
DOUBAN_IMAGE_PROXY_TYPE: doubanImageProxyType,
DOUBAN_IMAGE_PROXY: doubanImageProxy,
DISABLE_YELLOW_FILTER: disableYellowFilter,
CUSTOM_CATEGORIES: customCategories,
};
return (

View File

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

View File

@@ -14,8 +14,6 @@ import {
useState,
} from 'react';
import { getCustomCategories } from '@/lib/config.client';
import { useSite } from './SiteProvider';
interface SidebarContextType {
@@ -150,18 +148,17 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
]);
useEffect(() => {
getCustomCategories().then((categories) => {
if (categories.length > 0) {
setMenuItems((prevItems) => [
...prevItems,
{
icon: Star,
label: '自定义',
href: '/douban?type=custom',
},
]);
}
});
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) {
setMenuItems((prevItems) => [
...prevItems,
{
icon: Star,
label: '自定义',
href: '/douban?type=custom',
},
]);
}
}, []);
return (

View File

@@ -432,13 +432,12 @@ export const UserMenu: React.FC = () => {
</span>
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${
(authInfo?.role || 'user') === 'owner'
className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${(authInfo?.role || 'user') === 'owner'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
: (authInfo?.role || 'user') === 'admin'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
}`}
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
}`}
>
{getRoleText(authInfo?.role || 'user')}
</span>
@@ -517,13 +516,12 @@ export const UserMenu: React.FC = () => {
updateStatus &&
updateStatus !== UpdateStatus.FETCH_FAILED && (
<div
className={`w-2 h-2 rounded-full -translate-y-2 ${
updateStatus === UpdateStatus.HAS_UPDATE
className={`w-2 h-2 rounded-full -translate-y-2 ${updateStatus === UpdateStatus.HAS_UPDATE
? 'bg-yellow-500'
: updateStatus === UpdateStatus.NO_UPDATE
? 'bg-green-400'
: ''
}`}
? 'bg-green-400'
: ''
}`}
></div>
)}
</div>
@@ -555,7 +553,7 @@ export const UserMenu: React.FC = () => {
className='px-2 py-1 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border border-red-200 hover:border-red-300 dark:border-red-800 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors'
title='重置为默认设置'
>
</button>
</div>
<button
@@ -596,9 +594,8 @@ export const UserMenu: React.FC = () => {
{/* 下拉箭头 */}
<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>
@@ -613,11 +610,10 @@ export const UserMenu: React.FC = () => {
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 ${
doubanDataSource === option.value
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 ${doubanDataSource === 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>
{doubanDataSource === option.value && (
@@ -703,9 +699,8 @@ export const UserMenu: React.FC = () => {
{/* 下拉箭头 */}
<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>
@@ -720,11 +715,10 @@ export const UserMenu: React.FC = () => {
handleDoubanImageProxyTypeChange(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 ${
doubanImageProxyType === option.value
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 ${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>
{doubanImageProxyType === option.value && (

View File

@@ -1,17 +0,0 @@
/* 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

@@ -149,7 +149,7 @@ async function getInitConfig(configFile: string, subConfig: {
} catch (e) {
cfgFile = {} as ConfigFileStruct;
}
let adminConfig: AdminConfig = {
const adminConfig: AdminConfig = {
ConfigFile: configFile,
ConfigSubscribtion: subConfig,
SiteConfig: {
@@ -253,15 +253,14 @@ export async function resetConfig() {
originConfig = await (storage as any).getAdminConfig();
} else {
originConfig = {} as AdminConfig;
const adminConfig = await getInitConfig(originConfig.ConfigFile, originConfig.ConfigSubscribtion);
}
const adminConfig = await getInitConfig(originConfig.ConfigFile, originConfig.ConfigSubscribtion);
cachedConfig = adminConfig;
if (storage && typeof (storage as any).setAdminConfig === 'function') {
await (storage as any).setAdminConfig(adminConfig);
}
return;
}
}
export async function getCacheTime(): Promise<number> {