feat: 食品添加剂课程业务开发(接待/主场景/取证/记录/信函学习页面)
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user