feat: 数感启蒙、专注力页面重构、首页入口开发

This commit is contained in:
R524809
2026-04-21 17:51:16 +08:00
parent b2f5b203f8
commit 28151018ee
212 changed files with 2722 additions and 5207 deletions
@@ -0,0 +1,15 @@
{
"navigationStyle": "custom",
"navigationBarTitleText": "数学练习",
"navigationBarBackgroundColor": "#F8F0E0",
"navigationBarTextStyle": "black",
"backgroundColor": "#FEF6E7",
"enablePullDownRefresh": false,
"usingComponents": {
"nav-bar": "../../components3.0/nav-bar/nav-bar",
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
"draw-ad": "../../components/draw-ad/draw-ad",
"preview-footer-actions": "../../components3.0/preview-footer-actions/preview-footer-actions",
"toy-icon": "../../toy/icon/icon"
}
}
@@ -0,0 +1,261 @@
@import '../../style/theme.less';
page {
background-color: @bg-page;
}
.md-page {
min-height: 100vh;
padding: 0 @page-padding-x;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.md-main {
padding-top: 24rpx;
display: flex;
flex-direction: column;
gap: 64rpx;
}
/* ===== 预览卡 ===== */
.md-preview-wrap {
position: relative;
}
.md-preview-card {
background: @bg-white;
border-radius: @radius-xl;
box-shadow: @shadow;
padding: 48rpx;
border: @border;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: stretch;
}
.md-preview-kicker {
display: block;
text-align: center;
font-size: @fs-section-head-title;
font-weight: bold;
color: @text-secondary;
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 40rpx;
}
.md-canvas-wrap {
display: flex;
justify-content: center;
align-items: center;
min-height: 400rpx;
background: @bg-gray;
border-radius: @radius-sm;
border: 2rpx dashed rgba(50, 46, 37, 0.12);
overflow: hidden;
}
.md-canvas {
max-width: 100%;
border-radius: @radius-sm;
box-shadow: @shadow;
}
/* ===== 区块 ===== */
.md-section {
display: flex;
flex-direction: column;
gap: 32rpx;
}
.md-section-title {
font-size: 32rpx;
font-weight: 700;
color: #6d3b00;
padding-left: 8rpx;
}
/* ===== 练习类型 2列卡片网格 ===== */
.md-type-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32rpx;
}
.md-type-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 32rpx 24rpx;
border-radius: 24rpx;
background: #f8f0e0;
box-sizing: border-box;
transition:
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
background 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.md-type-card__icon {
font-size: 42rpx;
line-height: 1;
margin-bottom: 4rpx;
}
.md-type-card--active {
background: linear-gradient(145deg, #ffd709 0%, #efc900 100%);
box-shadow: 0 8rpx 32rpx rgba(50, 46, 37, 0.06);
}
.md-type-card--pressed {
transform: scale(0.95);
}
.md-type-card__label {
font-size: 30rpx;
font-weight: 700;
color: @text-secondary;
text-align: center;
}
.md-type-card--active .md-type-card__label {
color: #453900;
}
/* ===== 子选项 Chip ===== */
.md-chip-row {
display: flex;
flex-direction: row;
gap: 24rpx;
}
.md-chip-row--wrap {
flex-wrap: wrap;
.md-chip {
flex: none;
min-width: calc(33.33% - 16rpx);
}
}
.md-chip {
flex: 1;
box-sizing: border-box;
text-align: center;
padding: 24rpx 16rpx;
font-size: 28rpx;
font-weight: 700;
line-height: 40rpx;
color: @text-secondary;
background: @bg-card;
border-radius: 32rpx;
transition:
background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.md-chip--pressed {
transform: scale(0.96);
background: @brand;
color: @text-selected-btn;
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.08);
}
.md-chip--active {
background: @brand;
color: @text-selected-btn;
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.08);
}
/* ===== 数字选择网格(numberFind 专用) ===== */
.md-number-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 30rpx;
padding: 0 8rpx;
}
.md-number-cell {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 24rpx;
background: @bg-card;
box-sizing: border-box;
transition:
background-color 0.2s ease,
transform 0.2s ease,
box-shadow 0.2s ease;
}
.md-number-cell--pressed {
transform: scale(0.95);
background: @brand;
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.08);
}
.md-number-cell--active {
background: @brand;
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.08);
}
.md-number-text {
font-size: 40rpx;
font-weight: 700;
color: @text-secondary;
line-height: 1;
transition: color 0.2s ease;
}
.md-number-cell--active .md-number-text,
.md-number-cell--pressed .md-number-text {
color: @text-selected-btn;
}
/* ===== 随机生成按钮 ===== */
.md-shuffle-btn {
box-sizing: border-box;
width: 100%;
height: 108rpx;
padding: 0 32rpx;
border-radius: 32rpx;
border: 8rpx solid @bg-card;
background: transparent;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 12rpx;
transition:
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.md-shuffle-btn--hover {
transform: scale(0.98);
opacity: 0.92;
background-color: rgba(234, 226, 208, 0.55);
}
.md-shuffle-btn toy-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.md-shuffle-btn__text {
font-size: 36rpx;
font-weight: 700;
line-height: 1;
color: @text-secondary;
transition: color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
+249
View File
@@ -0,0 +1,249 @@
import { createMathPage } from '../shared/common/mathPageMixin';
import { BaseDrawService } from '../../core/draw/baseDraw';
import {
MATH_TYPE_CONFIGS,
findTypeByRouteId,
type MathTypeConfig,
type MathTypeAction,
} from './registry';
const TYPE_LIST = MATH_TYPE_CONFIGS.map((t) => ({
id: t.id,
title: t.title,
icon: t.icon,
}));
createMathPage({
canvas: null as Canvas | null,
ctx: null as RenderingContext | null,
boxHeight: 0,
boxWidth: 0,
drawService: null as BaseDrawService | null,
currentTypeConfig: null as MathTypeConfig | null,
currentData: null as any,
routeExtra: null as Record<string, any> | null,
data: {
pageTitle: '数学练习',
functionId: '',
hasContent: false,
showShareDialog: false,
boxWidth: 0,
boxHeight: 0,
selectedTypeId: '',
typeList: TYPE_LIST,
showActions: false,
actionsTitle: '选择模式',
currentActions: [] as MathTypeAction[],
currentMode: '',
showNumberGrid: false,
numberList: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
selectedNumber: 0,
},
onLoad(options: { id?: string; mode?: string }) {
const routeId = options.id || MATH_TYPE_CONFIGS[0].id;
const result = findTypeByRouteId(routeId);
if (!result) return;
const { typeConfig, mode, extra } = result;
const initialMode =
options.mode || mode || typeConfig.defaultMode || '';
this.currentTypeConfig = typeConfig;
this.routeExtra = extra || null;
const title = typeConfig.getTitle
? typeConfig.getTitle(initialMode)
: typeConfig.title;
this.setData({
selectedTypeId: typeConfig.id,
functionId: routeId,
pageTitle: title,
showActions: !!typeConfig.actions,
currentActions: typeConfig.actions || [],
actionsTitle: typeConfig.actionsTitle || '选择模式',
currentMode: initialMode,
showNumberGrid: !!typeConfig.hasNumberGrid,
selectedNumber: typeConfig.hasNumberGrid
? Math.floor(Math.random() * 10) + 1
: 0,
});
this.initPageInfo(routeId, title);
},
onReady() {
if (!this.currentTypeConfig) return;
const typeConfig = this.currentTypeConfig;
const mode = this.data.currentMode;
this.initCanvas({
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) => typeConfig.createDrawService(canvas, ctx, options),
drawServiceOptions: {
title: this.data.pageTitle,
subTitle: typeConfig.getSubTitle
? typeConfig.getSubTitle(mode)
: typeConfig.subTitle,
functionId: this.data.functionId,
},
onCanvasReady: () => {
this.onRandom();
},
});
},
/** 切换练习类型 */
onSelectType(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string;
if (!id || id === this.data.selectedTypeId) return;
const typeConfig = MATH_TYPE_CONFIGS.find((t) => t.id === id);
if (!typeConfig) return;
this.currentTypeConfig = typeConfig;
this.routeExtra = null;
const mode = typeConfig.defaultMode || '';
const title = typeConfig.getTitle
? typeConfig.getTitle(mode)
: typeConfig.title;
const subTitle = typeConfig.getSubTitle
? typeConfig.getSubTitle(mode)
: typeConfig.subTitle;
this.setData({
selectedTypeId: id,
functionId: id,
pageTitle: title,
showActions: !!typeConfig.actions,
currentActions: typeConfig.actions || [],
actionsTitle: typeConfig.actionsTitle || '选择模式',
currentMode: mode,
showNumberGrid: !!typeConfig.hasNumberGrid,
selectedNumber: typeConfig.hasNumberGrid
? Math.floor(Math.random() * 10) + 1
: 0,
});
this.initPageInfo(id, title);
if (this.canvas && this.ctx) {
this.drawService = typeConfig.createDrawService(
this.canvas,
this.ctx,
{ title, subTitle, functionId: id },
);
this.onRandom();
}
},
/** 切换子选项(模式) */
onSelectMode(e: WechatMiniprogram.TouchEvent) {
const value = e.currentTarget.dataset.value as string;
if (!value || value === this.data.currentMode) return;
const typeConfig = this.currentTypeConfig;
if (!typeConfig) return;
const title = typeConfig.getTitle
? typeConfig.getTitle(value)
: typeConfig.title;
const subTitle = typeConfig.getSubTitle
? typeConfig.getSubTitle(value)
: typeConfig.subTitle;
this.setData({
currentMode: value,
pageTitle: title,
});
this.initPageInfo(this.data.functionId, title);
if (this.drawService) {
(this.drawService as any).options.title = title;
(this.drawService as any).options.subTitle = subTitle;
}
this.onRandom();
},
/** 选择数字(numberFind 专用) */
onSelectNumber(e: WechatMiniprogram.TouchEvent) {
const number = parseInt(e.currentTarget.dataset.number);
if (isNaN(number) || number === this.data.selectedNumber) return;
this.setData({ selectedNumber: number });
this.drawCanvas();
},
/** 执行 Canvas 绘制 */
async drawCanvas() {
if (!this.ctx || !this.drawService || !this.currentData) return;
try {
const typeId = this.data.selectedTypeId;
const mode = this.data.currentMode;
if (typeId === 'number-find') {
await this.drawService.draw(this.data.selectedNumber);
} else if (
typeId === 'counting-matching' ||
typeId === 'number-coloring' ||
typeId === 'number-object-match'
) {
await this.drawService.draw(this.currentData, mode);
} else if (typeId === 'missing-number') {
await this.drawService.draw(this.currentData, mode);
} else if (typeId === 'addition') {
if (this.drawService) {
(this.drawService as any).options.title =
this.data.pageTitle;
}
await this.drawService.draw(this.currentData, mode);
} else if (typeId === 'counting-select') {
const drawMode =
this.routeExtra?.functionId === 'counting-fill'
? 'fill'
: 'select';
await this.drawService.draw(this.currentData, drawMode);
} else if (typeId === 'multiplication-table') {
await this.drawService.draw({});
} else {
await this.drawService.draw(this.currentData);
}
this.setData({ hasContent: true });
} catch (error) {
console.error('绘制失败:', error);
this.setData({ hasContent: false });
}
},
/** 随机生成数据并绘制 */
onRandom() {
if (!this.currentTypeConfig) return;
const extra = {
selectedNumber: this.data.selectedNumber,
functionId: this.data.functionId,
...this.routeExtra,
};
if (this.currentTypeConfig.hasNumberGrid) {
const num = Math.floor(Math.random() * 10) + 1;
this.setData({ selectedNumber: num });
extra.selectedNumber = num;
}
this.currentData = this.currentTypeConfig.generateData(
this.data.currentMode,
extra,
);
this.drawCanvas();
},
});
@@ -0,0 +1,104 @@
<nav-bar title="{{pageTitle}}" />
<view class="md-page">
<view class="md-main">
<!-- 打印预览卡 -->
<view class="md-preview-wrap">
<view class="md-preview-card">
<text class="md-preview-kicker">打印预览</text>
<view id="canvasWrapper" class="md-canvas-wrap">
<canvas
type="2d"
id="canvasContent"
class="md-canvas"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
</view>
</view>
</view>
<!-- 练习类型选择(2列卡片网格) -->
<view class="md-section">
<text class="md-section-title">练习类型</text>
<view class="md-type-grid">
<view
wx:for="{{typeList}}"
wx:key="id"
class="md-type-card {{selectedTypeId === item.id ? 'md-type-card--active' : ''}}"
data-id="{{item.id}}"
hover-class="md-type-card--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectType">
<text class="md-type-card__icon">{{item.icon}}</text>
<text class="md-type-card__label">{{item.title}}</text>
</view>
</view>
</view>
<!-- 子选项(当前类型有模式切换时显示) -->
<view wx:if="{{showActions}}" class="md-section">
<text class="md-section-title">{{actionsTitle}}</text>
<view class="md-chip-row {{currentActions.length > 3 ? 'md-chip-row--wrap' : ''}}">
<view
wx:for="{{currentActions}}"
wx:key="value"
class="md-chip {{currentMode === item.value ? 'md-chip--active' : ''}}"
data-value="{{item.value}}"
hover-class="md-chip--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectMode">
{{item.label}}
</view>
</view>
</view>
<!-- 数字选择网格(numberFind 专用) -->
<view wx:if="{{showNumberGrid}}" class="md-section">
<text class="md-section-title">选择数字</text>
<view class="md-number-grid">
<view
wx:for="{{numberList}}"
wx:key="*this"
class="md-number-cell {{selectedNumber === item ? 'md-number-cell--active' : ''}}"
data-number="{{item}}"
hover-class="md-number-cell--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectNumber">
<text class="md-number-text">{{item}}</text>
</view>
</view>
</view>
<!-- 随机生成按钮 -->
<view
class="md-shuffle-btn"
hover-class="md-shuffle-btn--hover"
hover-start-time="0"
hover-stay-time="70"
bindtap="onRandom">
<toy-icon
name="refresh"
size="40rpx"
color="#605b50"
custom-class="md-shuffle-btn__icon" />
<text class="md-shuffle-btn__text">随机生成</text>
</view>
<!-- 广告位 -->
<draw-ad type="mathDraw"></draw-ad>
</view>
</view>
<!-- 底部操作栏 -->
<preview-footer-actions
disabled="{{!hasContent}}"
bind:primary="exportToPrint"
bind:secondary="onShare" />
<!-- 分享引导弹窗 -->
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
+737
View File
@@ -0,0 +1,737 @@
import { BaseDrawService } from '../../core/draw/baseDraw';
import NumberFindDraw from '../shared/service/numberFindDraw';
import CountMatchDraw from '../shared/service/countMatchDraw';
import NumberColorDraw from '../shared/service/numberColorDraw';
import AdditionDraw from '../shared/service/additionDraw';
import MissingNumberDraw from '../shared/service/missingNumberDraw';
import CompareDraw from '../shared/service/compareDraw';
import CountingSelectDraw from '../shared/service/countingSelectDraw';
import NumberDecomposeDraw from '../shared/service/numberDecomposeDraw';
import NumberSortDraw from '../shared/service/numberSortDraw';
import NumberObjectMatchDraw from '../shared/service/numberObjectMatchDraw';
import MakeTenDraw from '../shared/service/makeTenDraw';
import BreakTenDraw from '../shared/service/breakTenDraw';
import FlatTenDraw from '../shared/service/flatTenDraw';
import OneDigitAdditionDraw from '../shared/service/oneDigitAdditionDraw';
import CalculationPracticeDraw from '../shared/service/calculationPracticeDraw';
import MultiplicationTableDraw from '../shared/service/multiplicationTableDraw';
import { getRandomNumberColor, getNumberColors, NUMBER_COLORS } from '../../constants/colors';
// ─── Types ───
export interface MathTypeAction {
value: string;
label: string;
}
export interface MathTypeConfig {
id: string;
title: string;
subTitle: string;
icon: string;
actionsTitle?: string;
actions?: MathTypeAction[];
defaultMode?: string;
hasNumberGrid?: boolean;
getTitle?: (mode: string) => string;
getSubTitle?: (mode: string) => string;
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) => BaseDrawService;
generateData: (mode?: string, extra?: any) => any;
drawArgs?: (data: any, mode: string) => any[];
/** 需要根据 functionId 决定不同的 actions/DrawService 的复合类型 */
variants?: Record<string, {
title: string;
subTitle: string;
actions?: MathTypeAction[];
defaultMode?: string;
createDrawService?: (canvas: Canvas, ctx: RenderingContext, options?: Record<string, any>) => BaseDrawService;
}>;
}
// ─── Data Generators ───
function generateNumberFindData(_mode?: string, extra?: any) {
const selectedNumber = extra?.selectedNumber || Math.floor(Math.random() * 10) + 1;
return { selectedNumber };
}
function generateCountMatchData(mode?: string) {
const availableNumbers = Array.from({ length: 10 }, (_, i) => i + 1);
const leftNumbers: number[] = [];
const pool = [...availableNumbers];
for (let i = 0; i < 5; i++) {
const idx = Math.floor(Math.random() * pool.length);
leftNumbers.push(pool.splice(idx, 1)[0]);
}
const rightNumbers = [...leftNumbers];
for (let i = rightNumbers.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[rightNumbers[i], rightNumbers[j]] = [rightNumbers[j], rightNumbers[i]];
}
return { leftNumbers, rightNumbers, _drawMode: mode || 'twelve-animals' };
}
function generateNumberColorData(mode?: string) {
const availableNumbers = Array.from({ length: 10 }, (_, i) => i + 1);
const numbers: number[] = [];
const pool = [...availableNumbers];
for (let i = 0; i < 6; i++) {
const idx = Math.floor(Math.random() * pool.length);
numbers.push(pool.splice(idx, 1)[0]);
}
return { numbers, _drawMode: mode || 'caterpillar' };
}
function generateAdditionData(mode?: string) {
const type = mode || 'addition-5';
const problems: Array<{ type: 'addition' | 'subtraction'; left: number; right: number; result: number }> = [];
for (let i = 0; i < 5; i++) {
if (type === 'addition-5') {
const left = Math.floor(Math.random() * 4) + 1;
const right = Math.floor(Math.random() * (5 - left)) + 1;
problems.push({ type: 'addition', left, right, result: left + right });
} else if (type === 'addition-10') {
const left = Math.floor(Math.random() * 9) + 1;
const right = Math.floor(Math.random() * (10 - left)) + 1;
problems.push({ type: 'addition', left, right, result: left + right });
} else if (type === 'subtraction-10') {
const left = Math.floor(Math.random() * 10) + 1;
const right = Math.floor(Math.random() * (left - 1)) + 1;
problems.push({ type: 'subtraction', left, right, result: left - right });
} else {
if (Math.random() < 0.5) {
const left = Math.floor(Math.random() * 9) + 1;
const right = Math.floor(Math.random() * (10 - left)) + 1;
problems.push({ type: 'addition', left, right, result: left + right });
} else {
const left = Math.floor(Math.random() * 10) + 1;
const right = Math.floor(Math.random() * (left - 1)) + 1;
problems.push({ type: 'subtraction', left, right, result: left - right });
}
}
}
return { problems };
}
function generateMissingNumberData(mode?: string) {
const maxNumber = parseInt(mode || '10') || 10;
const gridMap: Record<number, number> = { 10: 3, 20: 2, 40: 2, 50: 2, 80: 1, 100: 1, 120: 1 };
const gridCount = gridMap[maxNumber] || 1;
const grids: Array<{ numbers: (number | null)[]; colors: (string | null)[] }> = [];
for (let g = 0; g < gridCount; g++) {
const total = maxNumber;
const numbers = Array.from({ length: total }, (_, i) => i + 1);
const hideCount = Math.floor(total * (0.4 + Math.random() * 0.2));
const hidden = new Set<number>();
while (hidden.size < hideCount) hidden.add(Math.floor(Math.random() * total));
const gridNumbers: (number | null)[] = numbers.map((n, i) => hidden.has(i) ? null : n);
const gridColors: (string | null)[] = gridNumbers.map((n) => n ? getRandomNumberColor() : null);
grids.push({ numbers: gridNumbers, colors: gridColors });
}
return { grids, maxNumber };
}
function generateCompareData() {
const problems: Array<{
leftCount: number; rightCount: number;
leftImageIndex: number; rightImageIndex: number;
imageType: 'fruits' | 'twelve-animals';
}> = [];
for (let i = 0; i < 12; i++) {
const imageType: 'fruits' | 'twelve-animals' = Math.random() < 0.5 ? 'fruits' : 'twelve-animals';
const maxIdx = imageType === 'fruits' ? 22 : 12;
problems.push({
leftCount: Math.floor(Math.random() * 10) + 1,
rightCount: Math.floor(Math.random() * 10) + 1,
leftImageIndex: Math.floor(Math.random() * maxIdx) + 1,
rightImageIndex: Math.floor(Math.random() * maxIdx) + 1,
imageType,
});
}
return { problems };
}
function generateCountingSelectData(_mode?: string, extra?: any) {
const isFillMode = extra?.functionId === 'counting-fill';
const problems: Array<{
count: number; imageIndex: number; imageType: 'fruits' | 'twelve-animals';
options?: number[]; correctIndex?: number;
}> = [];
for (let i = 0; i < 9; i++) {
const imageType: 'fruits' | 'twelve-animals' = Math.random() < 0.5 ? 'fruits' : 'twelve-animals';
const maxIdx = imageType === 'fruits' ? 22 : 12;
const count = Math.floor(Math.random() * 10) + 1;
const problem: any = { count, imageIndex: Math.floor(Math.random() * maxIdx) + 1, imageType };
if (!isFillMode) {
const options: number[] = [];
const correctIndex = Math.floor(Math.random() * 3);
for (let j = 0; j < 3; j++) {
if (j === correctIndex) { options.push(count); }
else { let w; do { w = Math.floor(Math.random() * 10) + 1; } while (w === count); options.push(w); }
}
problem.options = options;
problem.correctIndex = correctIndex;
}
problems.push(problem);
}
return { problems };
}
function generateNumberDecomposeData(mode?: string, extra?: any) {
const decomposeMode = mode || 'with-image';
const maxNumber = extra?.maxNumber || 10;
const is20 = maxNumber === 20;
const minWhole = is20 ? 11 : 2;
const maxWhole = maxNumber;
const problems: Array<{
whole: number | null; part1: number | null; part2: number | null;
imageIndex?: number; imageType?: 'fruits' | 'twelve-animals';
}> = [];
const usedKeys = new Set<string>();
const problemCount = decomposeMode === 'with-image' ? 9 : 15;
let attempts = 0;
while (problems.length < problemCount && attempts < problemCount * 50) {
attempts++;
const whole = Math.floor(Math.random() * (maxWhole - minWhole + 1)) + minWhole;
const part1 = Math.floor(Math.random() * (whole - 1)) + 1;
const part2 = whole - part1;
if (decomposeMode === 'compose') {
const key = `${Math.min(part1, part2)}:${Math.max(part1, part2)}`;
if (usedKeys.has(key)) continue;
usedKeys.add(key);
problems.push({ whole: null, part1, part2 });
} else {
const showPart1 = Math.random() < 0.5;
const key = `${whole}:${Math.min(part1, part2)}:${Math.max(part1, part2)}:${showPart1}`;
if (usedKeys.has(key)) continue;
usedKeys.add(key);
const problem: any = {
whole,
part1: showPart1 ? part1 : null,
part2: showPart1 ? null : part2,
};
if (decomposeMode === 'with-image') {
const imgType: 'fruits' | 'twelve-animals' = Math.random() < 0.5 ? 'fruits' : 'twelve-animals';
problem.imageIndex = Math.floor(Math.random() * (imgType === 'fruits' ? 22 : 12)) + 1;
problem.imageType = imgType;
}
problems.push(problem);
}
}
return { problems, mode: decomposeMode };
}
function generateNumberSortData() {
const availableColors = getNumberColors();
const groups: Array<{ numbers: Array<{ number: number; color: string; angle: number }> }> = [];
for (let i = 0; i < 6; i++) {
const pool = Array.from({ length: 9 }, (_, i) => i + 1);
const selected: number[] = [];
for (let j = 0; j < 4; j++) {
const idx = Math.floor(Math.random() * pool.length);
selected.push(pool.splice(idx, 1)[0]);
}
const shuffled = [...selected];
for (let j = shuffled.length - 1; j > 0; j--) {
const k = Math.floor(Math.random() * (j + 1));
[shuffled[j], shuffled[k]] = [shuffled[k], shuffled[j]];
}
groups.push({
numbers: shuffled.map((num) => ({
number: num,
color: NUMBER_COLORS[num as keyof typeof NUMBER_COLORS] || availableColors[Math.floor(Math.random() * availableColors.length)],
angle: ((Math.random() - 0.5) * 60 * Math.PI) / 180,
})),
});
}
return { groups };
}
function generateNumberObjectMatchData() {
const fruitsIndices = Array.from({ length: 22 }, (_, i) => i + 1);
const animalsIndices = Array.from({ length: 12 }, (_, i) => i + 1);
const allImages = [
...fruitsIndices.map((idx) => ({ imageIndex: idx, folder: 'fruits' as const })),
...animalsIndices.map((idx) => ({ imageIndex: idx, folder: 'twelve-animals' as const })),
];
const selectedImages = [...allImages].sort(() => Math.random() - 0.5).slice(0, 4);
const gridImages = Array.from({ length: 20 }, () => {
const img = selectedImages[Math.floor(Math.random() * selectedImages.length)];
return { ...img, offsetX: (Math.random() - 0.5) * 30, offsetY: (Math.random() - 0.5) * 30 };
});
const bottomImages = [...selectedImages];
const countMap = new Map<string, number>();
gridImages.forEach((it) => {
const key = `${it.folder}-${it.imageIndex}`;
countMap.set(key, (countMap.get(key) || 0) + 1);
});
const imageCounts = selectedImages.map((img) => countMap.get(`${img.folder}-${img.imageIndex}`) || 0);
const bottomNumbers = [...imageCounts].sort(() => Math.random() - 0.5);
return { gridImages, bottomImages, bottomNumbers };
}
function generateMakeTenData() {
const problems: Array<{ left: number; right: number; result: number }> = [];
const used = new Set<string>();
let attempts = 0;
while (problems.length < 9 && attempts < 1000) {
attempts++;
const bigFirst = Math.random() < 0.5;
let left: number, right: number;
if (bigFirst) {
left = Math.floor(Math.random() * 4) + 6;
const minR = Math.max(1, 11 - left);
const maxR = Math.min(left - 1, 20 - left);
if (maxR < minR) continue;
right = Math.floor(Math.random() * (maxR - minR + 1)) + minR;
} else {
right = Math.floor(Math.random() * 4) + 6;
const minL = Math.max(1, 11 - right);
const maxL = Math.min(right - 1, 20 - right);
if (maxL < minL) continue;
left = Math.floor(Math.random() * (maxL - minL + 1)) + minL;
}
const key = `${left},${right}`;
if (used.has(key)) continue;
used.add(key);
problems.push({ left, right, result: left + right });
}
return { problems };
}
function generateBreakTenData() {
const problems: Array<{ minuend: number; subtrahend: number; result: number }> = [];
const used = new Set<string>();
let attempts = 0;
while (problems.length < 9 && attempts < 1000) {
attempts++;
const minuend = Math.floor(Math.random() * 10) + 11;
const subtrahend = Math.floor(Math.random() * 9) + 1;
if (minuend <= subtrahend) continue;
const key = `${minuend},${subtrahend}`;
if (used.has(key)) continue;
used.add(key);
problems.push({ minuend, subtrahend, result: minuend - subtrahend });
}
return { problems };
}
function generateFlatTenData() {
const allValid: Array<{ minuend: number; subtrahend: number; result: number }> = [];
for (let m = 11; m < 20; m++) {
const ones = m % 10;
for (let s = 1; s <= 10; s++) {
if (s > ones && m > s) allValid.push({ minuend: m, subtrahend: s, result: m - s });
}
}
const shuffled = [...allValid];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return { problems: shuffled.slice(0, Math.min(9, shuffled.length)) };
}
function generateBorrowTenData(mode?: string) {
let maxMinuend = 50;
if (mode === 'within-30') maxMinuend = 30;
else if (mode === 'within-100') maxMinuend = 100;
const byMinuend = new Map<number, Array<{ minuend: number; subtrahend: number; result: number }>>();
for (let m = 20; m <= maxMinuend; m++) {
const ones = m % 10;
for (let s = 1; s <= 10; s++) {
if (ones < s && m > s) {
if (!byMinuend.has(m)) byMinuend.set(m, []);
byMinuend.get(m)!.push({ minuend: m, subtrahend: s, result: m - s });
}
}
}
const minuends = Array.from(byMinuend.keys());
for (let i = minuends.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[minuends[i], minuends[j]] = [minuends[j], minuends[i]];
}
const problems: Array<{ minuend: number; subtrahend: number; result: number }> = [];
const target = 9;
if (minuends.length >= target) {
for (let i = 0; i < target; i++) {
const arr = byMinuend.get(minuends[i])!;
problems.push(arr[Math.floor(Math.random() * arr.length)]);
}
} else {
const per = Math.floor(target / minuends.length);
const rem = target % minuends.length;
for (let i = 0; i < minuends.length; i++) {
const arr = [...byMinuend.get(minuends[i])!];
for (let k = arr.length - 1; k > 0; k--) { const j = Math.floor(Math.random() * (k + 1)); [arr[k], arr[j]] = [arr[j], arr[k]]; }
const count = per + (i < rem ? 1 : 0);
for (let j = 0; j < Math.min(count, arr.length); j++) problems.push(arr[j]);
}
}
for (let i = problems.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[problems[i], problems[j]] = [problems[j], problems[i]];
}
return { problems };
}
function generateOneDigitAdditionData() {
const problems: Array<{ left: number; right: number; result: number }> = [];
const used = new Set<string>();
let attempts = 0;
while (problems.length < 6 && attempts < 1000) {
attempts++;
const left = Math.floor(Math.random() * 9) + 1;
const right = Math.floor(Math.random() * (10 - left)) + 1;
const key = `${left},${right}`;
if (used.has(key)) continue;
used.add(key);
problems.push({ left, right, result: left + right });
}
return { problems };
}
function generateCalculationPracticeData(mode?: string, extra?: any) {
const opType = extra?.operationType || 'addition';
let maxValue = 10;
if (mode === 'within-20') maxValue = 20;
else if (mode === 'within-50') maxValue = 50;
else if (mode === 'within-100') maxValue = 100;
const problems: Array<{ left: number; operator: '+' | '-'; right: number; result: number }> = [];
const used = new Set<string>();
let attempts = 0;
while (problems.length < 33 && attempts < 2000) {
attempts++;
let left: number, right: number, operator: '+' | '-', result: number;
const doAdd = opType === 'addition' ? true : opType === 'subtraction' ? false : Math.random() < 0.5;
if (doAdd) {
left = Math.floor(Math.random() * (maxValue - 1)) + 1;
right = Math.floor(Math.random() * (maxValue - left)) + 1;
operator = '+'; result = left + right;
} else {
left = Math.floor(Math.random() * maxValue) + 1;
right = Math.floor(Math.random() * left) + 1;
operator = '-'; result = left - right;
}
const key = `${left},${operator},${right}`;
if (used.has(key)) continue;
used.add(key);
problems.push({ left, operator, right, result });
}
return { problems };
}
function generateMultiplicationTableData() {
return {};
}
// ─── Registry ───
export const MATH_TYPE_CONFIGS: MathTypeConfig[] = [
{
id: 'number-find',
title: '看数字,涂一涂',
subTitle: '找一找下面相同的数字,涂上颜色',
icon: '🔍',
hasNumberGrid: true,
createDrawService: (canvas, ctx, opts) => new NumberFindDraw(canvas, ctx, opts),
generateData: generateNumberFindData,
},
{
id: 'counting-matching',
title: '数一数,连一连',
subTitle: '通过连线配对数字和对应的数量图形',
icon: '🔗',
actionsTitle: '选择图片',
actions: [
{ value: 'twelve-animals', label: '十二生肖' },
{ value: 'fruits', label: '水果' },
],
defaultMode: 'twelve-animals',
createDrawService: (canvas, ctx, opts) => new CountMatchDraw(canvas, ctx, opts),
generateData: generateCountMatchData,
},
{
id: 'number-coloring',
title: '按数字涂颜色',
subTitle: '按数字给相应的圆圈涂上颜色',
icon: '🎨',
actionsTitle: '选择样式',
actions: [
{ value: 'caterpillar', label: '毛毛虫' },
{ value: 'circle', label: '圆圈' },
],
defaultMode: 'caterpillar',
createDrawService: (canvas, ctx, opts) => new NumberColorDraw(canvas, ctx, opts),
generateData: generateNumberColorData,
},
{
id: 'addition',
title: '图形加减法',
subTitle: '通过图形化方式学习加减法运算',
icon: '',
actionsTitle: '选择类型',
actions: [
{ value: 'addition-5', label: '5以内加法' },
{ value: 'addition-10', label: '10以内加法' },
{ value: 'subtraction-10', label: '10以内减法' },
{ value: 'addition-subtraction-10', label: '10以内加减法' },
],
defaultMode: 'addition-5',
createDrawService: (canvas, ctx, opts) => new AdditionDraw(canvas, ctx, opts),
generateData: generateAdditionData,
},
{
id: 'missing-number',
title: '填缺少的数字',
subTitle: '在数字序列中找出并填写缺失的数字',
icon: '❓',
actionsTitle: '选择范围',
actions: [
{ value: '10', label: '10以内' },
{ value: '20', label: '20以内' },
{ value: '40', label: '40以内' },
{ value: '50', label: '50以内' },
{ value: '80', label: '80以内' },
{ value: '100', label: '100以内' },
{ value: '120', label: '120以内' },
],
defaultMode: '10',
createDrawService: (canvas, ctx, opts) => new MissingNumberDraw(canvas, ctx, opts),
generateData: generateMissingNumberData,
},
{
id: 'compare',
title: '数一数,比大小',
subTitle: '数一数,比较数量,在⭕️中填入>、<、=',
icon: '⚖️',
createDrawService: (canvas, ctx, opts) => new CompareDraw(canvas, ctx, opts),
generateData: generateCompareData,
},
{
id: 'counting-select',
title: '数一数,选一选',
subTitle: '数出物品数量,从多个选项中选择正确答案',
icon: '🎯',
createDrawService: (canvas, ctx, opts) => new CountingSelectDraw(canvas, ctx, opts),
generateData: generateCountingSelectData,
},
{
id: 'number-decompose',
title: '10以内分与合',
subTitle: '学习数的分解与组合',
icon: '🌳',
actionsTitle: '选择模式',
actions: [
{ value: 'with-image', label: '有图片模式' },
{ value: 'decompose', label: '分模式' },
{ value: 'compose', label: '组合模式' },
],
defaultMode: 'with-image',
createDrawService: (canvas, ctx, opts) => new NumberDecomposeDraw(canvas, ctx, opts),
generateData: (mode) => generateNumberDecomposeData(mode, { maxNumber: 10 }),
},
{
id: 'number-decompose-20',
title: '20以内分与合',
subTitle: '学习20以内数的分解与组合',
icon: '🌲',
actionsTitle: '选择模式',
actions: [
{ value: 'decompose', label: '20以内的分解' },
{ value: 'compose', label: '20以内的组合' },
],
defaultMode: 'decompose',
createDrawService: (canvas, ctx, opts) => new NumberDecomposeDraw(canvas, ctx, opts),
generateData: (mode) => generateNumberDecomposeData(mode, { maxNumber: 20 }),
},
{
id: 'number-sort',
title: '数字排序',
subTitle: '数字排序,写出正确顺序',
icon: '🔢',
createDrawService: (canvas, ctx, opts) => new NumberSortDraw(canvas, ctx, opts),
generateData: generateNumberSortData,
},
{
id: 'number-object-match',
title: '数物对应',
subTitle: '数一数图片数量并对应数字',
icon: '🧮',
actionsTitle: '选择模式',
actions: [
{ value: 'match', label: '连线' },
{ value: 'fill', label: '填写' },
],
defaultMode: 'match',
createDrawService: (canvas, ctx, opts) => new NumberObjectMatchDraw(canvas, ctx, opts),
generateData: generateNumberObjectMatchData,
},
{
id: 'make-ten',
title: '凑十法练习',
subTitle: '通过凑十法学习20以内进位加法',
icon: '🔟',
createDrawService: (canvas, ctx, opts) => new MakeTenDraw(canvas, ctx, opts),
generateData: generateMakeTenData,
},
{
id: 'break-ten',
title: '破十法练习',
subTitle: '通过破十法学习20以内退位减法',
icon: '💥',
createDrawService: (canvas, ctx, opts) => new BreakTenDraw(canvas, ctx, opts),
generateData: generateBreakTenData,
},
{
id: 'flat-ten',
title: '平十法练习',
subTitle: '通过平十法学习退位减法',
icon: '📐',
createDrawService: (canvas, ctx, opts) => new FlatTenDraw(canvas, ctx, opts),
generateData: generateFlatTenData,
},
{
id: 'borrow-ten',
title: '借十法练习',
subTitle: '拆大数,借出10,减小数,加剩数',
icon: '🏦',
actionsTitle: '选择范围',
actions: [
{ value: 'within-30', label: '30以内' },
{ value: 'within-50', label: '50以内' },
{ value: 'within-100', label: '100以内' },
],
defaultMode: 'within-50',
createDrawService: (canvas, ctx, opts) => new BreakTenDraw(canvas, ctx, { ...opts, mnemonic: '拆大数,借出10,减小数,加剩数' }),
generateData: generateBorrowTenData,
},
{
id: 'one-digit-addition',
title: '一位数加法',
subTitle: '通过圆点学习一位数加法运算',
icon: '⚫',
createDrawService: (canvas, ctx, opts) => new OneDigitAdditionDraw(canvas, ctx, opts),
generateData: generateOneDigitAdditionData,
},
{
id: 'practice-addition',
title: '加法口算',
subTitle: '加法计算练习题',
icon: '📝',
actionsTitle: '选择范围',
actions: [
{ value: 'within-10', label: '10以内' },
{ value: 'within-20', label: '20以内' },
{ value: 'within-50', label: '50以内' },
{ value: 'within-100', label: '100以内' },
],
defaultMode: 'within-10',
createDrawService: (canvas, ctx, opts) => new CalculationPracticeDraw(canvas, ctx, opts),
generateData: (mode) => generateCalculationPracticeData(mode, { operationType: 'addition' }),
},
{
id: 'practice-subtraction',
title: '减法口算',
subTitle: '减法计算练习题',
icon: '📝',
actionsTitle: '选择范围',
actions: [
{ value: 'within-10', label: '10以内' },
{ value: 'within-20', label: '20以内' },
{ value: 'within-50', label: '50以内' },
{ value: 'within-100', label: '100以内' },
],
defaultMode: 'within-10',
createDrawService: (canvas, ctx, opts) => new CalculationPracticeDraw(canvas, ctx, opts),
generateData: (mode) => generateCalculationPracticeData(mode, { operationType: 'subtraction' }),
},
{
id: 'practice-mixed',
title: '混合口算',
subTitle: '加减法混合计算练习题',
icon: '📝',
actionsTitle: '选择范围',
actions: [
{ value: 'within-10', label: '10以内' },
{ value: 'within-20', label: '20以内' },
{ value: 'within-50', label: '50以内' },
{ value: 'within-100', label: '100以内' },
],
defaultMode: 'within-10',
createDrawService: (canvas, ctx, opts) => new CalculationPracticeDraw(canvas, ctx, opts),
generateData: (mode) => generateCalculationPracticeData(mode, { operationType: 'mixed' }),
},
{
id: 'multiplication-table',
title: '九九乘法表',
subTitle: '九九乘法表',
icon: '✖️',
createDrawService: (canvas, ctx, opts) => new MultiplicationTableDraw(canvas, ctx, opts),
generateData: generateMultiplicationTableData,
},
];
/**
* 通过路由 ID 查找类型配置
* 处理别名映射:counting-fill → counting-select, number-object-fill → number-object-match 等
*/
export function findTypeByRouteId(
routeId: string,
): { typeConfig: MathTypeConfig; mode?: string; extra?: Record<string, any> } | null {
const direct = MATH_TYPE_CONFIGS.find((t) => t.id === routeId);
if (direct) return { typeConfig: direct };
// 别名映射
const aliases: Record<string, { typeId: string; extra?: Record<string, any> }> = {
'counting-fill': { typeId: 'counting-select', extra: { functionId: 'counting-fill' } },
'number-object-fill': { typeId: 'number-object-match', extra: { defaultMode: 'fill' } },
'number-write': { typeId: 'number-find', extra: { functionId: 'number-write' } },
'number-find': { typeId: 'number-find' },
'addition-5': { typeId: 'addition' },
'addition-10': { typeId: 'addition', extra: { defaultMode: 'addition-10' } },
'subtraction-10': { typeId: 'addition', extra: { defaultMode: 'subtraction-10' } },
'addition-subtraction-10': { typeId: 'addition', extra: { defaultMode: 'addition-subtraction-10' } },
};
const alias = aliases[routeId];
if (alias) {
const cfg = MATH_TYPE_CONFIGS.find((t) => t.id === alias.typeId);
if (cfg) return { typeConfig: cfg, mode: alias.extra?.defaultMode, extra: alias.extra };
}
return { typeConfig: MATH_TYPE_CONFIGS[0] };
}