feat: 食品添加剂课程业务开发(接待/主场景/取证/记录/信函学习页面)

This commit is contained in:
zhongyidian committed 2026-08-31 14:44:13 +08:00
1 parent ad37dfeb24
commit bf0c194d16
192 files changed
+4675 -624

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
import { useLayoutEffect, useRef } from "react";
import { ImageButton } from "@/components/common";
import { getStaticImage } from "@/utils";
import { EASE_IN_OUT, EASE_OUT, gsap, useReducedMotion } from "@/utils/motion";
import type { DeskItemProps } from "./DeskItem.types";
export function DeskItem({ item, onClick }: DeskItemProps) {
const rootRef = useRef<HTMLDivElement>(null);
const guideRef = useRef<HTMLImageElement>(null);
const reduced = useReducedMotion();
// 物件登场:从桌面下方托起;引导标签持续上下轻浮,提示可点击
useLayoutEffect(() => {
const root = rootRef.current;
const guide = guideRef.current;
if (!root) return;
if (reduced) {
gsap.set(root, { opacity: 1, y: 0 });
return;
}
const enter = gsap.fromTo(
root,
{ opacity: 0, y: 60 },
{ opacity: 1, y: 0, duration: 0.6, ease: EASE_OUT },
);
const float = guide
? gsap.fromTo(
guide,
{ y: 0 },
{
y: -10,
duration: 0.9,
ease: EASE_IN_OUT,
repeat: -1,
yoyo: true,
delay: 0.6,
},
)
: null;
return () => {
enter.kill();
float?.kill();
};
}, [reduced]);
return (
<div
ref={rootRef}
className="pointer-events-auto absolute"
style={{ left: item.left, bottom: item.bottom }}
>
<img
ref={guideRef}
src={getStaticImage(item.guide)}
alt=""
aria-hidden="true"
draggable={false}
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 max-w-none select-none"
style={{ width: item.guideWidth, height: item.guideHeight }}
/>
<ImageButton
src={item.image}
alt={item.name}
aria-label={`查看${item.name}`}
width={item.width}
height={item.height}
onClick={onClick}
/>
</div>
);
}
@@ -0,0 +1,6 @@
import type { DeskItemData } from "../Record.types";
export interface DeskItemProps {
item: DeskItemData;
onClick: () => void;
}
@@ -0,0 +1,101 @@
import { useLayoutEffect, useRef } from "react";
import { cn, getStaticImage } from "@/utils";
import { EASE_OUT, gsap, useReducedMotion } from "@/utils/motion";
import type { DialogueBubbleProps } from "./DialogueBubble.types";
const SPEAKERS = {
detective: {
tag: "record/称号标签-添侦探",
bubble: "record/添侦探.svg",
align: "left",
tagClassName: "left-10 h-18",
textClassName: "pl-12 pr-16",
},
assistant: {
tag: "record/称号标签-侦探助手",
bubble: "record/侦探助手.svg",
align: "right",
tagClassName: "right-14 h-20",
textClassName: "pl-10 pr-16",
},
} as const;
export function DialogueBubble({ line }: DialogueBubbleProps) {
const rootRef = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const speaker = SPEAKERS[line.speaker];
const fromRight = speaker.align === "right";
// 新消息从发言角色方向滑入,并带轻微回弹
useLayoutEffect(() => {
const node = rootRef.current;
if (!node) return;
if (reduced) {
gsap.set(node, { opacity: 1, x: 0, y: 0, scale: 1 });
return;
}
const tween = gsap.fromTo(
node,
{
opacity: 0,
x: fromRight ? 55 : -55,
y: 18,
scale: 0.92,
},
{
opacity: 1,
x: 0,
y: 0,
scale: 1,
duration: 0.55,
ease: EASE_OUT,
},
);
return () => {
tween.kill();
};
}, [fromRight, reduced]);
return (
<div
ref={rootRef}
className={cn(
"flex w-full flex-col",
fromRight ? "items-end" : "items-start",
)}
>
<div className="relative min-h-35 w-fit max-w-full shrink-0 ">
<img
src={getStaticImage(speaker.bubble)}
alt=""
aria-hidden="true"
draggable={false}
className="pointer-events-none absolute inset-0 h-full w-full max-w-none select-none"
/>
<img
src={getStaticImage(speaker.tag)}
alt=""
aria-hidden="true"
draggable={false}
className={cn(
"absolute -top-10 z-10 max-w-none select-none",
speaker.tagClassName,
)}
/>
<p
className={cn(
"relative flex h-full items-center whitespace-nowrap text-[28px] leading-normal text-[#5b4632]",
speaker.textClassName,
)}
>
{line.text}
</p>
</div>
</div>
);
}
@@ -0,0 +1,5 @@
import type { RecordLine } from "../Record.types";
export interface DialogueBubbleProps {
line: RecordLine;
}
@@ -0,0 +1,76 @@
import { useEffect, useMemo, useRef } from "react";
import { useRecordStore } from "@/stores/modules/record";
import { useReducedMotion } from "@/utils/motion";
import { PHASE_ORDER, RECORD_DIALOGUE } from "../record.data";
import { DialogueBubble } from "./DialogueBubble";
const FIRST_LINE_DELAY = 350;
const LINE_INTERVAL = 950;
export function DialogueFeed() {
const phase = useRecordStore((state) => state.phase);
const revealedCount = useRecordStore((state) => state.revealedCount);
const revealNextLine = useRecordStore((state) => state.revealNextLine);
const reduced = useReducedMotion();
const scrollRef = useRef<HTMLDivElement>(null);
const lines = useMemo(() => {
const reached = PHASE_ORDER.indexOf(phase);
return RECORD_DIALOGUE.filter(
(line) => PHASE_ORDER.indexOf(line.phase) <= reached,
);
}, [phase]);
const pending = revealedCount < lines.length;
useEffect(() => {
if (!pending) return;
const timer = setTimeout(
revealNextLine,
revealedCount === 0 ? FIRST_LINE_DELAY : LINE_INTERVAL,
);
return () => clearTimeout(timer);
}, [pending, revealedCount, revealNextLine]);
useEffect(() => {
const node = scrollRef.current;
if (!node || revealedCount === 0) return;
node.scrollTo({
top: node.scrollHeight,
behavior: reduced || revealedCount === 1 ? "auto" : "smooth",
});
}, [reduced, revealedCount]);
return (
<div
ref={scrollRef}
role="log"
aria-label="调查对话记录,可上下滑动查看"
aria-live="polite"
className="flex h-full touch-pan-y flex-col gap-8 overflow-y-auto overscroll-contain px-0 pt-12 pb-5 outline-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
style={{ WebkitOverflowScrolling: "touch" }}
>
{lines.slice(0, revealedCount).map((line) => (
<DialogueBubble key={line.id} line={line} />
))}
{pending && revealedCount > 0 && (
<div
className="flex items-center justify-center gap-3"
aria-hidden="true"
>
{[0, 1, 2].map((dot) => (
<span
key={dot}
className="h-3.5 w-3.5 animate-bounce rounded-full bg-[#fff0cf]"
style={{ animationDelay: `${dot * 0.16}s` }}
/>
))}
</div>
)}
</div>
);
}