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,206 @@
@import '../../style/theme.less';
page {
background-color: @bg-page;
}
.fd-page {
min-height: 100vh;
padding: 0 @page-padding-x;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.fd-main {
padding-top: 24rpx;
display: flex;
flex-direction: column;
gap: 64rpx;
}
/* ===== 预览卡 ===== */
.fd-preview-wrap {
position: relative;
}
.fd-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;
}
.fd-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;
}
.fd-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;
}
.fd-canvas {
max-width: 100%;
border-radius: @radius-sm;
box-shadow: @shadow;
}
/* ===== 区块 ===== */
.fd-section {
display: flex;
flex-direction: column;
gap: 32rpx;
}
.fd-section-title {
font-size: 32rpx;
font-weight: 700;
color: #6d3b00;
padding-left: 8rpx;
}
/* ===== 练习类型 2列卡片网格 ===== */
.fd-type-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32rpx;
}
.fd-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);
}
.fd-type-card__icon {
font-size: 42rpx;
line-height: 1;
margin-bottom: 4rpx;
}
.fd-type-card--active {
background: linear-gradient(145deg, #ffd709 0%, #efc900 100%);
box-shadow: 0 8rpx 32rpx rgba(50, 46, 37, 0.06);
}
.fd-type-card--pressed {
transform: scale(0.95);
}
.fd-type-card__label {
font-size: 30rpx;
font-weight: 700;
color: @text-secondary;
text-align: center;
}
.fd-type-card--active .fd-type-card__label {
color: #453900;
}
/* ===== 子选项 Chip ===== */
.fd-chip-row {
display: flex;
flex-direction: row;
gap: 32rpx;
}
.fd-chip {
flex: 1;
box-sizing: border-box;
text-align: center;
padding: 24rpx 24rpx;
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);
}
.fd-chip--pressed {
transform: scale(0.96);
background: @brand;
color: @text-selected-btn;
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.08);
}
.fd-chip--active {
background: @brand;
color: @text-selected-btn;
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.08);
}
/* ===== 随机生成按钮 ===== */
.fd-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);
}
.fd-shuffle-btn--hover {
transform: scale(0.98);
opacity: 0.92;
background-color: rgba(234, 226, 208, 0.55);
}
.fd-shuffle-btn toy-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.fd-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);
}
@@ -0,0 +1,184 @@
import { createFocusPage } from '../shared/common/focusPageMixin';
import { BaseDrawService } from '../../core/draw/baseDraw';
import {
FOCUS_TYPE_CONFIGS,
findTypeByRouteId,
type FocusTypeConfig,
type FocusTypeAction,
} from './registry';
const TYPE_LIST = FOCUS_TYPE_CONFIGS.map((t) => ({
id: t.id,
title: t.title,
icon: t.icon,
}));
createFocusPage({
canvas: null as Canvas | null,
ctx: null as RenderingContext | null,
boxHeight: 0,
boxWidth: 0,
drawService: null as BaseDrawService | null,
currentTypeConfig: null as FocusTypeConfig | null,
currentData: null as any,
data: {
pageTitle: '专注力练习',
functionId: '',
hasContent: false,
showShareDialog: false,
boxWidth: 0,
boxHeight: 0,
selectedTypeId: '',
typeList: TYPE_LIST,
showActions: false,
actionsTitle: '选择模式',
currentActions: [] as FocusTypeAction[],
currentMode: '',
},
onLoad(options: { id?: string; mode?: string }) {
const routeId = options.id || FOCUS_TYPE_CONFIGS[0].id;
const result = findTypeByRouteId(routeId);
if (!result) return;
const { typeConfig, mode } = result;
const initialMode =
options.mode || mode || typeConfig.defaultMode || '';
this.currentTypeConfig = typeConfig;
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,
});
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 = FOCUS_TYPE_CONFIGS.find((t) => t.id === id);
if (!typeConfig) return;
this.currentTypeConfig = typeConfig;
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,
});
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();
},
/** 执行 Canvas 绘制 */
async drawCanvas() {
if (!this.ctx || !this.drawService || !this.currentData) return;
try {
await this.drawService.draw(this.currentData);
this.setData({ hasContent: true });
} catch (error) {
console.error('绘制失败:', error);
this.setData({ hasContent: false });
}
},
/** 随机生成数据并绘制 */
onRandom() {
if (!this.currentTypeConfig) return;
this.currentData = this.currentTypeConfig.generateData(
this.data.currentMode,
);
this.drawCanvas();
},
});
@@ -0,0 +1,86 @@
<nav-bar title="{{pageTitle}}" />
<view class="fd-page">
<view class="fd-main">
<!-- 打印预览卡 -->
<view class="fd-preview-wrap">
<view class="fd-preview-card">
<text class="fd-preview-kicker">打印预览</text>
<view id="canvasWrapper" class="fd-canvas-wrap">
<canvas
type="2d"
id="canvasContent"
class="fd-canvas"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
</view>
</view>
</view>
<!-- 练习类型选择(2列卡片网格) -->
<view class="fd-section">
<text class="fd-section-title">练习类型</text>
<view class="fd-type-grid">
<view
wx:for="{{typeList}}"
wx:key="id"
class="fd-type-card {{selectedTypeId === item.id ? 'fd-type-card--active' : ''}}"
data-id="{{item.id}}"
hover-class="fd-type-card--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectType">
<text class="fd-type-card__icon">{{item.icon}}</text>
<text class="fd-type-card__label">{{item.title}}</text>
</view>
</view>
</view>
<!-- 子选项(当前类型有模式切换时显示) -->
<view wx:if="{{showActions}}" class="fd-section">
<text class="fd-section-title">{{actionsTitle}}</text>
<view class="fd-chip-row">
<view
wx:for="{{currentActions}}"
wx:key="value"
class="fd-chip {{currentMode === item.value ? 'fd-chip--active' : ''}}"
data-value="{{item.value}}"
hover-class="fd-chip--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectMode">
{{item.label}}
</view>
</view>
</view>
<!-- 随机生成按钮 -->
<view
class="fd-shuffle-btn"
hover-class="fd-shuffle-btn--hover"
hover-start-time="0"
hover-stay-time="70"
bindtap="onRandom">
<toy-icon
name="refresh"
size="40rpx"
color="#605b50"
custom-class="fd-shuffle-btn__icon" />
<text class="fd-shuffle-btn__text">随机生成</text>
</view>
<!-- 广告位 -->
<draw-ad type="focusDraw"></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" />
@@ -0,0 +1,691 @@
import { BaseDrawService } from '../../core/draw/baseDraw';
import ColorShapeMatchDraw from '../shared/service/colorShapeMatchDraw';
import ShapeSymbolDraw from '../shared/service/shapeSymbolDraw';
import PositionColoringDraw from '../shared/service/positionColoringDraw';
import ColorPatternDraw from '../shared/service/colorPatternDraw';
import MatchConnectDraw from '../shared/service/matchConnectDraw';
import LineRecognitionDraw from '../shared/service/lineRecognitionDraw';
import GridReasoningDraw, {
type GridPosition,
} from '../shared/service/gridReasoningDraw';
import CodeConnectDraw from '../shared/service/codeConnectDraw';
import DotConnectDraw from '../shared/service/dotConnectDraw';
import GridDraw, { type GridGroup } from '../shared/service/gridDraw';
import {
getRandomUniqueNumberColors,
getNumberColors,
getRandomNumberColor,
NUMBER_COLORS,
WATER_COLORS,
} from '../../constants/colors';
import { SHAPE_SYMBOL_SHAPES } from '../shared/shapes/shapeSymbolShapes';
import { generateCompletePattern } from '../shared/utils/gridUtils';
import { ALL_3X3_SHAPES } from '../shared/shapes/gridShapes3x3';
import { ALL_5X5_SHAPES } from '../shared/shapes/gridShapes5x5';
import { ALL_7X7_SHAPES } from '../shared/shapes/gridShapes7x7';
import type {
ShapeTemplate,
GridCell,
GridConfig,
} from '../shared/types/gridTypes';
// ─── Types ───
export interface FocusTypeAction {
value: string;
label: string;
}
export interface FocusTypeConfig {
id: string;
title: string;
subTitle: string;
icon: string;
actionsTitle?: string;
actions?: FocusTypeAction[];
defaultMode?: string;
getTitle?: (mode: string) => string;
getSubTitle?: (mode: string) => string;
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) => BaseDrawService;
generateData: (mode?: string) => any;
}
// ─── Helper: dotConnect ───
function generateNonBacktrackingSequence(length: number): number[] {
const getPosition = (num: number): [number, number] => {
const row = Math.floor((num - 1) / 3);
const col = (num - 1) % 3;
return [row, col];
};
const sequence: number[] = [];
const used = new Set<number>();
const startNum = Math.floor(Math.random() * 9) + 1;
sequence.push(startNum);
used.add(startNum);
let prevPos = getPosition(startNum);
let prevPrevPos: [number, number] | null = null;
while (sequence.length < length) {
const candidates: number[] = [];
for (let num = 1; num <= 9; num++) {
if (used.has(num)) continue;
const currentPos = getPosition(num);
if (
prevPrevPos &&
currentPos[0] === prevPrevPos[0] &&
currentPos[1] === prevPrevPos[1]
) {
continue;
}
candidates.push(num);
}
const pool =
candidates.length > 0
? candidates
: Array.from({ length: 9 }, (_, i) => i + 1).filter(
(n) => !used.has(n),
);
if (pool.length === 0) break;
const nextNum = pool[Math.floor(Math.random() * pool.length)];
sequence.push(nextNum);
used.add(nextNum);
prevPrevPos = prevPos;
prevPos = getPosition(nextNum);
}
return sequence;
}
// ─── Helpers: gridReasoning ───
function generateRandomPositions(count: number): GridPosition[] {
const allPositions: GridPosition[] = [];
for (let y = 0; y < 3; y++) {
for (let x = 0; x < 3; x++) {
allPositions.push({ x, y });
}
}
const shuffled = [...allPositions].sort(() => Math.random() - 0.5);
return shuffled.slice(0, count);
}
function unionPositions(
pos1: GridPosition[],
pos2: GridPosition[],
): GridPosition[] {
const set = new Set<string>();
const result: GridPosition[] = [];
for (const pos of pos1) {
const key = `${pos.x},${pos.y}`;
if (!set.has(key)) {
set.add(key);
result.push(pos);
}
}
for (const pos of pos2) {
const key = `${pos.x},${pos.y}`;
if (!set.has(key)) {
set.add(key);
result.push(pos);
}
}
return result;
}
function subtractPositions(
pos1: GridPosition[],
pos2: GridPosition[],
): GridPosition[] {
const set2 = new Set(pos2.map((p) => `${p.x},${p.y}`));
return pos1.filter((p) => !set2.has(`${p.x},${p.y}`));
}
// ─── Helpers: gridDrawing ───
function generateUniqueGroups(
allShapes: ShapeTemplate[],
count: number,
config: GridConfig,
): GridGroup[] {
const groups: GridGroup[] = [];
const usedPatterns = new Set<string>();
const availableShapes = [...allShapes];
for (let i = availableShapes.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[availableShapes[i], availableShapes[j]] = [
availableShapes[j],
availableShapes[i],
];
}
for (let i = 0; i < count; i++) {
const shapeIndex = i % availableShapes.length;
const shape = availableShapes[shapeIndex];
const cells = generateCompletePattern(shape, config);
const sig = cells
.map((c: GridCell) => `${c.x},${c.y},${c.color}`)
.sort()
.join('|');
if (
usedPatterns.has(sig) &&
usedPatterns.size < Math.min(count, allShapes.length)
) {
let found = false;
for (const nextShape of availableShapes) {
const nextCells = generateCompletePattern(nextShape, config);
const nextSig = nextCells
.map((c: GridCell) => `${c.x},${c.y},${c.color}`)
.sort()
.join('|');
if (!usedPatterns.has(nextSig)) {
groups.push({ filledCells: nextCells, emptyCells: [] });
usedPatterns.add(nextSig);
found = true;
break;
}
}
if (!found) {
groups.push({ filledCells: cells, emptyCells: [] });
}
} else {
groups.push({ filledCells: cells, emptyCells: [] });
usedPatterns.add(sig);
}
}
return groups;
}
// ─── Data Generators ───
function generateColorShapeMatchData() {
const shapes = [...SHAPE_SYMBOL_SHAPES].filter((s) => s.id !== 'cross');
const selectedShapes = shapes.sort(() => Math.random() - 0.5).slice(0, 4);
const colors = getRandomUniqueNumberColors(4);
const legendItems = selectedShapes.map((shape, i) => ({
shapeId: shape.id,
color: colors[i],
}));
const allColors: (string | null)[] = new Array(25).fill(null);
const positions = Array.from({ length: 25 }, (_, i) => i);
const shuffledPos = [...positions].sort(() => Math.random() - 0.5);
for (let i = 0; i < 4; i++) allColors[shuffledPos[i]] = colors[i];
for (let i = 4; i < 25; i++)
allColors[shuffledPos[i]] =
colors[Math.floor(Math.random() * colors.length)];
const practiceGrid: Array<Array<string | null>> = [];
for (let row = 0; row < 5; row++) {
practiceGrid.push(
Array.from({ length: 5 }, (_, col) => allColors[row * 5 + col]),
);
}
return { legendItems, practiceGrid };
}
function generateShapeSymbolData() {
const shuffled = [...SHAPE_SYMBOL_SHAPES].sort(() => Math.random() - 0.5);
const selectedShapes = shuffled.slice(0, 4);
const symbols = ['+', '-', '×', '✓'];
const shuffledSymbols = [...symbols].sort(() => Math.random() - 0.5);
const shapeColorMap = new Map<string, string>();
const usedColors = new Set<string>();
const legendMapping: Array<{
shapeId: string;
symbol: string;
color: string;
}> = [];
for (let i = 0; i < 4; i++) {
const shape = selectedShapes[i];
let color: string;
if (shapeColorMap.has(shape.id)) {
color = shapeColorMap.get(shape.id)!;
} else {
do {
color = getRandomNumberColor();
} while (usedColors.has(color));
shapeColorMap.set(shape.id, color);
usedColors.add(color);
}
legendMapping.push({
shapeId: shape.id,
symbol: shuffledSymbols[i],
color,
});
}
const practiceRows: Array<
Array<{ shapeId: string | null; symbol: string | null }>
> = [];
for (let g = 0; g < 4; g++) {
const shapeRow = Array.from({ length: 8 }, () => {
const rs =
selectedShapes[
Math.floor(Math.random() * selectedShapes.length)
];
return { shapeId: rs.id, symbol: null };
});
practiceRows.push(shapeRow);
practiceRows.push(
Array.from({ length: 8 }, () => ({ shapeId: null, symbol: null })),
);
}
return {
legendMapping,
practiceRows,
shapeColorMap: Object.fromEntries(shapeColorMap),
};
}
function generatePositionColoringData() {
const allImageIndices = Array.from({ length: 33 }, (_, i) => i + 1);
const shuffled = [...allImageIndices].sort(() => Math.random() - 0.5);
const selectedImages = shuffled.slice(0, 9);
const referenceGrid: number[] = [...selectedImages];
const taskImageIndices = [...selectedImages].sort(
() => Math.random() - 0.5,
);
const tasks = taskImageIndices.map((imageIndex) => {
const gridIndex = referenceGrid.indexOf(imageIndex);
return {
imageIndex,
position: { x: gridIndex % 3, y: Math.floor(gridIndex / 3) },
};
});
return { referenceGrid, tasks };
}
function generateColorPatternData() {
const allShapes = [...SHAPE_SYMBOL_SHAPES].sort(() => Math.random() - 0.5);
const selectedShapes = allShapes.slice(0, 8);
const allColors = getNumberColors();
const groups: Array<{ shapeId: string; colors: Array<string | null> }> = [];
for (let gi = 0; gi < 8; gi++) {
const shuffledColors = [...allColors].sort(() => Math.random() - 0.5);
const selectedColors = shuffledColors.slice(0, 2);
const patternColors: string[] = [];
for (let i = 0; i < 4; i++) {
patternColors.push(
selectedColors[
Math.floor(Math.random() * selectedColors.length)
],
);
}
if (new Set(patternColors).size === 1) {
const other = selectedColors.filter((c) => c !== patternColors[0]);
if (other.length > 0) {
patternColors[Math.floor(Math.random() * 4)] =
other[Math.floor(Math.random() * other.length)];
}
}
groups.push({
shapeId: selectedShapes[gi].id,
colors: [...patternColors, ...new Array(6).fill(null)],
});
}
return { groups };
}
function generateMatchConnectData() {
const allImageIndices = Array.from({ length: 33 }, (_, i) => i + 1);
const shuffled = [...allImageIndices].sort(() => Math.random() - 0.5);
const selectedImages = shuffled.slice(0, 5);
const referenceSequence = [...selectedImages];
const boxes = Array.from({ length: 6 }, () => ({
imageIndices: [...selectedImages],
}));
return { referenceSequence, boxes };
}
function generateLineRecognitionData() {
const LINE_TYPES: Array<{ type: string; name: string }> = [
{ type: 'straight', name: '直线' },
{ type: 'dashed', name: '虚线' },
{ type: 'wavy', name: '波浪线' },
{ type: 'zigzag', name: '锯齿线' },
{ type: 'spiral', name: '电话线' },
];
const colorKeys = Object.keys(NUMBER_COLORS)
.map(Number)
.filter((k) => k !== 11 && k !== 12);
const shuffledColors = [...colorKeys].sort(() => Math.random() - 0.5);
const selectedColorKeys = shuffledColors.slice(0, 5);
const shuffledLineTypes = [...LINE_TYPES].sort(() => Math.random() - 0.5);
const referenceLines = shuffledLineTypes.map((lt, i) => ({
type: lt.type,
name: lt.name,
color: NUMBER_COLORS[selectedColorKeys[i]],
}));
const practiceBlocks: Array<{
color: string;
lineType: string;
x: number;
y: number;
}> = [];
for (let row = 0; row < 8; row++) {
for (let col = 0; col < 2; col++) {
const sel =
referenceLines[
Math.floor(Math.random() * referenceLines.length)
];
practiceBlocks.push({
color: sel.color,
lineType: sel.type,
x: (Math.random() - 0.5) * 40,
y: (Math.random() - 0.5) * 16,
});
}
}
return { referenceLines, practiceBlocks };
}
function generateGridReasoningData(mode?: string) {
const operatorType = mode || 'mixed';
const colorKeys = Object.keys(NUMBER_COLORS).map(Number);
const selectedColor =
NUMBER_COLORS[colorKeys[Math.floor(Math.random() * colorKeys.length)]];
const rows: Array<{
grid1: GridPosition[];
grid2: GridPosition[];
operator: '+' | '-';
result: GridPosition[];
}> = [];
for (let i = 0; i < 5; i++) {
let operator: '+' | '-';
if (operatorType === 'addition') operator = '+';
else if (operatorType === 'subtraction') operator = '-';
else operator = Math.random() > 0.5 ? '+' : '-';
const grid1Count =
operator === '-'
? Math.floor(Math.random() * 8) + 2
: Math.floor(Math.random() * 9) + 1;
const grid1 = generateRandomPositions(grid1Count);
let grid2: GridPosition[];
let result: GridPosition[];
if (operator === '+') {
grid2 = generateRandomPositions(Math.floor(Math.random() * 9) + 1);
result = unionPositions(grid1, grid2);
} else {
const maxG2 = grid1Count - 1;
grid2 = generateRandomPositions(
Math.floor(Math.random() * maxG2) + 1,
);
result = subtractPositions(grid1, grid2);
}
rows.push({ grid1, grid2, operator, result });
}
const answerOptions = rows.map((r, i) => ({
positions: r.result,
rowIndex: i,
}));
answerOptions.sort(() => Math.random() - 0.5);
return { rows, answerOptions, color: selectedColor };
}
function generateCodeConnectData() {
const startOptions = [1, 2, 3];
const startNumber =
startOptions[Math.floor(Math.random() * startOptions.length)];
const numbers = Array.from({ length: 8 }, (_, i) => startNumber + i);
const colorIndices = Array.from(
{ length: WATER_COLORS.extended24.length },
(_, i) => i,
);
const shuffledIdx = [...colorIndices]
.sort(() => Math.random() - 0.5)
.slice(0, 8);
const colorMap = numbers.map((num, i) => ({
number: num,
color: WATER_COLORS.extended24[shuffledIdx[i]].hex,
}));
const groups: Array<{
sequence: number[];
dots: Array<{ number: number; color: string; angle: number }>;
}> = [];
for (let i = 0; i < 4; i++) {
const sequence = [...numbers]
.sort(() => Math.random() - 0.5)
.slice(0, 5);
const dots = numbers.map((num, j) => {
const angle = (j / 8) * Math.PI * 2 - Math.PI / 2;
const info = colorMap.find((m) => m.number === num);
return { number: num, color: info?.color || '#000', angle };
});
groups.push({ sequence, dots });
}
return { colorMap, startNumber, groups };
}
function generateDotConnectData() {
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const colors = getRandomUniqueNumberColors(9);
const colorMap = numbers.map((num, i) => ({
number: num,
color: colors[i],
}));
const groups = Array.from({ length: 9 }, () => {
const seqLen = Math.floor(Math.random() * 3) + 4;
return { sequence: generateNonBacktrackingSequence(seqLen) };
});
return { colorMap, groups };
}
function generateGridDrawingData(mode?: string) {
const m = mode || '3x3';
let allShapes: ShapeTemplate[];
let config: GridConfig;
let groupsPerRow: number;
let totalRows: number;
if (m === '5x5') {
allShapes = ALL_5X5_SHAPES;
config = { rows: 5, cols: 5 };
groupsPerRow = 2;
totalRows = 4;
} else if (m === '7x7') {
allShapes = ALL_7X7_SHAPES;
config = { rows: 7, cols: 7 };
groupsPerRow = 1;
totalRows = 2;
} else {
allShapes = ALL_3X3_SHAPES;
config = { rows: 3, cols: 3 };
groupsPerRow = 2;
totalRows = 4;
}
const totalGroups = groupsPerRow * totalRows;
const groups = generateUniqueGroups(allShapes, totalGroups, config);
return { config, groups, groupsPerRow, totalRows };
}
// ─── Registry ───
const GRID_DRAWING_SUBTITLES: Record<string, string> = {
'3x3': '简单有趣,培养专注力',
'5x5': '创意挑战,提升观察力',
'7x7': '大师挑战,锻炼耐心',
};
export const FOCUS_TYPE_CONFIGS: FocusTypeConfig[] = [
{
id: 'color-shape-match',
title: '根据颜色画图形',
subTitle: '根据颜色画出对应的图形',
icon: '🎯',
createDrawService: (canvas, ctx, opts) =>
new ColorShapeMatchDraw(canvas, ctx, opts),
generateData: generateColorShapeMatchData,
},
{
id: 'shape-symbol',
title: '图形符号配对',
subTitle: '根据图形画对应的符号',
icon: '🔗',
createDrawService: (canvas, ctx, opts) =>
new ShapeSymbolDraw(canvas, ctx, opts),
generateData: generateShapeSymbolData,
},
{
id: 'position-coloring',
title: '方位涂涂乐',
subTitle: '观察卡片位置,在对应的方格中涂上颜色',
icon: '📍',
createDrawService: (canvas, ctx, opts) =>
new PositionColoringDraw(canvas, ctx, opts),
generateData: generatePositionColoringData,
},
{
id: 'color-pattern',
title: '颜色找规律',
subTitle: '观察颜色规律,在空白图形中涂上颜色',
icon: '🎨',
createDrawService: (canvas, ctx, opts) =>
new ColorPatternDraw(canvas, ctx, opts),
generateData: generateColorPatternData,
},
{
id: 'match-connect',
title: '连连看',
subTitle: '快来根据物品连一连吧!',
icon: '🔗',
createDrawService: (canvas, ctx, opts) =>
new MatchConnectDraw(canvas, ctx, opts),
generateData: generateMatchConnectData,
},
{
id: 'line-recognition',
title: '线条识别',
subTitle: '认识不同线条,画出颜色对应的线条',
icon: '📏',
createDrawService: (canvas, ctx, opts) =>
new LineRecognitionDraw(canvas, ctx, opts),
generateData: generateLineRecognitionData,
},
{
id: 'grid-reasoning',
title: '方格推理',
subTitle: '仔细观察,推理出合并方格并连线',
icon: '🧩',
actionsTitle: '选择运算',
actions: [
{ value: 'addition', label: '加法运算' },
{ value: 'subtraction', label: '减法运算' },
{ value: 'mixed', label: '混合运算' },
],
defaultMode: 'mixed',
createDrawService: (canvas, ctx, opts) =>
new GridReasoningDraw(canvas, ctx, opts),
generateData: generateGridReasoningData,
},
{
id: 'code-connect',
title: '译码连线',
subTitle: '按照数字顺序,将数字对应的颜色连起来',
icon: '🔢',
createDrawService: (canvas, ctx, opts) =>
new CodeConnectDraw(canvas, ctx, opts),
generateData: generateCodeConnectData,
},
{
id: 'dot-connect',
title: '数字点连线',
subTitle: '按数字顺序连点成图',
icon: '🔗',
createDrawService: (canvas, ctx, opts) =>
new DotConnectDraw(canvas, ctx, opts),
generateData: generateDotConnectData,
},
{
id: 'grid-drawing',
title: '格子仿画',
subTitle: '在网格中填充颜色,形成各种形状',
icon: '🎨',
actionsTitle: '选择难度',
actions: [
{ value: '3x3', label: '3×3' },
{ value: '5x5', label: '5×5' },
{ value: '7x7', label: '7×7' },
],
defaultMode: '3x3',
getTitle: (mode: string) => {
const labels: Record<string, string> = {
'3x3': '3×3',
'5x5': '5×5',
'7x7': '7×7',
};
return `格子仿画 ${labels[mode] || '3×3'}`;
},
getSubTitle: (mode: string) =>
GRID_DRAWING_SUBTITLES[mode] || GRID_DRAWING_SUBTITLES['3x3'],
createDrawService: (canvas, ctx, opts) =>
new GridDraw(canvas, ctx, opts),
generateData: generateGridDrawingData,
},
];
/**
* 通过路由 ID 查找类型配置
* 处理 grid-drawing-3x3 → grid-drawing + mode=3x3 等别名
*/
export function findTypeByRouteId(
routeId: string,
): { typeConfig: FocusTypeConfig; mode?: string } | null {
const direct = FOCUS_TYPE_CONFIGS.find((t) => t.id === routeId);
if (direct) return { typeConfig: direct };
if (routeId.startsWith('grid-drawing-')) {
const mode = routeId.replace('grid-drawing-', '');
const cfg = FOCUS_TYPE_CONFIGS.find((t) => t.id === 'grid-drawing');
if (cfg) return { typeConfig: cfg, mode };
}
return { typeConfig: FOCUS_TYPE_CONFIGS[0] };
}