599 lines
25 KiB
TypeScript
599 lines
25 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import { ArrowUp, BookOpen, Hammer, Monitor, Moon, Settings, Sparkles, Sun } from 'lucide-react';
|
|
import { useI18n } from '@/lib/hooks/use-i18n';
|
|
import { LanguageSwitcher } from '@/components/language-switcher';
|
|
import { createLogger } from '@/lib/logger';
|
|
import { cn } from '@/lib/utils';
|
|
import { SettingsDialog } from '@/components/settings';
|
|
import { GenerationToolbar } from '@/components/generation/generation-toolbar';
|
|
import { useTheme } from '@/lib/hooks/use-theme';
|
|
import { nanoid } from 'nanoid';
|
|
import { dedupeCourseMaterialFiles } from '@/lib/document/course-materials';
|
|
import type { SelectedCourseMaterial } from '@/lib/types/generation';
|
|
import { useSettingsStore } from '@/lib/store/settings';
|
|
import { hasUsableLLMProvider } from '@/lib/store/settings-validation';
|
|
import { useMediaGenerationStore } from '@/lib/store/media-generation';
|
|
import { toast } from 'sonner';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { useDraftCache } from '@/lib/hooks/use-draft-cache';
|
|
import { SpeechButton } from '@/components/audio/speech-button';
|
|
import { shouldShowVocationalTestUi } from '@/lib/config/feature-flags';
|
|
import { InteractiveModeButton } from '@/components/generation/interactive-mode-button';
|
|
import {
|
|
GenerationModeTabs,
|
|
type GenerationMode,
|
|
} from '@/components/generation/generation-mode-tabs';
|
|
import { LargeCourseWorkbench } from '@/components/generation/large-course-workbench';
|
|
import { opsApiPath } from '@/lib/ops/api-fetch';
|
|
import { storeDocumentBlob } from '@/lib/utils/image-storage';
|
|
import { createForegroundGenerationSession } from '@/lib/generation/foreground-session';
|
|
import { isOpsWorkbenchEnabled, isSettingsManagementEnabled } from '@/lib/config/deployment-role';
|
|
|
|
const log = createLogger('Home');
|
|
|
|
const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled';
|
|
const INTERACTIVE_MODE_STORAGE_KEY = 'interactiveModeEnabled';
|
|
|
|
interface FormState {
|
|
courseMaterials: SelectedCourseMaterial[];
|
|
requirement: string;
|
|
webSearch: boolean;
|
|
interactiveMode: boolean;
|
|
vocationalTestMode: boolean;
|
|
}
|
|
|
|
const initialFormState: FormState = {
|
|
courseMaterials: [],
|
|
requirement: '',
|
|
webSearch: false,
|
|
interactiveMode: false,
|
|
vocationalTestMode: false,
|
|
};
|
|
|
|
function HomePage() {
|
|
const { t } = useI18n();
|
|
const { theme, setTheme } = useTheme();
|
|
const router = useRouter();
|
|
const showVocationalTestUi = shouldShowVocationalTestUi();
|
|
const [form, setForm] = useState<FormState>(initialFormState);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [settingsSection, setSettingsSection] = useState<
|
|
import('@/lib/types/settings').SettingsSection | undefined
|
|
>(undefined);
|
|
const [generationMode, setGenerationMode] = useState<GenerationMode>('normal');
|
|
|
|
// Draft cache for requirement text
|
|
const { cachedValue: cachedRequirement, updateCache: updateRequirementCache } =
|
|
useDraftCache<string>({ key: 'requirementDraft' });
|
|
|
|
// A usable LLM provider exists ⇒ a concrete model is always selected (#580
|
|
// invariant). Gate generation on this single condition (state A vs B)
|
|
// instead of inspecting modelId directly.
|
|
const providersConfig = useSettingsStore((s) => s.providersConfig);
|
|
const hasUsableProvider = hasUsableLLMProvider(providersConfig);
|
|
const [themeOpen, setThemeOpen] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
|
const opsWorkbenchEnabled = isOpsWorkbenchEnabled();
|
|
const settingsManagementEnabled = isSettingsManagementEnabled();
|
|
|
|
// Hydrate client-only state after mount (avoids SSR mismatch)
|
|
useEffect(() => {
|
|
try {
|
|
const savedWebSearch = localStorage.getItem(WEB_SEARCH_STORAGE_KEY);
|
|
const savedInteractiveMode = localStorage.getItem(INTERACTIVE_MODE_STORAGE_KEY);
|
|
const updates: Partial<FormState> = {};
|
|
if (savedWebSearch === 'true') updates.webSearch = true;
|
|
if (savedInteractiveMode === 'true') updates.interactiveMode = true;
|
|
if (Object.keys(updates).length > 0) {
|
|
setForm((prev) => ({ ...prev, ...updates }));
|
|
}
|
|
} catch {
|
|
/* localStorage unavailable */
|
|
}
|
|
}, []);
|
|
|
|
// Restore requirement draft from localStorage on mount. The previous derived-state
|
|
// pattern initialised `prev` from the cached value itself, so on the first client
|
|
// render the comparison was always equal and the restore never fired. Use an effect
|
|
// so the cache is hydrated into the form once we know the live requirement is empty.
|
|
const draftRestoredRef = useRef(false);
|
|
useEffect(() => {
|
|
if (draftRestoredRef.current) return;
|
|
if (!cachedRequirement) return;
|
|
draftRestoredRef.current = true;
|
|
setForm((prev) => (prev.requirement ? prev : { ...prev, requirement: cachedRequirement }));
|
|
}, [cachedRequirement]);
|
|
|
|
// Close dropdowns when clicking outside
|
|
useEffect(() => {
|
|
if (!themeOpen) return;
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) {
|
|
setThemeOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, [themeOpen]);
|
|
|
|
useEffect(() => {
|
|
// Clear stale media store to prevent cross-course thumbnail contamination.
|
|
// The store may hold tasks from a previously visited classroom whose elementIds
|
|
// (gen_img_1, etc.) collide with other courses' placeholders.
|
|
useMediaGenerationStore.getState().revokeObjectUrls();
|
|
useMediaGenerationStore.setState({ tasks: {} });
|
|
}, []);
|
|
|
|
const updateForm = <K extends keyof FormState>(field: K, value: FormState[K]) => {
|
|
setForm((prev) => ({ ...prev, [field]: value }));
|
|
try {
|
|
if (field === 'webSearch') localStorage.setItem(WEB_SEARCH_STORAGE_KEY, String(value));
|
|
if (field === 'interactiveMode')
|
|
localStorage.setItem(INTERACTIVE_MODE_STORAGE_KEY, String(value));
|
|
if (field === 'requirement') updateRequirementCache(value as string);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
};
|
|
|
|
const addCourseMaterials = (files: File[]) => {
|
|
setForm((prev) => {
|
|
const dedupedFiles = dedupeCourseMaterialFiles(prev.courseMaterials, files);
|
|
const startOrder = prev.courseMaterials.length + 1;
|
|
const additions = dedupedFiles.map((file, index) => ({
|
|
id: nanoid(8),
|
|
file,
|
|
name: file.name,
|
|
size: file.size,
|
|
lastModified: file.lastModified,
|
|
type: file.type,
|
|
order: startOrder + index,
|
|
}));
|
|
|
|
return additions.length > 0
|
|
? { ...prev, courseMaterials: [...prev.courseMaterials, ...additions] }
|
|
: prev;
|
|
});
|
|
};
|
|
|
|
const removeCourseMaterial = (id: string) => {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
courseMaterials: prev.courseMaterials
|
|
.filter((item) => item.id !== id)
|
|
.map((item, index) => ({ ...item, order: index + 1 })),
|
|
}));
|
|
};
|
|
|
|
const usesForegroundGeneration =
|
|
form.courseMaterials.length > 0 || form.interactiveMode || form.vocationalTestMode;
|
|
|
|
// Plain requirements use the durable server-side task flow. Features that
|
|
// still need browser-owned files or the original interactive/vocational
|
|
// outline pipeline are handed to the existing generation-preview flow.
|
|
const startGeneration = async () => {
|
|
if (!form.requirement.trim()) {
|
|
setError(t('upload.requirementRequired'));
|
|
return;
|
|
}
|
|
setError(null);
|
|
if (isSubmitting) return;
|
|
setIsSubmitting(true);
|
|
try {
|
|
if (usesForegroundGeneration) {
|
|
const settings = useSettingsStore.getState();
|
|
const pdfProviderConfig = settings.pdfProvidersConfig[settings.pdfProviderId];
|
|
const session = await createForegroundGenerationSession({
|
|
sessionId: nanoid(10),
|
|
requirement: form.requirement,
|
|
webSearch: form.webSearch,
|
|
interactiveMode: form.interactiveMode,
|
|
taskEngineMode: form.vocationalTestMode,
|
|
courseMaterials: form.courseMaterials,
|
|
pdfProviderId: settings.pdfProviderId,
|
|
pdfProviderConfig,
|
|
storeDocument: storeDocumentBlob,
|
|
});
|
|
sessionStorage.setItem('generationSession', JSON.stringify(session));
|
|
router.push('/generation-preview');
|
|
return;
|
|
}
|
|
|
|
const response = await fetch('/api/generate-classroom', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
requirement: form.requirement,
|
|
...(form.webSearch ? { enableWebSearch: true } : {}),
|
|
}),
|
|
});
|
|
const body = (await response.json()) as {
|
|
success?: boolean;
|
|
jobId?: string;
|
|
error?: string;
|
|
details?: string;
|
|
};
|
|
if (!response.ok || !body.success || !body.jobId) {
|
|
throw new Error(body.details ?? body.error ?? `HTTP ${response.status}`);
|
|
}
|
|
toast.success(t('jobs.started'), {
|
|
action: {
|
|
label: t('jobs.viewTasks'),
|
|
onClick: () => router.push('/tasks'),
|
|
},
|
|
});
|
|
} catch (err) {
|
|
log.error('Failed to start generation:', err);
|
|
setError(err instanceof Error ? err.message : t('upload.generateFailed'));
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const canGenerate = !!form.requirement.trim() && hasUsableProvider && !isSubmitting;
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (canGenerate) void startGeneration();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-[100dvh] w-full bg-gradient-to-b from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 flex flex-col items-center p-4 pt-16 md:p-8 md:pt-16 overflow-x-hidden">
|
|
{/* ═══ Top-right pill ═══ */}
|
|
<div
|
|
ref={toolbarRef}
|
|
className="fixed top-4 right-4 z-50 flex items-center gap-1 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md px-2 py-1.5 rounded-full border border-gray-100/50 dark:border-gray-700/50 shadow-sm"
|
|
>
|
|
{/* Language Selector */}
|
|
<LanguageSwitcher onOpen={() => setThemeOpen(false)} />
|
|
|
|
<div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" />
|
|
|
|
{/* Theme Selector */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => {
|
|
setThemeOpen(!themeOpen);
|
|
}}
|
|
className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all"
|
|
>
|
|
{theme === 'light' && <Sun className="w-4 h-4" />}
|
|
{theme === 'dark' && <Moon className="w-4 h-4" />}
|
|
{theme === 'system' && <Monitor className="w-4 h-4" />}
|
|
</button>
|
|
{themeOpen && (
|
|
<div className="absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[140px]">
|
|
<button
|
|
onClick={() => {
|
|
setTheme('light');
|
|
setThemeOpen(false);
|
|
}}
|
|
className={cn(
|
|
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
|
|
theme === 'light' &&
|
|
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
|
|
)}
|
|
>
|
|
<Sun className="w-4 h-4" />
|
|
{t('settings.themeOptions.light')}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setTheme('dark');
|
|
setThemeOpen(false);
|
|
}}
|
|
className={cn(
|
|
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
|
|
theme === 'dark' &&
|
|
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
|
|
)}
|
|
>
|
|
<Moon className="w-4 h-4" />
|
|
{t('settings.themeOptions.dark')}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setTheme('system');
|
|
setThemeOpen(false);
|
|
}}
|
|
className={cn(
|
|
'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
|
|
theme === 'system' &&
|
|
'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
|
|
)}
|
|
>
|
|
<Monitor className="w-4 h-4" />
|
|
{t('settings.themeOptions.system')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" />
|
|
|
|
{/* Published course catalog — available on every learner surface. */}
|
|
<button
|
|
onClick={() => router.push('/learn')}
|
|
title={t('learner.openCatalog')}
|
|
className="flex items-center gap-1.5 rounded-full px-2 py-2 text-gray-400 transition-all hover:bg-white hover:text-violet-600 hover:shadow-sm dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-violet-400"
|
|
>
|
|
<BookOpen className="w-4 h-4" />
|
|
<span className="hidden text-xs font-medium sm:inline">{t('learner.openCatalog')}</span>
|
|
</button>
|
|
|
|
<div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" />
|
|
|
|
{/* Generation tasks — managed on the dedicated /tasks page */}
|
|
<button
|
|
onClick={() => router.push('/tasks')}
|
|
title={t('jobs.title')}
|
|
className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all group"
|
|
>
|
|
<Hammer className="w-4 h-4 group-hover:scale-110 transition-transform" />
|
|
</button>
|
|
|
|
{settingsManagementEnabled && (
|
|
<>
|
|
<div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" />
|
|
|
|
{/* Settings are operator-managed; learner deployments do not expose this control. */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setSettingsOpen(true)}
|
|
className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all group"
|
|
>
|
|
<Settings className="w-4 h-4 group-hover:rotate-90 transition-transform duration-500" />
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{settingsManagementEnabled && (
|
|
<SettingsDialog
|
|
open={settingsOpen}
|
|
onOpenChange={(open) => {
|
|
setSettingsOpen(open);
|
|
if (!open) setSettingsSection(undefined);
|
|
}}
|
|
initialSection={settingsSection}
|
|
/>
|
|
)}
|
|
|
|
{/* ═══ Background Decor ═══ */}
|
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
|
<div
|
|
className="absolute top-0 left-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl animate-pulse"
|
|
style={{ animationDuration: '4s' }}
|
|
/>
|
|
<div
|
|
className="absolute bottom-0 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl animate-pulse"
|
|
style={{ animationDuration: '6s' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* ═══ Hero section: title + input (centered, wider) ═══ */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.6, ease: 'easeOut' }}
|
|
className={cn('relative z-20 w-full max-w-[800px] flex flex-col items-center mt-[10vh]')}
|
|
>
|
|
{/* ── Logo ── */}
|
|
<motion.img
|
|
src={opsApiPath('/mailuo-logo.png')}
|
|
alt="麦洛学习"
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{
|
|
delay: 0.1,
|
|
type: 'spring',
|
|
stiffness: 200,
|
|
damping: 20,
|
|
}}
|
|
className="h-12 md:h-16 mb-2 -ml-2 md:-ml-3"
|
|
/>
|
|
|
|
{/* ── Slogan ── */}
|
|
<motion.p
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.25 }}
|
|
className="text-sm text-muted-foreground/60 mb-8"
|
|
>
|
|
{t('home.slogan')}
|
|
</motion.p>
|
|
|
|
<div className="mb-6">
|
|
<GenerationModeTabs
|
|
value={generationMode}
|
|
onChange={setGenerationMode}
|
|
showLarge={opsWorkbenchEnabled}
|
|
/>
|
|
</div>
|
|
|
|
{generationMode === 'normal' ? (
|
|
<>
|
|
{/* ── Unified input area ── */}
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.97 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ delay: 0.35 }}
|
|
className="w-full"
|
|
>
|
|
<div className="w-full rounded-2xl border border-border/60 bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl shadow-xl shadow-black/[0.03] dark:shadow-black/20 transition-shadow focus-within:shadow-2xl focus-within:shadow-violet-500/[0.06]">
|
|
{/* Textarea */}
|
|
<textarea
|
|
placeholder={t('upload.requirementPlaceholder')}
|
|
className="w-full resize-none border-0 bg-transparent px-4 pt-1 pb-2 text-[13px] leading-relaxed placeholder:text-muted-foreground/40 focus:outline-none min-h-[140px] max-h-[300px]"
|
|
value={form.requirement}
|
|
onChange={(e) => updateForm('requirement', e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
rows={4}
|
|
/>
|
|
|
|
{/* Toolbar row */}
|
|
<div className="px-3 pb-3 flex items-end gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<GenerationToolbar
|
|
webSearch={form.webSearch}
|
|
onWebSearchChange={(v) => updateForm('webSearch', v)}
|
|
onSettingsOpen={(section) => {
|
|
if (!settingsManagementEnabled) return;
|
|
setSettingsSection(section);
|
|
setSettingsOpen(true);
|
|
}}
|
|
courseMaterials={form.courseMaterials}
|
|
onCourseMaterialsAdd={addCourseMaterials}
|
|
onCourseMaterialRemove={removeCourseMaterial}
|
|
onPdfError={setError}
|
|
/>
|
|
</div>
|
|
|
|
{/* Interactive mode toggle */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<InteractiveModeButton
|
|
pressed={form.interactiveMode}
|
|
label={t('toolbar.interactiveModeLabel')}
|
|
onPressedChange={(pressed) => updateForm('interactiveMode', pressed)}
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="text-xs">
|
|
{t('toolbar.interactiveModeHint')}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{/* Voice input */}
|
|
<SpeechButton
|
|
size="md"
|
|
onTranscription={(text) => {
|
|
setForm((prev) => {
|
|
const next = prev.requirement + (prev.requirement ? ' ' : '') + text;
|
|
updateRequirementCache(next);
|
|
return { ...prev, requirement: next };
|
|
});
|
|
}}
|
|
/>
|
|
|
|
{/* Send button — background for plain prompts, foreground for
|
|
materials and interactive/vocational generation. */}
|
|
<button
|
|
onClick={() => void startGeneration()}
|
|
disabled={!canGenerate}
|
|
title={t(
|
|
usesForegroundGeneration ? 'jobs.foregroundHint' : 'jobs.backgroundHint',
|
|
)}
|
|
className={cn(
|
|
'shrink-0 h-8 rounded-lg flex items-center justify-center gap-1.5 transition-all px-3',
|
|
canGenerate
|
|
? 'bg-primary text-primary-foreground hover:opacity-90 shadow-sm cursor-pointer'
|
|
: 'bg-muted text-muted-foreground/40 cursor-not-allowed',
|
|
)}
|
|
>
|
|
<span className="text-xs font-medium">
|
|
{t(
|
|
usesForegroundGeneration
|
|
? 'jobs.generateForeground'
|
|
: 'jobs.generateBackground',
|
|
)}
|
|
</span>
|
|
<ArrowUp className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{showVocationalTestUi && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.4 }}
|
|
className="mt-2 flex w-full justify-start px-1"
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={form.vocationalTestMode}
|
|
onClick={() => updateForm('vocationalTestMode', !form.vocationalTestMode)}
|
|
className={cn(
|
|
'inline-flex h-7 items-center gap-2 rounded-full border px-2.5 text-[11px] font-medium transition-colors',
|
|
form.vocationalTestMode
|
|
? 'border-cyan-400/70 bg-cyan-50 text-cyan-700 shadow-[0_0_10px_rgba(6,182,212,0.16)] dark:bg-cyan-950/40 dark:text-cyan-300'
|
|
: 'border-border/70 bg-background/70 text-muted-foreground hover:border-cyan-300/60 hover:text-cyan-700 dark:hover:text-cyan-300',
|
|
)}
|
|
>
|
|
<span className="rounded-full bg-cyan-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-normal text-cyan-700 dark:bg-cyan-900/45 dark:text-cyan-300">
|
|
测试功能
|
|
</span>
|
|
<Sparkles className="size-3.5" />
|
|
<span>职教任务</span>
|
|
<span
|
|
className={cn(
|
|
'relative h-3.5 w-6 rounded-full transition-colors',
|
|
form.vocationalTestMode ? 'bg-cyan-500' : 'bg-muted-foreground/25',
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'absolute left-0.5 top-0.5 size-2.5 rounded-full bg-white transition-transform',
|
|
form.vocationalTestMode ? 'translate-x-2.5' : 'translate-x-0',
|
|
)}
|
|
/>
|
|
</span>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
从当前输入框提交职教实操训练测试
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* ── Error ── */}
|
|
<AnimatePresence>
|
|
{error && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: 'auto' }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
className="mt-3 w-full p-3 bg-destructive/10 border border-destructive/20 rounded-lg"
|
|
>
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
) : (
|
|
<LargeCourseWorkbench
|
|
embedded
|
|
onSettingsOpen={(section) => {
|
|
if (!settingsManagementEnabled) return;
|
|
setSettingsSection(section);
|
|
setSettingsOpen(true);
|
|
}}
|
|
/>
|
|
)}
|
|
</motion.div>
|
|
|
|
{/* Footer — flows with content, at the very end */}
|
|
<div className="mt-auto pt-12 pb-4 text-center text-xs text-muted-foreground/40">
|
|
麦洛学习
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Page() {
|
|
return <HomePage />;
|
|
}
|