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

@@ -0,0 +1,563 @@
.letter {
filter: drop-shadow(0 18px 22px rgb(70 37 12 / 28%));
}
.highlight {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 2px solid rgb(255 255 255 / 82%);
border-radius: 999px;
color: #fffdf2;
font-weight: 700;
font-size: 17px;
line-height: 1;
letter-spacing: 0.2px;
white-space: nowrap;
text-shadow: 0 1px 2px rgb(93 48 20 / 36%);
box-shadow:
0 2px 5px rgb(77 44 17 / 18%),
inset 0 1px 2px rgb(255 255 255 / 34%);
cursor: grab;
}
.highlight:active {
cursor: grabbing;
}
.highlightPlaced {
opacity: 0;
}
.highlightOverlay {
min-width: 250px;
height: 48px;
padding: 0 22px;
font-size: 22px;
box-shadow:
0 12px 26px rgb(75 41 13 / 28%),
inset 0 1px 2px rgb(255 255 255 / 40%);
}
.board {
position: absolute;
left: 835px;
top: 140px;
width: 1035px;
height: 825px;
box-sizing: border-box;
border: 20px solid #9a5729;
border-radius: 34px 34px 48px 48px;
background:
radial-gradient(circle at 12% 18%, rgb(255 255 255 / 24%) 0 3px, transparent 4px),
linear-gradient(100deg, #ffe8b5 0%, #fff2c9 48%, #f8d994 100%);
box-shadow:
0 22px 34px rgb(62 31 8 / 40%),
inset 0 0 0 5px #e7a45c,
inset 0 0 0 10px #70401f;
}
.board::before {
content: "";
position: absolute;
z-index: 4;
left: -34px;
top: -54px;
width: calc(100% + 68px);
height: 58px;
box-sizing: border-box;
border: 7px solid #774019;
border-radius: 30px;
background:
linear-gradient(90deg, transparent 0 10px, #f3aa54 11px 18px, transparent 19px calc(100% - 19px), #f3aa54 calc(100% - 18px) calc(100% - 11px), transparent calc(100% - 10px)),
linear-gradient(#d88639, #f0b36a 40%, #b76528 44%, #d88a3f 72%, #8d471e);
box-shadow:
inset 0 2px 3px rgb(255 239 181 / 75%),
0 6px 8px rgb(65 30 8 / 25%);
}
.board::after {
content: "";
position: absolute;
inset: 7px;
pointer-events: none;
border: 3px solid rgb(169 96 38 / 55%);
border-radius: 17px 17px 29px 29px;
box-shadow: inset 0 0 28px rgb(165 93 32 / 24%);
}
.hanger {
position: absolute;
z-index: 5;
top: -104px;
width: 25px;
height: 104px;
border: 4px solid #5a321b;
border-top: 0;
border-radius: 0 0 10px 10px;
background: linear-gradient(90deg, #6e3d20, #b77442 44%, #5e321b);
box-shadow: 4px 2px 4px rgb(54 26 9 / 24%);
}
.hangerLeft {
left: 285px;
}
.hangerRight {
right: 270px;
}
.boardTitle {
position: absolute;
z-index: 7;
left: 50%;
top: -20px;
width: 535px;
height: 92px;
transform: translateX(-50%);
box-sizing: border-box;
border: 6px solid #7b401b;
border-radius: 26px;
background: linear-gradient(#c9803d, #9b5424 58%, #7f411d);
color: #ffebbd;
font-weight: 800;
font-size: 43px;
line-height: 78px;
letter-spacing: 14px;
text-align: center;
text-indent: 14px;
text-shadow:
0 3px 0 #6c3215,
0 0 5px #fff2bb;
box-shadow:
inset 0 3px 4px rgb(255 222 141 / 55%),
0 8px 10px rgb(82 39 11 / 28%);
}
.boardTitle::before,
.boardTitle::after {
content: "•";
position: absolute;
top: 0;
color: #e9ad5f;
font-size: 30px;
text-shadow: 0 2px #6c3215;
}
.boardTitle::before {
left: 18px;
}
.boardTitle::after {
right: 18px;
}
.slotGrid {
position: absolute;
z-index: 2;
inset: 108px 55px 55px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 42px 45px;
}
.slot {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 0;
box-sizing: border-box;
border: 4px dashed #d7a65a;
border-radius: 28px;
background: rgb(255 228 168 / 28%);
transition: 160ms ease;
}
.slot::before,
.slot::after {
content: "✦";
position: absolute;
color: #dca04b;
font-size: 28px;
}
.slot::before {
left: 15px;
top: 14px;
}
.slot::after {
right: 15px;
bottom: 12px;
}
.slotOver {
border-color: #b66d28;
background: rgb(248 194 88 / 34%);
box-shadow: 0 0 0 8px rgb(220 147 50 / 13%);
transform: scale(1.015);
}
.magnifier {
width: 94px;
height: 94px;
color: #dda858;
opacity: 0.34;
}
.card {
position: relative;
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 15px 22px 17px;
border: 4px solid color-mix(in srgb, var(--clue-color), #9b591c 25%);
border-radius: 9px;
background:
repeating-linear-gradient(0deg, rgb(151 93 32 / 4%) 0 1px, transparent 1px 7px),
#fff9e8;
box-shadow:
0 9px 12px rgb(97 52 13 / 21%),
inset 0 0 0 3px #fff;
transform: rotate(var(--card-rotate));
animation: cardIn 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.card::after {
content: "";
position: absolute;
inset: 10px;
pointer-events: none;
border: 2px dashed color-mix(in srgb, var(--clue-color), transparent 35%);
border-radius: 5px;
}
@keyframes cardIn {
from {
opacity: 0;
transform: translateY(-25px) rotate(calc(var(--card-rotate) - 2deg)) scale(0.86);
}
}
.bookmark {
position: absolute;
z-index: 3;
left: 17px;
top: -9px;
width: 44px;
height: 58px;
box-sizing: border-box;
padding-top: 8px;
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 76%, 0 100%);
background: var(--clue-color);
color: white;
font-size: 24px;
text-align: center;
text-shadow: 0 2px 1px rgb(97 45 15 / 35%);
box-shadow: inset 0 0 0 3px rgb(255 255 255 / 55%);
}
.pin {
position: absolute;
z-index: 4;
right: 15px;
top: -13px;
width: 21px;
height: 21px;
border: 3px solid #9b571d;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff0ad 0 15%, #e99c35 25% 62%, #a95d1d 64%);
box-shadow: 2px 3px 3px rgb(71 36 13 / 28%);
}
.pin::after {
content: "";
position: absolute;
left: 13px;
top: 14px;
width: 3px;
height: 24px;
transform: rotate(34deg);
transform-origin: top;
background: #875123;
}
.cardIcon {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 128px;
height: 128px;
margin: 0 auto 2px;
border: 6px solid var(--clue-color);
border-radius: 50%;
background: #fff1c9;
font-size: 74px;
line-height: 1;
box-shadow:
0 0 0 5px #fff9e8,
0 0 0 8px var(--clue-color);
}
.cardTitle {
position: relative;
z-index: 3;
width: 92%;
height: 60px;
margin: 4px auto 8px;
border: 3px solid #fff7d4;
border-radius: 8px;
background: var(--clue-dark-color);
color: #fff8db;
font-weight: 800;
font-size: 34px;
line-height: 54px;
letter-spacing: 4px;
text-align: center;
text-shadow: 0 2px 2px rgb(88 39 12 / 38%);
box-shadow: 0 0 0 3px var(--clue-dark-color);
}
.cardLabel {
position: relative;
z-index: 2;
overflow: hidden;
width: 92%;
margin: 0 auto;
color: #694424;
font-size: 21px;
line-height: 37px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.completedStamp {
position: absolute;
z-index: 12;
right: 50px;
bottom: 65px;
transform: rotate(-8deg);
border: 6px double #ff7779;
border-radius: 20px;
padding: 7px 17px;
color: #ff7478;
font-weight: 800;
font-size: 34px;
letter-spacing: 2px;
background: rgb(255 250 235 / 76%);
box-shadow: inset 0 0 0 2px #ff9a9c;
animation: stampIn 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes stampIn {
from {
opacity: 0;
transform: rotate(-8deg) scale(1.8);
}
}
.investigateButton {
position: absolute;
z-index: 12;
left: 50%;
transform: translateX(-50%);
min-width: 260px;
height: 75px;
border: 6px double #ffe1a1;
border-radius: 27px;
background: linear-gradient(#eead56, #d77c24 58%, #ba601b);
color: #fff9d9;
font-weight: 800;
font-size: 36px;
letter-spacing: 5px;
text-shadow: 0 3px 2px #9a4819;
box-shadow:
0 4px 0 #824019,
0 9px 16px rgb(73 34 9 / 30%);
cursor: pointer;
animation: cardIn 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.investigateButton:hover {
filter: brightness(1.06);
}
.investigateButton:active {
transform: translateX(-50%) translateY(3px);
box-shadow:
0 1px 0 #824019,
0 5px 10px rgb(73 34 9 / 24%);
}
.bottomHint {
position: absolute;
z-index: 20;
left: 50%;
bottom: 23px;
display: flex;
align-items: center;
gap: 20px;
transform: translateX(-50%);
color: #fff4d2;
font-weight: 700;
font-size: 30px;
white-space: nowrap;
text-shadow: 0 2px 4px rgb(88 42 13 / 62%);
}
.bottomHint::before,
.bottomHint::after {
content: "";
width: 195px;
height: 2px;
background: linear-gradient(90deg, transparent, #fff4d2 35%, #fff4d2 65%, transparent);
}
.bulb {
font-size: 45px;
filter: drop-shadow(0 2px 2px rgb(89 44 15 / 42%));
}
@media (prefers-reduced-motion: reduce) {
.card,
.completedStamp,
.investigateButton {
animation: none;
}
}
/* 制作稿已提供完整交互素材,以下样式只负责定位和状态变化。 */
.sourceHighlight {
position: absolute;
z-index: 2;
pointer-events: none;
transition: opacity 180ms ease;
}
.highlightPlaced {
opacity: 0;
}
.highlightOverlay {
position: relative !important;
left: auto !important;
top: auto !important;
min-width: 0;
padding: 0;
filter: drop-shadow(0 12px 16px rgb(70 36 10 / 30%));
}
.hotspot {
z-index: 3;
cursor: grab;
}
.hotspot:active {
cursor: grabbing;
}
.slot {
border: 0;
border-radius: 0;
background: transparent;
}
.slot::before,
.slot::after {
display: none;
}
.slotOver {
border: 0;
background: transparent;
filter: brightness(1.06) drop-shadow(0 0 10px rgb(205 132 33 / 42%));
box-shadow: none;
transform: scale(1.02);
}
.card {
position: relative;
width: auto;
height: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 0;
border: 0;
border-radius: 0;
background: none;
object-fit: contain;
box-shadow: none;
transform: none;
animation: cardIn 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.completedStamp {
right: 42px;
bottom: 58px;
width: 248px;
height: 124px;
box-sizing: border-box;
padding: 0;
border: 0;
border-radius: 0;
background: none;
box-shadow: none;
object-fit: contain;
}
.investigateButton {
left: 1352px;
top: 900px;
bottom: auto;
width: 241px;
min-width: 0;
height: 81px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.investigateButton img {
display: block;
width: 100%;
height: 100%;
}
.investigateButton:active {
transform: translateX(-50%) translateY(3px);
box-shadow: none;
}
.bottomHint {
bottom: 0;
gap: 18px;
color: inherit;
font-size: inherit;
text-shadow: none;
}
.bottomHint::before,
.bottomHint::after {
display: none;
}
.bottomHint > span {
width: 220px;
height: 3px;
background: linear-gradient(90deg, transparent, #fff5d3 20% 80%, transparent);
}
.bottomHint > img {
width: 849px;
height: 108px;
object-fit: contain;
}
+303
View File
@@ -0,0 +1,303 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { appConfig } from "@/app.config";
import { BackgroundContainer, VideoOverlay } from "@/components/common";
import {
DragDropProvider,
type DragDropProviderProps,
Draggable,
Droppable,
} from "@/components/course-interactions/drag-drop";
import { useCourseCanvas } from "@/course-runtime/canvas";
import { useCaseStore } from "@/stores/modules/case";
import { cn, getPublicPath, getStaticImage } from "@/utils";
import styles from "./LetterStudy.module.css";
import { LETTER_CLUES } from "./letterStudy.data";
import type { ClueCardProps, ClueHighlightProps } from "./letterStudy.types";
const LETTER_WIDTH = 697;
const LETTER_HEIGHT = 834;
const SLOT_COUNT = LETTER_CLUES.length;
const COMPLETION_VIDEO = "video/demo.mp4";
const SLOTS_STORAGE_KEY = `${appConfig.storage.prefix}-letter-study-slots-session`;
const createEmptySlots = () =>
Array.from({ length: SLOT_COUNT }, () => null as string | null);
const restoreSlots = (): (string | null)[] => {
try {
const stored = sessionStorage.getItem(SLOTS_STORAGE_KEY);
if (!stored) return createEmptySlots();
const parsed: unknown = JSON.parse(stored);
if (!Array.isArray(parsed) || parsed.length !== SLOT_COUNT) {
return createEmptySlots();
}
const clueIds = new Set(LETTER_CLUES.map((clue) => clue.id));
const valid = parsed.every(
(id): id is string | null =>
id === null || (typeof id === "string" && clueIds.has(id)),
);
if (!valid) return createEmptySlots();
const placedIds = parsed.filter(
(id): id is string => typeof id === "string",
);
return new Set(placedIds).size === placedIds.length
? parsed
: createEmptySlots();
} catch {
return createEmptySlots();
}
};
const findClue = (id: string) => LETTER_CLUES.find((clue) => clue.id === id);
function ClueHighlight({ clue, className }: ClueHighlightProps) {
return (
<img
src={getStaticImage(clue.dragImage)}
alt=""
aria-hidden="true"
draggable={false}
className={cn("select-none", className)}
style={{
left: clue.dragArea.left * LETTER_WIDTH,
top: clue.dragArea.top * LETTER_HEIGHT,
width: clue.dragArea.width * LETTER_WIDTH,
height: clue.dragArea.height * LETTER_HEIGHT,
maxWidth: "none",
}}
/>
);
}
function ClueCard({ clue, className }: ClueCardProps) {
return (
<img
src={getStaticImage(clue.cardImage)}
alt={`${clue.label}调查卡`}
draggable={false}
className={cn(styles.card, "select-none", className)}
/>
);
}
export default function LetterStudy() {
const navigate = useNavigate();
const { scale } = useCourseCanvas();
const promptCaseRegistration = useCaseStore(
(state) => state.promptCaseRegistration,
);
const [activeClueId, setActiveClueId] = useState<string | null>(null);
const [slots, setSlots] = useState<(string | null)[]>(restoreSlots);
const [videoOpen, setVideoOpen] = useState(false);
const completionFinishedRef = useRef(false);
const placedIds = slots.filter((id): id is string => id !== null);
const allPlaced = placedIds.length === SLOT_COUNT;
useEffect(() => {
try {
sessionStorage.setItem(SLOTS_STORAGE_KEY, JSON.stringify(slots));
} catch {
// sessionStorage 不可用时仅退回当前页面内状态
}
}, [slots]);
const handleVideoFinish = useCallback(() => {
if (completionFinishedRef.current) return;
completionFinishedRef.current = true;
promptCaseRegistration();
setVideoOpen(false);
navigate("/main-scene");
}, [navigate, promptCaseRegistration]);
const handleDragStart: DragDropProviderProps["onDragStart"] = (event) => {
setActiveClueId(String(event.active.data.current?.clueId ?? ""));
};
const handleDragEnd: DragDropProviderProps["onDragEnd"] = (event) => {
setActiveClueId(null);
const clueId = String(event.active.data.current?.clueId ?? "");
const slotIndex = Number(String(event.over?.id ?? "").replace("slot-", ""));
if (!clueId || !event.over || Number.isNaN(slotIndex)) return;
setSlots((previousSlots) => {
if (previousSlots.includes(clueId) || previousSlots[slotIndex] !== null) {
return previousSlots;
}
const nextSlots = [...previousSlots];
nextSlots[slotIndex] = clueId;
return nextSlots;
});
};
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"
/>
<DragDropProvider
scale={scale}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveClueId(null)}
renderOverlay={({ activeId }) => {
const clue = findClue(String(activeId).split(":")[0]);
return clue ? (
<ClueHighlight
clue={clue}
className={cn("pointer-events-none", styles.highlightOverlay)}
/>
) : null;
}}
>
<div
className={cn("absolute left-12.5 top-38.5", styles.letter)}
style={{ width: LETTER_WIDTH, height: LETTER_HEIGHT }}
>
<img
src={getStaticImage("main-scene/letters/求助信-交互底板用")}
alt="恐慌市民乙先生的求助信"
draggable={false}
className="h-full w-full select-none"
/>
{LETTER_CLUES.map((clue) => {
const hidden =
placedIds.includes(clue.id) || activeClueId === clue.id;
return (
<ClueHighlight
key={`${clue.id}-image`}
clue={clue}
className={cn(
styles.sourceHighlight,
hidden && styles.highlightPlaced,
)}
/>
);
})}
{LETTER_CLUES.flatMap((clue) => {
const placed = placedIds.includes(clue.id);
return clue.hotspots.map((hotspot, index) => (
<Draggable
key={`${clue.id}:${index}`}
id={`${clue.id}:${index}`}
data={{ clueId: clue.id }}
disabled={placed}
aria-label={`拖动线索:${clue.label}`}
className={styles.hotspot}
style={{
position: "absolute",
left: `${hotspot.left * 100}%`,
top: `${hotspot.top * 100}%`,
width: `${hotspot.width * 100}%`,
height: `${hotspot.height * 100}%`,
}}
>
<span className="block h-full w-full" />
</Draggable>
));
})}
{allPlaced && (
<img
src={getStaticImage("record/敲章-线索已整理")}
alt="线索已整理"
draggable={false}
className={styles.completedStamp}
/>
)}
</div>
<section aria-label="案件线索板" className={styles.board}>
<span className={cn(styles.hanger, styles.hangerLeft)} />
<span className={cn(styles.hanger, styles.hangerRight)} />
<h2 className={styles.boardTitle}>案件线索板</h2>
<div className={styles.slotGrid}>
{slots.map((clueId, index) => {
const clue = clueId ? findClue(clueId) : undefined;
const slotId = `slot-${index}`;
return (
<Droppable
key={slotId}
id={slotId}
canDrop={() => clue === undefined}
className={({ isOver, canDrop }) =>
cn(styles.slot, isOver && canDrop && styles.slotOver)
}
>
{clue ? (
<ClueCard clue={clue} />
) : (
<img
src={getStaticImage("main-scene/interactions/拖拽区域")}
alt=""
aria-hidden="true"
draggable={false}
className="h-full w-full select-none object-contain"
/>
)}
</Droppable>
);
})}
</div>
</section>
{allPlaced && (
<button
type="button"
className={styles.investigateButton}
onClick={() => setVideoOpen(true)}
>
<img
src={getStaticImage("main-scene/buttons/按钮-深入调查")}
alt="深入调查"
draggable={false}
/>
</button>
)}
</DragDropProvider>
<div className={styles.bottomHint}>
<span />
<img
src={getStaticImage(
allPlaced
? "main-scene/interactions/恭喜关键线索提炼"
: "main-scene/interactions/拖拽提示",
)}
alt={
allPlaced
? "恭喜!关键线索已提炼完成,快去深入调查吧!"
: "拖拽信中关键信息至线索板上,生成调查方向"
}
draggable={false}
/>
<span />
</div>
<VideoOverlay
open={videoOpen}
src={getPublicPath(COMPLETION_VIDEO)}
skipLabel="跳过"
onEnded={handleVideoFinish}
onSkip={handleVideoFinish}
/>
</BackgroundContainer>
);
}
+66
View File
@@ -0,0 +1,66 @@
import type { LetterClue } from "./letterStudy.types";
/**
* 四张拖拽图片按求助信原图 697 x 834 定位。橙色和蓝色素材已经包含
* 跨行的两段高亮,因此每条说法只需要一个拖拽热区。
*/
export const LETTER_CLUES: LetterClue[] = [
{
id: "colorant",
label: "颜色鲜艳是因为色素多",
dragImage: "main-scene/interactions/拖拽-红",
cardImage: "main-scene/cards/卡片-色彩迷局",
dragArea: {
left: 0.2568,
top: 0.4551,
width: 0.3401,
height: 0.0647,
},
hotspots: [{ left: 0.2568, top: 0.4551, width: 0.3401, height: 0.0647 }],
},
{
id: "flavoring",
label: "水果味都是香精调出来的",
dragImage: "main-scene/interactions/拖拽-橙",
cardImage: "main-scene/cards/卡片-味觉密码",
dragArea: {
left: 0.0947,
top: 0.4551,
width: 0.7848,
height: 0.1187,
},
hotspots: [
{ left: 0.6069, top: 0.4551, width: 0.2726, height: 0.0647 },
{ left: 0.0947, top: 0.5102, width: 0.1722, height: 0.0647 },
],
},
{
id: "thickener",
label: "果冻Q弹是因为加了很多“胶”",
dragImage: "main-scene/interactions/拖拽-绿",
cardImage: "main-scene/cards/卡片-Q弹真相",
dragArea: {
left: 0.2568,
top: 0.5102,
width: 0.4648,
height: 0.0647,
},
hotspots: [{ left: 0.2568, top: 0.5102, width: 0.4648, height: 0.0647 }],
},
{
id: "preservative",
label: "面包放得久是因为防腐剂多",
dragImage: "main-scene/interactions/拖拽-蓝",
cardImage: "main-scene/cards/卡片-保鲜疑云",
dragArea: {
left: 0.0947,
top: 0.5102,
width: 0.7848,
height: 0.1199,
},
hotspots: [
{ left: 0.7346, top: 0.5102, width: 0.1449, height: 0.0647 },
{ left: 0.0947, top: 0.5653, width: 0.3099, height: 0.0647 },
],
},
];
@@ -0,0 +1,27 @@
/** 信纸上可拖拽线索图的比例位置和尺寸 */
export interface LetterClueDragArea {
left: number;
top: number;
width: number;
height: number;
}
/** 求助信里的一条关键信息,拖到线索板后生成调查方向 */
export interface LetterClue {
id: string;
label: string;
dragImage: string;
cardImage: string;
dragArea: LetterClueDragArea;
hotspots: LetterClueDragArea[];
}
export interface ClueCardProps {
clue: LetterClue;
className?: string;
}
export interface ClueHighlightProps {
clue: LetterClue;
className?: string;
}