feat: 食品添加剂课程业务开发(接待/主场景/取证/记录/信函学习页面)
This commit is contained in:
1 parent
ad37dfeb24
commit
bf0c194d16
192 files changed
+4675
-624
No files matched your search
@@ -0,0 +1,32 @@
|
||||
import type { RecordPhase } from "@/stores/types/record.types";
|
||||
|
||||
export type RecordSpeaker = "detective" | "assistant";
|
||||
|
||||
/** 一条调查流水对话 */
|
||||
export interface RecordLine {
|
||||
id: string;
|
||||
/** 属于哪个剧情阶段,阶段推进后才会放出 */
|
||||
phase: RecordPhase;
|
||||
speaker: RecordSpeaker;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** 桌面物件:随剧情阶段变化,点击进入对应子场景 */
|
||||
export interface DeskItemData {
|
||||
id: string;
|
||||
phase: RecordPhase;
|
||||
name: string;
|
||||
/** 物件图片资源 key */
|
||||
image: string;
|
||||
/** 引导标签资源 key(悬在物件上方) */
|
||||
guide: string;
|
||||
/** 点击后进入的路由 */
|
||||
to: string;
|
||||
/** 以 1920 x 1080 设计稿为准的落位与尺寸 */
|
||||
left: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
guideWidth: number;
|
||||
guideHeight: number;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { BackgroundContainer, ImageButton } from "@/components/common";
|
||||
import { useRecordStore } from "@/stores/modules/record";
|
||||
import { getStaticImage } from "@/utils";
|
||||
import { DeskItem } from "./components/DeskItem";
|
||||
import { DialogueFeed } from "./components/DialogueFeed";
|
||||
import { DESK_ITEMS } from "./record.data";
|
||||
|
||||
export default function Record() {
|
||||
const navigate = useNavigate();
|
||||
const phase = useRecordStore((state) => state.phase);
|
||||
|
||||
// 桌面物件随剧情阶段替换
|
||||
const deskItems = DESK_ITEMS.filter((item) => item.phase === phase);
|
||||
|
||||
return (
|
||||
<BackgroundContainer
|
||||
src="record/对话全屏背景"
|
||||
className="relative h-full w-full overflow-hidden"
|
||||
contentClassName="absolute inset-0 overflow-hidden"
|
||||
>
|
||||
{/* 页面标题 */}
|
||||
<img
|
||||
src={getStaticImage("title/按钮-调查记录")}
|
||||
alt="调查交流记录"
|
||||
draggable={false}
|
||||
className="absolute left-5 top-0 z-30 h-auto w-131 select-none"
|
||||
/>
|
||||
|
||||
{/* 返回主场景 */}
|
||||
<div className="absolute right-8 top-5 z-30">
|
||||
<ImageButton
|
||||
src="components/button/按钮-返回"
|
||||
alt="返回主场景"
|
||||
width={244}
|
||||
height={98}
|
||||
onClick={() => navigate("/main-scene")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 对话角色位于桌板后方 */}
|
||||
<img
|
||||
src={getStaticImage("record/形象-添侦探")}
|
||||
alt="添侦探"
|
||||
draggable={false}
|
||||
className="absolute bottom-0 left-27 z-10 h-222 select-none"
|
||||
/>
|
||||
<img
|
||||
src={getStaticImage("record/形象-侦探助手")}
|
||||
alt="实习侦探"
|
||||
draggable={false}
|
||||
className="absolute -right-12 -bottom-4 z-10 h-201 select-none"
|
||||
/>
|
||||
|
||||
{/* 最新对话逐条浮现 */}
|
||||
<section
|
||||
aria-label="调查交流记录"
|
||||
className="absolute left-142.5 top-90 z-20 h-105 w-262.5"
|
||||
>
|
||||
<DialogueFeed />
|
||||
</section>
|
||||
|
||||
{/* 前景桌板与侦探档案盒,遮挡角色下半身 */}
|
||||
<img
|
||||
src={getStaticImage("record/对话全屏-置顶桌板")}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className="pointer-events-none absolute bottom-0 left-0 z-30 h-168 max-w-none select-none"
|
||||
/>
|
||||
|
||||
{/* 桌面物件位于桌板之上 */}
|
||||
<div className="pointer-events-none absolute bottom-16 z-40">
|
||||
{deskItems.map((item) => (
|
||||
<DeskItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={() => navigate(item.to)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</BackgroundContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { RecordPhase } from "@/stores/types/record.types";
|
||||
import type { DeskItemData, RecordLine } from "./Record.types";
|
||||
|
||||
/** 剧情推进顺序,用于判断某阶段的内容是否已经解锁 */
|
||||
export const PHASE_ORDER: RecordPhase[] = ["letter", "cola"];
|
||||
|
||||
export const RECORD_DIALOGUE: RecordLine[] = [
|
||||
{
|
||||
id: "d1",
|
||||
phase: "letter",
|
||||
speaker: "assistant",
|
||||
text: "添侦探,我们的调查从哪里开始呢?",
|
||||
},
|
||||
{
|
||||
id: "d2",
|
||||
phase: "letter",
|
||||
speaker: "detective",
|
||||
text: "我们再来看看求助信",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 桌面物件按剧情阶段出现,尺寸按素材原始比例放大。
|
||||
* 下一阶段的「可乐(点击标签进入核查)」在这里追加一条即可。
|
||||
*/
|
||||
export const DESK_ITEMS: DeskItemData[] = [
|
||||
{
|
||||
id: "help-letter",
|
||||
phase: "letter",
|
||||
name: "求助信",
|
||||
image: "record/可点击-求助信",
|
||||
guide: "record/引导-点击查看求助信",
|
||||
to: "/letter-study",
|
||||
// 可点击-求助信 原图 303 x 138,引导 350 x 167
|
||||
left: 860,
|
||||
bottom: -58,
|
||||
width: 303,
|
||||
height: 138,
|
||||
guideWidth: 350,
|
||||
guideHeight: 167,
|
||||
},
|
||||
];
|
||||
Reference in new issue
Block a user