mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-21 11:43:41 +08:00
feat: Implement user-defined video sources and danmaku API management with new settings pages and a dedicated store.
This commit is contained in:
@@ -80,6 +80,7 @@ export function PasswordGate({ children, hasAuth: initialHasAuth }: { children:
|
||||
profileId: data.profileId,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
customPermissions: data.customPermissions,
|
||||
}, data.persistSession ?? persistSession);
|
||||
|
||||
// Reload to re-initialize stores with profiled keys
|
||||
|
||||
@@ -16,12 +16,12 @@ const HLS_LIVE_CONFIG: Partial<Hls['config']> = {
|
||||
lowLatencyMode: true,
|
||||
liveDurationInfinity: true,
|
||||
manifestLoadingTimeOut: 10000,
|
||||
manifestLoadingMaxRetry: 2,
|
||||
manifestLoadingMaxRetry: 3,
|
||||
levelLoadingTimeOut: 10000,
|
||||
fragLoadingTimeOut: 15000,
|
||||
fragLoadingTimeOut: 20000,
|
||||
};
|
||||
|
||||
const LOADING_TIMEOUT_MS = 20000;
|
||||
const LOADING_TIMEOUT_MS = 30000;
|
||||
|
||||
interface IPTVPlayerProps {
|
||||
channel: M3UChannel;
|
||||
@@ -30,8 +30,12 @@ interface IPTVPlayerProps {
|
||||
onChannelChange: (channel: M3UChannel) => void;
|
||||
}
|
||||
|
||||
function getProxiedUrl(url: string): string {
|
||||
return `/api/iptv/stream?url=${encodeURIComponent(url)}`;
|
||||
function getProxiedUrl(url: string, ua?: string, referer?: string): string {
|
||||
let proxyUrl = `/api/iptv/stream?`;
|
||||
if (ua) proxyUrl += `ua=${encodeURIComponent(ua)}&`;
|
||||
if (referer) proxyUrl += `referer=${encodeURIComponent(referer)}&`;
|
||||
proxyUrl += `url=${encodeURIComponent(url)}`;
|
||||
return proxyUrl;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -161,7 +165,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
|
||||
const proxiedUrl = getProxiedUrl(url);
|
||||
const proxiedUrl = getProxiedUrl(url, channel.httpUserAgent, channel.httpReferrer);
|
||||
|
||||
// Global loading timeout
|
||||
let loadingResolved = false;
|
||||
@@ -309,7 +313,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
|
||||
}, { once: true });
|
||||
}, { once: true });
|
||||
}
|
||||
}, []);
|
||||
}, [channel.httpUserAgent, channel.httpReferrer]);
|
||||
|
||||
// Load on channel/route change
|
||||
useEffect(() => {
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface SourceInfo {
|
||||
sourceName?: string;
|
||||
latency?: number;
|
||||
pic?: string;
|
||||
typeName?: string;
|
||||
}
|
||||
|
||||
interface EpisodeListProps {
|
||||
@@ -47,6 +48,7 @@ export function EpisodeList({
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [sourceExpanded, setSourceExpanded] = useState(false);
|
||||
const [showAllSources, setShowAllSources] = useState(false);
|
||||
|
||||
// Source latency state
|
||||
const [latencies, setLatencies] = useState<Record<string, number>>({});
|
||||
@@ -233,74 +235,184 @@ export function EpisodeList({
|
||||
刷新延迟
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-[200px] overflow-y-auto">
|
||||
{sortedSources.map((source, index) => {
|
||||
const isCurrent = source.source === currentSource;
|
||||
const latency = latencies[source.source] ?? source.latency;
|
||||
{(() => {
|
||||
const MAX_VISIBLE = 5;
|
||||
const visibleSources = showAllSources ? sortedSources : sortedSources.slice(0, MAX_VISIBLE);
|
||||
const hasMoreSources = sortedSources.length > MAX_VISIBLE;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${source.source}-${index}`}
|
||||
onClick={() => {
|
||||
if (!isCurrent) {
|
||||
onSourceChange!(source);
|
||||
setSourceExpanded(false);
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
w-full p-2.5 rounded-[var(--radius-2xl)] text-left transition-all duration-200
|
||||
flex items-center gap-2.5
|
||||
${isCurrent
|
||||
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)]'
|
||||
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)] cursor-pointer'
|
||||
}
|
||||
`}
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
>
|
||||
{source.pic && (
|
||||
<div className="w-10 h-14 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
|
||||
<Image
|
||||
src={source.pic}
|
||||
alt=""
|
||||
width={40}
|
||||
height={56}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{source.sourceName || source.source}
|
||||
</div>
|
||||
{latency !== undefined && (
|
||||
<div className="mt-0.5">
|
||||
<LatencyBadge latency={latency} />
|
||||
// Group sources by typeName
|
||||
const groupedByType = new Map<string, typeof visibleSources>();
|
||||
for (const source of visibleSources) {
|
||||
const typeName = source.typeName || '';
|
||||
if (!groupedByType.has(typeName)) groupedByType.set(typeName, []);
|
||||
groupedByType.get(typeName)!.push(source);
|
||||
}
|
||||
const hasTypeGroups = groupedByType.size > 1 || (groupedByType.size === 1 && !groupedByType.has(''));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1.5 max-h-[300px] overflow-y-auto">
|
||||
{hasTypeGroups ? (
|
||||
Array.from(groupedByType.entries()).map(([typeName, typeSources]) => (
|
||||
<div key={typeName || '__default'}>
|
||||
{typeName && (
|
||||
<div className="text-[10px] font-medium text-[var(--text-color-secondary)] uppercase tracking-wider px-2 pt-2 pb-1">
|
||||
{typeName}
|
||||
</div>
|
||||
)}
|
||||
{typeSources.map((source, index) => {
|
||||
const isCurrent = source.source === currentSource;
|
||||
const latency = latencies[source.source] ?? source.latency;
|
||||
const globalIndex = sortedSources.indexOf(source);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${source.source}-${index}`}
|
||||
onClick={() => {
|
||||
if (!isCurrent) {
|
||||
onSourceChange!(source);
|
||||
setSourceExpanded(false);
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
w-full p-2.5 rounded-[var(--radius-2xl)] text-left transition-all duration-200
|
||||
flex items-center gap-2.5
|
||||
${isCurrent
|
||||
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)]'
|
||||
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)] cursor-pointer'
|
||||
}
|
||||
`}
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
>
|
||||
{source.pic && (
|
||||
<div className="w-10 h-14 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
|
||||
<Image
|
||||
src={source.pic}
|
||||
alt=""
|
||||
width={40}
|
||||
height={56}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{source.sourceName || source.source}
|
||||
</div>
|
||||
{latency !== undefined && (
|
||||
<div className="mt-0.5">
|
||||
<LatencyBadge latency={latency} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCurrent && (
|
||||
<Icons.Play size={14} className="flex-shrink-0" />
|
||||
)}
|
||||
{!isCurrent && globalIndex < 3 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`flex-shrink-0 ${globalIndex === 0 ? 'bg-yellow-500/20 text-yellow-600 border-yellow-500' :
|
||||
globalIndex === 1 ? 'bg-gray-400/20 text-gray-600 border-gray-400' :
|
||||
'bg-orange-400/20 text-orange-600 border-orange-400'
|
||||
}`}
|
||||
>
|
||||
#{globalIndex + 1}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
visibleSources.map((source, index) => {
|
||||
const isCurrent = source.source === currentSource;
|
||||
const latency = latencies[source.source] ?? source.latency;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${source.source}-${index}`}
|
||||
onClick={() => {
|
||||
if (!isCurrent) {
|
||||
onSourceChange!(source);
|
||||
setSourceExpanded(false);
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
w-full p-2.5 rounded-[var(--radius-2xl)] text-left transition-all duration-200
|
||||
flex items-center gap-2.5
|
||||
${isCurrent
|
||||
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)]'
|
||||
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)] cursor-pointer'
|
||||
}
|
||||
`}
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
>
|
||||
{source.pic && (
|
||||
<div className="w-10 h-14 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
|
||||
<Image
|
||||
src={source.pic}
|
||||
alt=""
|
||||
width={40}
|
||||
height={56}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{source.sourceName || source.source}
|
||||
</div>
|
||||
{latency !== undefined && (
|
||||
<div className="mt-0.5">
|
||||
<LatencyBadge latency={latency} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCurrent && (
|
||||
<Icons.Play size={14} className="flex-shrink-0" />
|
||||
)}
|
||||
{!isCurrent && index < 3 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`flex-shrink-0 ${index === 0 ? 'bg-yellow-500/20 text-yellow-600 border-yellow-500' :
|
||||
index === 1 ? 'bg-gray-400/20 text-gray-600 border-gray-400' :
|
||||
'bg-orange-400/20 text-orange-600 border-orange-400'
|
||||
}`}
|
||||
>
|
||||
#{index + 1}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{hasMoreSources && (
|
||||
<button
|
||||
onClick={() => setShowAllSources(!showAllSources)}
|
||||
className="w-full mt-1.5 py-1.5 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] flex items-center justify-center gap-1 transition-colors cursor-pointer"
|
||||
>
|
||||
{showAllSources ? (
|
||||
<>收起 <Icons.ChevronDown size={12} className="rotate-180" /></>
|
||||
) : (
|
||||
<>展开更多 ({sortedSources.length - MAX_VISIBLE}) <Icons.ChevronDown size={12} /></>
|
||||
)}
|
||||
</div>
|
||||
{isCurrent && (
|
||||
<Icons.Play size={14} className="flex-shrink-0" />
|
||||
)}
|
||||
{!isCurrent && index < 3 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`flex-shrink-0 ${index === 0 ? 'bg-yellow-500/20 text-yellow-600 border-yellow-500' :
|
||||
index === 1 ? 'bg-gray-400/20 text-gray-600 border-gray-400' :
|
||||
'bg-orange-400/20 text-orange-600 border-orange-400'
|
||||
}`}
|
||||
>
|
||||
#{index + 1}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { userSourcesStore } from '@/lib/store/user-sources-store';
|
||||
import { parseDanmakuResponse, parseSearchResults, matchEpisode, fuzzyMatchTitle } from '@/lib/utils/danmaku-utils';
|
||||
import type { DanmakuComment } from '@/lib/types/danmaku';
|
||||
|
||||
@@ -27,18 +28,22 @@ export function useDanmaku({ videoTitle, episodeName, episodeIndex }: UseDanmaku
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fetchedKeyRef = useRef('');
|
||||
|
||||
// Sync with settings store
|
||||
// Sync with settings store + user danmaku API override
|
||||
useEffect(() => {
|
||||
const s = settingsStore.getSettings();
|
||||
setDanmakuEnabledState(s.danmakuEnabled);
|
||||
setApiUrl(s.danmakuApiUrl);
|
||||
const updateApi = () => {
|
||||
const s = settingsStore.getSettings();
|
||||
setDanmakuEnabledState(s.danmakuEnabled);
|
||||
|
||||
const unsub = settingsStore.subscribe(() => {
|
||||
const ns = settingsStore.getSettings();
|
||||
setDanmakuEnabledState(ns.danmakuEnabled);
|
||||
setApiUrl(ns.danmakuApiUrl);
|
||||
});
|
||||
return unsub;
|
||||
// User's active danmaku API takes priority over system setting
|
||||
const userApi = userSourcesStore.getActiveDanmakuApi();
|
||||
setApiUrl(userApi ? userApi.url : s.danmakuApiUrl);
|
||||
};
|
||||
|
||||
updateApi();
|
||||
|
||||
const unsub1 = settingsStore.subscribe(updateApi);
|
||||
const unsub2 = userSourcesStore.subscribe(updateApi);
|
||||
return () => { unsub1(); unsub2(); };
|
||||
}, []);
|
||||
|
||||
const setDanmakuEnabled = useCallback((v: boolean) => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { SourceBadgeItem } from './SourceBadgeItem';
|
||||
import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation';
|
||||
@@ -17,6 +17,7 @@ interface Source {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
typeName?: string;
|
||||
}
|
||||
|
||||
interface SourceBadgeListProps {
|
||||
@@ -37,6 +38,19 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So
|
||||
const badgeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const badgeRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
// Group sources by typeName if type info is available
|
||||
const typeGroups = useMemo(() => {
|
||||
const groups = new Map<string, Source[]>();
|
||||
for (const source of sources) {
|
||||
const type = source.typeName || '';
|
||||
if (!groups.has(type)) groups.set(type, []);
|
||||
groups.get(type)!.push(source);
|
||||
}
|
||||
// Only use grouping if there are meaningful type names
|
||||
const hasTypes = groups.size > 1 || (groups.size === 1 && !groups.has(''));
|
||||
return hasTypes ? groups : null;
|
||||
}, [sources]);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setIsExpanded(prev => {
|
||||
const next = !prev;
|
||||
@@ -97,19 +111,47 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So
|
||||
ref={badgeContainerRef}
|
||||
className="flex items-center gap-2 flex-wrap p-1"
|
||||
>
|
||||
{sources.map((source, index) => (
|
||||
<SourceBadgeItem
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
name={source.name}
|
||||
count={source.count}
|
||||
isSelected={selectedSources.has(source.id)}
|
||||
onToggle={() => onToggleSource(source.id)}
|
||||
isFocused={focusedIndex === index}
|
||||
onFocus={() => setFocusedIndex(index)}
|
||||
innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[index] = el; }}
|
||||
/>
|
||||
))}
|
||||
{typeGroups ? (
|
||||
Array.from(typeGroups.entries()).map(([typeName, typeSources]) => (
|
||||
<div key={typeName || '__default'} className="flex items-center gap-2 flex-wrap">
|
||||
{typeName && (
|
||||
<span className="text-[10px] font-medium text-[var(--text-color-secondary)] uppercase tracking-wider px-1 select-none">
|
||||
{typeName}:
|
||||
</span>
|
||||
)}
|
||||
{typeSources.map((source) => {
|
||||
const globalIndex = sources.indexOf(source);
|
||||
return (
|
||||
<SourceBadgeItem
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
name={source.name}
|
||||
count={source.count}
|
||||
isSelected={selectedSources.has(source.id)}
|
||||
onToggle={() => onToggleSource(source.id)}
|
||||
isFocused={focusedIndex === globalIndex}
|
||||
onFocus={() => setFocusedIndex(globalIndex)}
|
||||
innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[globalIndex] = el; }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
sources.map((source, index) => (
|
||||
<SourceBadgeItem
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
name={source.name}
|
||||
count={source.count}
|
||||
isSelected={selectedSources.has(source.id)}
|
||||
onToggle={() => onToggleSource(source.id)}
|
||||
isFocused={focusedIndex === index}
|
||||
onFocus={() => setFocusedIndex(index)}
|
||||
innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[index] = el; }}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -90,11 +90,18 @@ export const VideoCard = memo<VideoCardProps>(({
|
||||
|
||||
{/* Badge Container */}
|
||||
<div className="absolute top-2 left-2 right-2 z-10 flex items-center justify-between gap-1">
|
||||
{video.sourceName && (
|
||||
<Badge variant="primary" className="bg-[var(--accent-color)] flex-shrink-0 max-w-[50%] truncate">
|
||||
{video.sourceName}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{video.sourceName && (
|
||||
<Badge variant="primary" className="bg-[var(--accent-color)] flex-shrink-0 max-w-[50%] truncate">
|
||||
{video.sourceName}
|
||||
</Badge>
|
||||
)}
|
||||
{video.type_name && (
|
||||
<Badge variant="secondary" className="flex-shrink-0 max-w-[40%] truncate text-[10px]">
|
||||
{video.type_name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayLatency !== undefined && (
|
||||
<LatencyBadge latency={displayLatency} className="flex-shrink-0" />
|
||||
|
||||
@@ -66,6 +66,7 @@ export const VideoGroupCard = memo<VideoGroupCardProps>(({
|
||||
sourceName: v.sourceName,
|
||||
latency: v.latency,
|
||||
pic: v.vod_pic,
|
||||
typeName: v.type_name,
|
||||
}));
|
||||
params.set('groupedSources', JSON.stringify(groupData));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getSession, clearSession, hasPermission, type Role } from '@/lib/store/auth-store';
|
||||
import { getSession, clearSession, hasPermission, type Role, type Permission } from '@/lib/store/auth-store';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { LogOut, Shield, Info } from 'lucide-react';
|
||||
@@ -9,14 +9,32 @@ import { LogOut, Shield, Info } from 'lucide-react';
|
||||
interface AccountInfo {
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions?: string[];
|
||||
}
|
||||
|
||||
interface ConfigEntry {
|
||||
password: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions: Permission[];
|
||||
}
|
||||
|
||||
const ALL_PERMISSIONS: { key: Permission; label: string }[] = [
|
||||
{ key: 'source_management', label: '视频源管理' },
|
||||
{ key: 'account_management', label: '账户管理' },
|
||||
{ key: 'danmaku_api', label: '弹幕 API' },
|
||||
{ key: 'data_management', label: '数据管理' },
|
||||
{ key: 'player_settings', label: '播放器设置' },
|
||||
{ key: 'danmaku_appearance', label: '弹幕外观' },
|
||||
{ key: 'iptv_access', label: 'IPTV 访问' },
|
||||
];
|
||||
|
||||
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access'],
|
||||
admin: ['player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access'],
|
||||
viewer: ['view_settings'],
|
||||
};
|
||||
|
||||
export function AccountSettings() {
|
||||
const [session, setSessionState] = useState<ReturnType<typeof getSession>>(null);
|
||||
const [hasAuth, setHasAuth] = useState(false);
|
||||
@@ -54,7 +72,7 @@ export function AccountSettings() {
|
||||
|
||||
// Config generator helpers
|
||||
const addConfigEntry = () => {
|
||||
setConfigEntries([...configEntries, { password: '', name: '', role: 'viewer' }]);
|
||||
setConfigEntries([...configEntries, { password: '', name: '', role: 'viewer', customPermissions: [] }]);
|
||||
};
|
||||
|
||||
const updateConfigEntry = (index: number, field: keyof ConfigEntry, value: string) => {
|
||||
@@ -63,6 +81,18 @@ export function AccountSettings() {
|
||||
setConfigEntries(updated);
|
||||
};
|
||||
|
||||
const toggleConfigPermission = (index: number, perm: Permission) => {
|
||||
const updated = [...configEntries];
|
||||
const entry = updated[index];
|
||||
const perms = entry.customPermissions || [];
|
||||
if (perms.includes(perm)) {
|
||||
entry.customPermissions = perms.filter(p => p !== perm);
|
||||
} else {
|
||||
entry.customPermissions = [...perms, perm];
|
||||
}
|
||||
setConfigEntries(updated);
|
||||
};
|
||||
|
||||
const removeConfigEntry = (index: number) => {
|
||||
setConfigEntries(configEntries.filter((_, i) => i !== index));
|
||||
};
|
||||
@@ -70,7 +100,17 @@ export function AccountSettings() {
|
||||
const generateAccountsString = () => {
|
||||
return configEntries
|
||||
.filter(e => e.password.trim() && e.name.trim())
|
||||
.map(e => `${e.password}:${e.name}${e.role !== 'viewer' ? ':' + e.role : ''}`)
|
||||
.map(e => {
|
||||
let str = `${e.password}:${e.name}`;
|
||||
const hasCustomPerms = e.customPermissions && e.customPermissions.length > 0;
|
||||
if (e.role !== 'viewer' || hasCustomPerms) {
|
||||
str += ':' + e.role;
|
||||
}
|
||||
if (hasCustomPerms) {
|
||||
str += ':' + e.customPermissions.join('|');
|
||||
}
|
||||
return str;
|
||||
})
|
||||
.join(',');
|
||||
};
|
||||
|
||||
@@ -92,6 +132,7 @@ export function AccountSettings() {
|
||||
password: '',
|
||||
name: a.name,
|
||||
role: a.role,
|
||||
customPermissions: (a.customPermissions || []) as Permission[],
|
||||
}));
|
||||
setConfigEntries(existingEntries);
|
||||
setShowConfigGen(true);
|
||||
@@ -279,6 +320,30 @@ export function AccountSettings() {
|
||||
<option value="super_admin">超级管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Custom permissions: show only those not in the selected role */}
|
||||
{(() => {
|
||||
const rolePerms = ROLE_PERMISSIONS[entry.role] || [];
|
||||
const extraPerms = ALL_PERMISSIONS.filter(p => !rolePerms.includes(p.key));
|
||||
if (extraPerms.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 pl-1">
|
||||
{extraPerms.map(p => {
|
||||
const checked = entry.customPermissions?.includes(p.key) ?? false;
|
||||
return (
|
||||
<label key={p.key} className="flex items-center gap-1 text-[10px] text-[var(--text-color-secondary)] cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleConfigPermission(index, p.key)}
|
||||
className="w-3 h-3 rounded accent-[var(--accent-color)]"
|
||||
/>
|
||||
{p.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeConfigEntry(index)}
|
||||
@@ -331,7 +396,7 @@ export function AccountSettings() {
|
||||
</p>
|
||||
<div className="text-xs text-[var(--text-color-secondary)] space-y-0.5">
|
||||
<p><code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ADMIN_PASSWORD</code> — 单管理员密码</p>
|
||||
<p><code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ACCOUNTS</code> — 多账户(密码:名称[:角色])</p>
|
||||
<p><code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ACCOUNTS</code> — 多账户(密码:名称[:角色[:权限1|权限2]])</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ImportModalTabs } from './import/ImportModalTabs';
|
||||
import { FileImportTab } from './import/FileImportTab';
|
||||
import { LinkImportTab } from './import/LinkImportTab';
|
||||
import { SubscriptionImportTab } from './import/SubscriptionImportTab';
|
||||
import { JsonImportTab } from './import/JsonImportTab';
|
||||
import type { ImportResult } from '@/lib/utils/source-import-utils';
|
||||
import type { SourceSubscription } from '@/lib/types';
|
||||
import { ModalBackdrop } from '@/components/ui/ModalBackdrop';
|
||||
@@ -33,7 +34,7 @@ export function ImportModal({
|
||||
onRemoveSubscription,
|
||||
onRefreshSubscription
|
||||
}: ImportModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<'file' | 'link' | 'subscription'>('file');
|
||||
const [activeTab, setActiveTab] = useState<'file' | 'link' | 'subscription' | 'json'>('file');
|
||||
|
||||
// Reset tab on open
|
||||
useEffect(() => {
|
||||
@@ -90,6 +91,10 @@ export function ImportModal({
|
||||
onRefresh={onRefreshSubscription}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'json' && (
|
||||
<JsonImportTab />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { userSourcesStore, type DanmakuApiEntry } from '@/lib/store/user-sources-store';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
|
||||
export function UserDanmakuSettings() {
|
||||
const [apis, setApis] = useState<DanmakuApiEntry[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [systemApiUrl, setSystemApiUrl] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const state = userSourcesStore.getState();
|
||||
setApis(state.danmakuApis);
|
||||
setActiveId(state.activeDanmakuApiId);
|
||||
setSystemApiUrl(settingsStore.getSettings().danmakuApiUrl);
|
||||
|
||||
const unsub = userSourcesStore.subscribe(() => {
|
||||
const s = userSourcesStore.getState();
|
||||
setApis(s.danmakuApis);
|
||||
setActiveId(s.activeDanmakuApiId);
|
||||
});
|
||||
const unsub2 = settingsStore.subscribe(() => {
|
||||
setSystemApiUrl(settingsStore.getSettings().danmakuApiUrl);
|
||||
});
|
||||
return () => { unsub(); unsub2(); };
|
||||
}, []);
|
||||
|
||||
const handleAdd = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !url.trim()) {
|
||||
setError('名称和 URL 不能为空');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
setError('请输入有效的 URL');
|
||||
return;
|
||||
}
|
||||
const id = `danmaku-${Date.now().toString(36)}`;
|
||||
userSourcesStore.addDanmakuApi({ id, name: name.trim(), url: url.trim() });
|
||||
setName('');
|
||||
setUrl('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title="弹幕 API" description="管理你的弹幕 API,选择当前使用的 API。">
|
||||
<div className="space-y-4">
|
||||
{/* Add form */}
|
||||
<form onSubmit={handleAdd} className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="API 名称"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setError(''); }}
|
||||
className="flex-1 px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="API URL (https://...)"
|
||||
value={url}
|
||||
onChange={(e) => { setUrl(e.target.value); setError(''); }}
|
||||
className="flex-[2] px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] text-sm font-medium hover:brightness-110 transition-all cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<Icons.Plus size={14} />
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* API list with radio select */}
|
||||
<div className="space-y-2">
|
||||
{/* System default option */}
|
||||
<button
|
||||
onClick={() => userSourcesStore.setActiveDanmakuApi(null)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 bg-[var(--glass-bg)] border rounded-[var(--radius-2xl)] text-left transition-all cursor-pointer ${
|
||||
activeId === null ? 'border-[var(--accent-color)] bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)]' : 'border-[var(--glass-border)]'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
|
||||
activeId === null ? 'border-[var(--accent-color)]' : 'border-[var(--glass-border)]'
|
||||
}`}>
|
||||
{activeId === null && <span className="w-2 h-2 rounded-full bg-[var(--accent-color)]" />}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-color)]">使用系统默认</p>
|
||||
{systemApiUrl && (
|
||||
<p className="text-[10px] text-[var(--text-color-secondary)] truncate">{systemApiUrl}</p>
|
||||
)}
|
||||
{!systemApiUrl && (
|
||||
<p className="text-[10px] text-[var(--text-color-secondary)]">未配置系统弹幕 API</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{apis.map(api => (
|
||||
<div key={api.id} className={`flex items-center gap-3 px-4 py-2.5 bg-[var(--glass-bg)] border rounded-[var(--radius-2xl)] ${
|
||||
activeId === api.id ? 'border-[var(--accent-color)] bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)]' : 'border-[var(--glass-border)]'
|
||||
}`}>
|
||||
<button
|
||||
onClick={() => userSourcesStore.setActiveDanmakuApi(api.id)}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left cursor-pointer"
|
||||
>
|
||||
<span className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
|
||||
activeId === api.id ? 'border-[var(--accent-color)]' : 'border-[var(--glass-border)]'
|
||||
}`}>
|
||||
{activeId === api.id && <span className="w-2 h-2 rounded-full bg-[var(--accent-color)]" />}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-color)] truncate">{api.name}</p>
|
||||
<p className="text-[10px] text-[var(--text-color-secondary)] truncate">{api.url}</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => userSourcesStore.removeDanmakuApi(api.id)}
|
||||
className="p-1 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer flex-shrink-0"
|
||||
>
|
||||
<Icons.Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { userSourcesStore } from '@/lib/store/user-sources-store';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
|
||||
export function UserSourceSettings() {
|
||||
const [sources, setSources] = useState<VideoSource[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setSources(userSourcesStore.getSources());
|
||||
const unsub = userSourcesStore.subscribe(() => {
|
||||
setSources(userSourcesStore.getSources());
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
const handleAdd = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !baseUrl.trim()) {
|
||||
setError('名称和接口地址不能为空');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new URL(baseUrl);
|
||||
} catch {
|
||||
setError('请输入有效的 URL');
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `user-${name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Date.now().toString(36)}`;
|
||||
const source: VideoSource = {
|
||||
id,
|
||||
name: name.trim(),
|
||||
baseUrl: baseUrl.trim(),
|
||||
searchPath: '/provide/vod',
|
||||
detailPath: '/provide/vod',
|
||||
enabled: true,
|
||||
};
|
||||
userSourcesStore.addSource(source);
|
||||
setName('');
|
||||
setBaseUrl('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title="个人视频源" description="添加你自己的视频源,不影响其他用户。">
|
||||
<div className="space-y-4">
|
||||
{/* Add form */}
|
||||
<form onSubmit={handleAdd} className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="源名称"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setError(''); }}
|
||||
className="flex-1 px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="接口地址 (https://...)"
|
||||
value={baseUrl}
|
||||
onChange={(e) => { setBaseUrl(e.target.value); setError(''); }}
|
||||
className="flex-[2] px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] text-sm font-medium hover:brightness-110 transition-all cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<Icons.Plus size={14} />
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Source list */}
|
||||
{sources.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{sources.map(source => (
|
||||
<div
|
||||
key={source.id}
|
||||
className="flex items-center justify-between px-4 py-2.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
onClick={() => userSourcesStore.toggleSource(source.id)}
|
||||
className={`w-8 h-5 rounded-full transition-colors cursor-pointer relative flex-shrink-0 ${
|
||||
source.enabled !== false ? 'bg-[var(--accent-color)]' : 'bg-[var(--glass-border)]'
|
||||
}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${
|
||||
source.enabled !== false ? 'left-3.5' : 'left-0.5'
|
||||
}`} />
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-color)] truncate">{source.name}</p>
|
||||
<p className="text-[10px] text-[var(--text-color-secondary)] truncate">{source.baseUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => userSourcesStore.removeSource(source.id)}
|
||||
className="p-1 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer flex-shrink-0"
|
||||
>
|
||||
<Icons.Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sources.length === 0 && (
|
||||
<p className="text-xs text-[var(--text-color-secondary)] text-center py-4">
|
||||
还没有个人视频源,添加一个试试吧。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
interface ImportModalTabsProps {
|
||||
activeTab: 'file' | 'link' | 'subscription';
|
||||
onTabChange: (tab: 'file' | 'link' | 'subscription') => void;
|
||||
activeTab: 'file' | 'link' | 'subscription' | 'json';
|
||||
onTabChange: (tab: 'file' | 'link' | 'subscription' | 'json') => void;
|
||||
}
|
||||
|
||||
export function ImportModalTabs({ activeTab, onTabChange }: ImportModalTabsProps) {
|
||||
@@ -10,6 +10,7 @@ export function ImportModalTabs({ activeTab, onTabChange }: ImportModalTabsProps
|
||||
{ id: 'file', label: '文件导入' },
|
||||
{ id: 'link', label: '链接导入' },
|
||||
{ id: 'subscription', label: '订阅管理' },
|
||||
{ id: 'json', label: 'JSON' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
|
||||
export function JsonImportTab() {
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [preview, setPreview] = useState<VideoSource[] | null>(null);
|
||||
const [imported, setImported] = useState(false);
|
||||
|
||||
const handleParse = () => {
|
||||
setError('');
|
||||
setPreview(null);
|
||||
if (!jsonText.trim()) {
|
||||
setError('请粘贴 JSON 内容');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
if (!Array.isArray(parsed)) {
|
||||
setError('JSON 格式错误:应为数组');
|
||||
return;
|
||||
}
|
||||
|
||||
const sources: VideoSource[] = [];
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const item = parsed[i];
|
||||
if (!item.name || !item.baseUrl) {
|
||||
setError(`第 ${i + 1} 项缺少 name 或 baseUrl`);
|
||||
return;
|
||||
}
|
||||
sources.push({
|
||||
id: item.id || `json-${item.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${i}`,
|
||||
name: item.name,
|
||||
baseUrl: item.baseUrl,
|
||||
searchPath: item.searchPath || '/provide/vod',
|
||||
detailPath: item.detailPath || '/provide/vod',
|
||||
enabled: item.enabled !== false,
|
||||
headers: item.headers,
|
||||
priority: item.priority,
|
||||
group: item.group,
|
||||
});
|
||||
}
|
||||
|
||||
if (sources.length === 0) {
|
||||
setError('没有有效的视频源');
|
||||
return;
|
||||
}
|
||||
|
||||
setPreview(sources);
|
||||
} catch (e) {
|
||||
setError('JSON 解析失败:' + (e instanceof Error ? e.message : '格式错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
if (!preview) return;
|
||||
|
||||
const settings = settingsStore.getSettings();
|
||||
const existingIds = new Set(settings.sources.map(s => s.id));
|
||||
const newSources = preview.filter(s => !existingIds.has(s.id));
|
||||
const updatedSources = [...settings.sources, ...newSources];
|
||||
settingsStore.saveSettings({ ...settings, sources: updatedSources });
|
||||
setImported(true);
|
||||
setPreview(null);
|
||||
setJsonText('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-[var(--text-color-secondary)]">
|
||||
粘贴 JSON 数组格式的视频源配置。格式:
|
||||
<code className="block mt-1 px-2 py-1 bg-black/10 rounded text-[10px]">
|
||||
{'[{ "name": "...", "baseUrl": "..." }]'}
|
||||
</code>
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => { setJsonText(e.target.value); setError(''); setPreview(null); setImported(false); }}
|
||||
placeholder='[{ "name": "源名称", "baseUrl": "https://..." }]'
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)] font-mono resize-none"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
{!preview && !imported && (
|
||||
<button
|
||||
onClick={handleParse}
|
||||
className="w-full px-4 py-2.5 bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] text-sm font-medium hover:brightness-110 transition-all cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
<Icons.Search size={14} />
|
||||
解析预览
|
||||
</button>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]">
|
||||
<p className="text-sm text-[var(--text-color)]">
|
||||
解析成功,共 <span className="font-bold text-[var(--accent-color)]">{preview.length}</span> 个视频源
|
||||
</p>
|
||||
<div className="mt-2 space-y-1 max-h-[150px] overflow-y-auto">
|
||||
{preview.map((s, i) => (
|
||||
<div key={i} className="text-xs text-[var(--text-color-secondary)] flex gap-2">
|
||||
<span className="font-medium text-[var(--text-color)]">{s.name}</span>
|
||||
<span className="truncate">{s.baseUrl}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setPreview(null); }}
|
||||
className="flex-1 px-4 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] rounded-[var(--radius-2xl)] text-sm hover:bg-[var(--glass-hover)] transition-all cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
className="flex-1 px-4 py-2 bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] text-sm font-medium hover:brightness-110 transition-all cursor-pointer flex items-center justify-center gap-1"
|
||||
>
|
||||
<Icons.Download size={14} />
|
||||
导入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imported && (
|
||||
<p className="text-xs text-green-500 text-center py-2">导入成功!</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user