137 lines
6.7 KiB
TypeScript
137 lines
6.7 KiB
TypeScript
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;
|