Files
makelore/src/pages/ModuleSelection/index.tsx

137 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import moduleCanvasImage from '@/assets/module-canvas-neon.jpg';
import moduleGameImage from '@/assets/module-game.jpg';
import moduleLearningImage from '@/assets/module-learning.jpg';
import moduleRobotImage from '@/assets/module-robot.jpg';
import logoWordmarkSource from '@/assets/makelore-wordmark-source.png';
import { UserProfileDialog } from '@/components/profile/UserProfileDialog';
import { useCurrentUserProfile } from '@/hooks/use-current-user-profile';
import { aiModules, isAiModuleAllowed, type AiModuleId } from '@/lib/ai-modules';
import { getAuthUserDisplayName } from '@/lib/auth-user-display';
import { cn } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth';
import { getUserProfileDisplayName } from '@/stores/user-profile';
const moduleSelectionContent: Record<AiModuleId, { label: string; image: string; imageAlt: string }> = {
programming: { label: 'Code 编程', image: moduleGameImage, imageAlt: '游戏创作工作台' },
painting: { label: 'Canvas 设计', image: moduleCanvasImage, imageAlt: '数位板设计创作' },
learning: { label: 'Learning 学习', image: moduleLearningImage, imageAlt: '数学科技宇宙' },
robot: { label: 'Robot 机器', image: moduleRobotImage, imageAlt: '青少年管理机器人硬件与设备绑定' },
};
export function ModuleSelection({ authRequired = true }: { authRequired?: boolean }) {
const navigate = useNavigate();
const authUser = useAuthStore((state) => state.user);
const moduleAccess = useAuthStore((state) => state.moduleAccess);
const authenticated = useAuthStore((state) => state.isAuthenticated());
const {
userProfile,
profileRequired,
profileSyncError,
} = useCurrentUserProfile(authRequired);
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const userDisplayName = getUserProfileDisplayName(userProfile) || getAuthUserDisplayName(authUser);
const [dismissedSyncError, setDismissedSyncError] = useState<string | null>(null);
const profileDialogShouldBeOpen = profileDialogOpen
|| profileRequired
|| Boolean(profileSyncError && dismissedSyncError !== profileSyncError);
const openModule = (route: string | null, enabled: boolean) => {
if (!route || !enabled) return;
if (authRequired && !authenticated) {
navigate('/login');
return;
}
navigate(route);
};
return (
<main data-testid="ai-module-selection-page" className="relative min-h-[100dvh] overflow-auto bg-background px-4 py-6 text-foreground sm:px-8 sm:py-10">
<div className="mx-auto flex min-h-[calc(100dvh-3rem)] max-w-6xl flex-col">
<header
data-testid="module-selection-drag-region"
className="drag-region absolute inset-x-0 top-0 flex h-16 items-start justify-end px-6 pt-6 sm:px-8 sm:pt-8"
>
<img src={logoWordmarkSource} alt="Makelore" className="h-8 w-40 object-cover object-center" draggable="false" />
</header>
<div className="grid flex-1 items-center gap-8 py-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] lg:gap-12">
<section className="min-w-0">
<p className="text-sm font-semibold tracking-[0.08em] text-muted-foreground">
{userDisplayName ? `${userDisplayName},欢迎回来!` : '欢迎回来!'}
</p>
<h1 className="mt-3 whitespace-nowrap text-[clamp(2rem,4vw,4rem)] font-semibold leading-[1.02] tracking-[-0.055em]"></h1>
</section>
<section
data-testid="ai-module-options"
aria-label="AI 模块列表"
className="module-selection-options"
>
{aiModules.map((module) => {
const content = moduleSelectionContent[module.id];
const enabledByPolicy = isAiModuleAllowed(module.id, moduleAccess);
const enabled = module.enabled && enabledByPolicy;
return (
<button
key={module.id}
type="button"
data-testid={`ai-module-option-${module.id}`}
disabled={!enabled}
aria-disabled={!enabled}
onClick={() => openModule(module.route, enabled)}
aria-label={`${content.label}${module.description}${enabled ? '' : '(已关闭)'}`}
className={cn(
'module-option-card module-option-card-horizontal group motion-press flex w-full min-w-0 items-stretch rounded-lg border border-transparent bg-transparent p-0 text-left shadow-none disabled:cursor-not-allowed disabled:bg-transparent disabled:text-muted-foreground/70 disabled:opacity-75',
!enabled && 'module-option-card-disabled',
)}
>
<span className="module-option-card-frame" aria-hidden="true" />
<div className="module-option-card-surface relative flex h-full w-full rounded-lg bg-background">
<div className="module-option-card-image relative shrink-0">
<img
src={content.image}
alt={content.imageAlt}
className={cn('module-option-card-image-media h-full w-full object-cover', !enabled && 'grayscale')}
draggable="false"
/>
</div>
<div className="module-option-card-content relative min-h-0 flex-1">
<p className="module-option-card-title font-medium tracking-[-0.02em]">{content.label}</p>
<p className="module-option-card-description font-medium text-muted-foreground">{module.description}</p>
{!enabled ? (
<p className="module-option-card-status font-semibold">
{module.enabled ? '管理员已关闭' : '暂未开放'}
</p>
) : null}
</div>
</div>
<span className="module-option-card-hit-area" aria-hidden="true" />
</button>
);
})}
</section>
</div>
</div>
<UserProfileDialog
open={profileDialogShouldBeOpen}
required={profileRequired}
syncError={profileSyncError}
onOpenChange={(open) => {
if (open) {
setDismissedSyncError(null);
setProfileDialogOpen(true);
return;
}
if (profileSyncError) setDismissedSyncError(profileSyncError);
setProfileDialogOpen(false);
}}
/>
</main>
);
}
export default ModuleSelection;