Skip to content

Commit 49ef6da

Browse files
committed
feat: 新手引导系统
1 parent 4c87c50 commit 49ef6da

9 files changed

Lines changed: 710 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- 在合适的时候自主调用相关 skill
99
- 如果要撰写 prd、ttd、临时文档等需要放在 `\dev\design`
1010
- 始终使用中文与我交流
11+
- 在代码字符串中禁止使用中文引号(`""``''`),必须使用英文引号或其他方式替代,避免解析器将其误判为字符串边界导致语法错误
1112

1213
## 建议执行
1314

src/App.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ import { useEmbedStore } from "./stores/embedStore";
7373
import { useEmbedMode } from "./hooks/useEmbedMode";
7474
import { useEmbedChangeNotifier } from "./hooks/useEmbedChangeNotifier";
7575
import { useFlowStore } from "./stores/flow";
76+
import { useNewcomerStore, isNewcomerPassed } from "./stores/newcomerStore";
77+
import { NewcomerGuideModal } from "./components/modals/NewcomerGuideModal";
7678

7779
const JsonViewer = lazy(() => import("./components/JsonViewer"));
7880
const DebugModal = lazy(() =>
@@ -501,8 +503,16 @@ function App() {
501503
}
502504
}
503505

504-
// Star定时提醒
505-
if (localStorage.getItem("_mpe_stared") !== "true") {
506+
// 新手引导检测
507+
if (!isNewcomerPassed()) {
508+
useNewcomerStore.getState().openModal();
509+
}
510+
511+
// Star定时提醒(需通过新手测试后才启动)
512+
if (
513+
localStorage.getItem("_mpe_stared") !== "true" &&
514+
isNewcomerPassed()
515+
) {
506516
setInterval(
507517
() => {
508518
if (!isShowStarRemind) {
@@ -589,6 +599,7 @@ function App() {
589599
<DebugModal />
590600
</Suspense>
591601
{isWikiModuleVisible && <WikiModal />}
602+
<NewcomerGuideModal />
592603
<GlobalListener />
593604
</ThemeProvider>
594605
);

src/components/Header.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,8 @@ function Header() {
299299

300300
// 检测版本更新
301301
useEffect(() => {
302+
if (localStorage.getItem("mpe_newcomer_passed") !== "true") return;
303+
302304
const lastVersion = localStorage.getItem("mpe_last_version");
303305
const currentVersion = globalConfig.version;
304306
if (lastVersion === currentVersion) return;
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import { useState } from "react";
2+
import {
3+
Modal,
4+
Button,
5+
Steps,
6+
Radio,
7+
Checkbox,
8+
Space,
9+
Typography,
10+
Alert,
11+
Result,
12+
Divider,
13+
} from "antd";
14+
import {
15+
BookOutlined,
16+
FormOutlined,
17+
ExperimentOutlined,
18+
TrophyOutlined,
19+
} from "@ant-design/icons";
20+
import {
21+
useNewcomerStore,
22+
checkFixedPass,
23+
checkRandomPass,
24+
} from "../../stores/newcomerStore";
25+
import type { QuizQuestion } from "../../data/newcomerQuiz";
26+
27+
const { Title, Paragraph, Text, Link } = Typography;
28+
29+
export function NewcomerGuideModal() {
30+
const {
31+
modalOpen,
32+
step,
33+
fixedQuiz,
34+
randomQuiz,
35+
fixedAnswers,
36+
randomAnswers,
37+
setStep,
38+
setFixedAnswer,
39+
setRandomAnswer,
40+
markPassed,
41+
closeModal,
42+
} = useNewcomerStore();
43+
44+
const [fixedError, setFixedError] = useState(false);
45+
const [randomError, setRandomError] = useState(false);
46+
47+
if (!modalOpen) return null;
48+
49+
const handleSubmitFixed = () => {
50+
if (checkFixedPass(fixedQuiz, fixedAnswers)) {
51+
setFixedError(false);
52+
setStep(2);
53+
} else {
54+
setFixedError(true);
55+
}
56+
};
57+
58+
const handleSubmitRandom = () => {
59+
if (checkRandomPass(randomQuiz, randomAnswers)) {
60+
setRandomError(false);
61+
setStep(3);
62+
} else {
63+
setRandomError(true);
64+
}
65+
};
66+
67+
const handleFinish = () => {
68+
markPassed();
69+
closeModal();
70+
};
71+
72+
return (
73+
<Modal
74+
open={modalOpen}
75+
closable={false}
76+
maskClosable={false}
77+
keyboard={false}
78+
footer={null}
79+
width={640}
80+
centered
81+
destroyOnHidden
82+
>
83+
<Steps
84+
current={step}
85+
size="small"
86+
style={{ marginBottom: 24 }}
87+
items={[
88+
{ title: "了解", icon: <BookOutlined /> },
89+
{ title: "基础", icon: <FormOutlined /> },
90+
{ title: "技巧", icon: <ExperimentOutlined /> },
91+
{ title: "通关", icon: <TrophyOutlined /> },
92+
]}
93+
/>
94+
95+
{step === 0 && <IntroPage onNext={() => setStep(1)} />}
96+
{step === 1 && (
97+
<QuizPage
98+
title="基础知识测试"
99+
description="以下为 MaaFW 基本常识,必须全部答对才能进入下一步。"
100+
quiz={fixedQuiz}
101+
answers={fixedAnswers}
102+
setAnswer={setFixedAnswer}
103+
error={fixedError}
104+
errorMessage="存在错误答案,请全部答对后再提交。"
105+
onSubmit={handleSubmitFixed}
106+
onBack={() => setStep(0)}
107+
/>
108+
)}
109+
{step === 2 && (
110+
<QuizPage
111+
title="常用技巧测试"
112+
description="以下为常用小知识,答对 60% 即可通过。"
113+
quiz={randomQuiz}
114+
answers={randomAnswers}
115+
setAnswer={setRandomAnswer}
116+
error={randomError}
117+
errorMessage="正确率不足 60%,请重新检查后再提交。"
118+
onSubmit={handleSubmitRandom}
119+
onBack={() => setStep(1)}
120+
/>
121+
)}
122+
{step === 3 && <CertificatePage onFinish={handleFinish} />}
123+
</Modal>
124+
);
125+
}
126+
127+
function IntroPage({ onNext }: { onNext: () => void }) {
128+
return (
129+
<div>
130+
<Title level={4}>欢迎使用 MaaPipelineEditor</Title>
131+
<Paragraph>
132+
<Text strong>MaaPipelineEditor (MPE)</Text>{" "}
133+
<Text strong>MaaFramework (MaaFW)</Text> 的可视化 Pipeline
134+
编辑器,帮助你以图形化方式编辑任务流程。
135+
</Paragraph>
136+
137+
<Alert
138+
type="warning"
139+
showIcon
140+
message="重要提示"
141+
description="MPE 是 MaaFW 的辅助工具,不能替代对 MaaFW 本身的学习。请确保你已经了解 MaaFW 的基本概念(如 Node、Task、Pipeline 等)后再使用本编辑器。"
142+
style={{ marginBottom: 16 }}
143+
/>
144+
145+
<Paragraph>如果你还不熟悉 MaaFramework,请先阅读官方文档:</Paragraph>
146+
147+
<Space direction="vertical" style={{ marginBottom: 24 }}>
148+
<Link href="https://maafw.com/docs/1.1-QuickStarted" target="_blank">
149+
MaaFramework 官方文档
150+
</Link>
151+
</Space>
152+
153+
<Divider />
154+
155+
<div style={{ textAlign: "right" }}>
156+
<Button type="primary" onClick={onNext}>
157+
我已了解,开始答题
158+
</Button>
159+
</div>
160+
</div>
161+
);
162+
}
163+
164+
function QuizPage({
165+
title,
166+
description,
167+
quiz,
168+
answers,
169+
setAnswer,
170+
error,
171+
errorMessage,
172+
onSubmit,
173+
onBack,
174+
}: {
175+
title: string;
176+
description: string;
177+
quiz: QuizQuestion[];
178+
answers: Record<number, number | number[]>;
179+
setAnswer: (qi: number, val: number | number[]) => void;
180+
error: boolean;
181+
errorMessage: string;
182+
onSubmit: () => void;
183+
onBack: () => void;
184+
}) {
185+
const allAnswered = quiz.every((q, i) => {
186+
const a = answers[i];
187+
if (a === undefined) return false;
188+
if (q.type === "multi") return Array.isArray(a) && a.length > 0;
189+
return true;
190+
});
191+
192+
return (
193+
<div>
194+
<Title level={4}>{title}</Title>
195+
<Paragraph type="secondary">{description}</Paragraph>
196+
197+
{error && (
198+
<Alert
199+
type="error"
200+
showIcon
201+
message={errorMessage}
202+
style={{ marginBottom: 16 }}
203+
/>
204+
)}
205+
206+
<div style={{ maxHeight: 400, overflowY: "auto", paddingRight: 8 }}>
207+
<Space direction="vertical" size="large" style={{ width: "100%" }}>
208+
{quiz.map((q, qi) => (
209+
<QuizItem
210+
key={qi}
211+
index={qi}
212+
question={q}
213+
value={answers[qi]}
214+
onChange={(val) => setAnswer(qi, val)}
215+
/>
216+
))}
217+
</Space>
218+
</div>
219+
220+
<Divider />
221+
222+
<div style={{ display: "flex", justifyContent: "space-between" }}>
223+
<Button onClick={onBack}>上一步</Button>
224+
<Button type="primary" disabled={!allAnswered} onClick={onSubmit}>
225+
提交答案
226+
</Button>
227+
</div>
228+
</div>
229+
);
230+
}
231+
232+
function QuizItem({
233+
index,
234+
question,
235+
value,
236+
onChange,
237+
}: {
238+
index: number;
239+
question: QuizQuestion;
240+
value: number | number[] | undefined;
241+
onChange: (val: number | number[]) => void;
242+
}) {
243+
const prefixMap = { choice: "单选题", judge: "判断题", multi: "多选题" };
244+
const prefix = prefixMap[question.type];
245+
246+
if (question.type === "multi") {
247+
return (
248+
<div>
249+
<Text strong>
250+
{index + 1}. [{prefix}] {question.question}
251+
</Text>
252+
<Checkbox.Group
253+
value={Array.isArray(value) ? value : []}
254+
onChange={(checked) => onChange(checked as number[])}
255+
style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}
256+
>
257+
{question.options.map((opt, oi) => (
258+
<Checkbox key={oi} value={oi}>
259+
{String.fromCharCode(65 + oi)}. {opt}
260+
</Checkbox>
261+
))}
262+
</Checkbox.Group>
263+
</div>
264+
);
265+
}
266+
267+
return (
268+
<div>
269+
<Text strong>
270+
{index + 1}. [{prefix}] {question.question}
271+
</Text>
272+
<Radio.Group
273+
value={typeof value === "number" ? value : undefined}
274+
onChange={(e) => onChange(e.target.value)}
275+
style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}
276+
>
277+
{question.options.map((opt, oi) => (
278+
<Radio key={oi} value={oi}>
279+
{question.type === "choice"
280+
? `${String.fromCharCode(65 + oi)}. ${opt}`
281+
: opt}
282+
</Radio>
283+
))}
284+
</Radio.Group>
285+
</div>
286+
);
287+
}
288+
289+
function CertificatePage({ onFinish }: { onFinish: () => void }) {
290+
return (
291+
<Result
292+
status="success"
293+
icon={<TrophyOutlined style={{ color: "#faad14" }} />}
294+
title="恭喜通过测试!"
295+
subTitle="你已具备使用 MaaPipelineEditor 的基础知识,欢迎开始使用。"
296+
extra={
297+
<Space direction="vertical" align="center">
298+
<Link href="https://mpe.maa.plus/" target="_blank">
299+
查看 MaaPipelineEditor 使用文档
300+
</Link>
301+
<Button type="primary" size="large" onClick={onFinish}>
302+
开始使用 MPE
303+
</Button>
304+
</Space>
305+
}
306+
/>
307+
);
308+
}

src/data/newcomerQuiz.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/** 新手引导题目类型定义 */
2+
export interface QuizQuestion {
3+
type: "choice" | "judge" | "multi";
4+
question: string;
5+
options: string[];
6+
answer: number | number[];
7+
}
8+
9+
/** 判断单题是否正确 */
10+
export function isAnswerCorrect(
11+
question: QuizQuestion,
12+
userAnswer: number | number[] | undefined,
13+
): boolean {
14+
if (userAnswer === undefined) return false;
15+
if (question.type === "multi") {
16+
if (!Array.isArray(userAnswer) || !Array.isArray(question.answer))
17+
return false;
18+
const sorted = [...userAnswer].sort();
19+
const expected = [...question.answer].sort();
20+
return (
21+
sorted.length === expected.length &&
22+
sorted.every((v, i) => v === expected[i])
23+
);
24+
}
25+
return userAnswer === question.answer;
26+
}
27+
28+
/** 从题库中随机抽取 n 题 */
29+
export function pickRandom(pool: QuizQuestion[], n: number): QuizQuestion[] {
30+
const shuffled = [...pool].sort(() => Math.random() - 0.5);
31+
return shuffled.slice(0, Math.min(n, pool.length));
32+
}

0 commit comments

Comments
 (0)