feat: 添加控笔页

This commit is contained in:
R524809
2026-05-28 16:46:30 +08:00
parent 824e83c887
commit 2f0c4ab591
21 changed files with 1406 additions and 169 deletions
@@ -0,0 +1,268 @@
/** 控笔图形:100×100 坐标系,Y 轴向下 */
export type PenControlCategory = 'line' | 'curve' | 'shape' | 'combo';
export const PEN_CONTROL_CATEGORY_LABEL: Record<PenControlCategory, string> = {
line: '直线',
curve: '曲线',
shape: '形状',
combo: '组合',
};
export type PenControlRenderMode = 'stroke' | 'fill' | 'both';
export interface PenControlPattern {
id: string;
name: string;
category: PenControlCategory;
categoryLabel?: string;
tags: string[];
paths: string[];
render: PenControlRenderMode;
strokeScale?: number;
}
const RAW_PEN_CONTROL_PATTERNS: PenControlPattern[] = [
{
id: 'horizontal-lines',
name: '横线',
category: 'line',
tags: ['基础', '直线'],
render: 'stroke',
paths: [
'M 14 26 L 86 26',
'M 14 42 L 86 42',
'M 14 58 L 86 58',
'M 14 74 L 86 74',
],
},
{
id: 'vertical-lines',
name: '竖线',
category: 'line',
tags: ['基础', '直线'],
render: 'stroke',
paths: [
'M 26 14 L 26 86',
'M 42 14 L 42 86',
'M 58 14 L 58 86',
'M 74 14 L 74 86',
],
},
{
id: 'diagonal-lr',
name: '左斜线',
category: 'line',
tags: ['斜线'],
render: 'stroke',
paths: [
'M 14 14 L 86 86',
'M 14 32 L 68 86',
'M 32 14 L 86 68',
'M 14 50 L 50 86',
'M 50 14 L 86 50',
],
},
{
id: 'diagonal-rl',
name: '右斜线',
category: 'line',
tags: ['斜线'],
render: 'stroke',
paths: [
'M 86 14 L 14 86',
'M 86 32 L 32 86',
'M 68 14 L 14 68',
'M 86 50 L 50 86',
'M 50 14 L 14 50',
],
},
{
id: 'zigzag',
name: '锯齿线',
category: 'line',
tags: ['折线'],
render: 'stroke',
paths: ['M 14 18 L 26 82 L 38 18 L 50 82 L 62 18 L 74 82 L 86 18'],
},
{
id: 'loop-single',
name: '回字圈',
category: 'shape',
tags: ['形状'],
render: 'stroke',
paths: [
'M 14 14 L 86 14 L 86 86 L 14 86 Z',
'M 28 28 L 72 28 L 72 72 L 28 72 Z',
'M 42 42 L 58 42 L 58 58 L 42 58 Z',
],
},
{
id: 'spiral',
name: '蜗牛线',
category: 'combo',
tags: ['组合'],
render: 'stroke',
strokeScale: 0.95,
paths: [
'M 88 50 Q 88 86, 50 86 Q 14 86, 14 50 Q 14 18, 50 18 Q 80 18, 80 50 Q 80 74, 50 74 Q 26 74, 26 50 Q 26 30, 50 30 Q 68 30, 68 50 Q 68 62, 50 62 Q 38 62, 38 50 Q 38 44, 50 44 Q 56 44, 56 50',
],
},
{
id: 'mountain',
name: '山峰线',
category: 'line',
tags: ['折线'],
render: 'stroke',
paths: ['M 14 76 L 32 28 L 50 64 L 68 28 L 86 76'],
},
{
id: 'stair',
name: '台阶线',
category: 'line',
tags: ['折线'],
render: 'stroke',
paths: [
'M 14 58 L 14 43 L 38 43 L 38 28 L 62 28 L 62 13 L 86 13',
'M 14 88 L 14 73 L 38 73 L 38 58 L 62 58 L 62 43 L 86 43',
],
},
{
id: 'corner-turn',
name: '转折线',
category: 'line',
tags: ['折线'],
render: 'stroke',
paths: [
'M 86 86 L 14 86 L 14 14 L 86 14 L 86 74 L 26 74 L 26 26 L 74 26 L 74 62 L 38 62 L 38 38 L 62 38 L 62 50',
],
},
{
id: 'wave',
name: '波浪线',
category: 'curve',
tags: ['曲线'],
render: 'stroke',
paths: ['M 14 50 Q 32 18, 50 50 Q 68 82, 86 50'],
},
{
id: 'horizontal-curve',
name: '横曲线',
category: 'curve',
tags: ['曲线'],
render: 'stroke',
paths: [
'M 14 24 Q 20 8, 26 24 Q 32 40, 38 24 Q 44 8, 50 24 Q 56 40, 62 24 Q 68 8, 74 24 Q 80 40, 86 24',
'M 14 50 Q 20 34, 26 50 Q 32 66, 38 50 Q 44 34, 50 50 Q 56 66, 62 50 Q 68 34, 74 50 Q 80 66, 86 50',
'M 14 76 Q 20 60, 26 76 Q 32 92, 38 76 Q 44 60, 50 76 Q 56 92, 62 76 Q 68 60, 74 76 Q 80 92, 86 76',
],
},
{
id: 'vertical-curve',
name: '竖曲线',
category: 'curve',
tags: ['曲线'],
render: 'stroke',
paths: [
'M 24 14 Q 8 20, 24 26 Q 40 32, 24 38 Q 8 44, 24 50 Q 40 56, 24 62 Q 8 68, 24 74 Q 40 80, 24 86',
'M 50 14 Q 34 20, 50 26 Q 66 32, 50 38 Q 34 44, 50 50 Q 66 56, 50 62 Q 34 68, 50 74 Q 66 80, 50 86',
'M 76 14 Q 60 20, 76 26 Q 92 32, 76 38 Q 60 44, 76 50 Q 92 56, 76 62 Q 60 68, 76 74 Q 92 80, 76 86',
],
},
{
id: 's-curve',
name: 'S 弯',
category: 'curve',
tags: ['曲线'],
render: 'stroke',
strokeScale: 1.1,
paths: ['M 80 16 Q 18 18, 50 50 Q 82 82, 20 84'],
},
{
id: 'arc-up',
name: '左弧线',
category: 'curve',
tags: ['弧线'],
render: 'stroke',
paths: [
'M 52 13 Q 13 13, 13 52',
'M 69 30 Q 30 30, 30 69',
'M 86 47 Q 47 47, 47 86',
],
},
{
id: 'arc-down',
name: '右弧线',
category: 'curve',
tags: ['弧线'],
render: 'stroke',
paths: [
'M 13 52 Q 52 52, 52 13',
'M 30 69 Q 69 69, 69 30',
'M 47 86 Q 86 86, 86 47',
],
},
{
id: 'figure-eight',
name: '长8线',
category: 'combo',
tags: ['组合'],
render: 'stroke',
strokeScale: 0.95,
paths: [
'M 20 50 C 10 41, 13 20, 27 13 C 41 6, 42 28, 34 40 C 30 45, 25 49, 20 50 C 30 59, 27 80, 14 87 C 5 92, 5 72, 8 60 C 11 55, 15 51, 20 50',
'M 50 50 C 40 41, 43 20, 57 13 C 71 6, 72 28, 64 40 C 60 45, 55 49, 50 50 C 60 59, 57 80, 43 87 C 32 92, 31 72, 36 60 C 40 55, 45 51, 50 50',
'M 80 50 C 70 41, 73 20, 87 13 C 99 7, 100 28, 94 40 C 90 45, 85 49, 80 50 C 90 59, 87 80, 73 87 C 62 92, 61 72, 66 60 C 70 55, 75 51, 80 50',
],
},
{
id: 'cross-hatch',
name: '小太阳',
category: 'combo',
tags: ['组合'],
render: 'stroke',
strokeScale: 0.9,
paths: [
'M 62 50 C 62 56.6, 56.6 62, 50 62 C 43.4 62, 38 56.6, 38 50 C 38 43.4, 43.4 38, 50 38 C 56.6 38, 62 43.4, 62 50 Z',
'M 50 30 L 50 12',
'M 50 70 L 50 88',
'M 30 50 L 12 50',
'M 70 50 L 88 50',
'M 36 36 L 22 22',
'M 64 36 L 78 22',
'M 36 64 L 22 78',
'M 64 64 L 78 78',
],
},
];
export const PEN_CONTROL_PATTERNS: PenControlPattern[] =
RAW_PEN_CONTROL_PATTERNS.map((p) => ({
...p,
categoryLabel: PEN_CONTROL_CATEGORY_LABEL[p.category],
}));
const PATTERN_BY_ID = Object.fromEntries(
PEN_CONTROL_PATTERNS.map((p) => [p.id, p]),
) as Record<string, PenControlPattern>;
export function getPenControlPattern(
id: string,
): PenControlPattern | undefined {
return PATTERN_BY_ID[id];
}
export const PEN_CONTROL_PATTERN_IDS = PEN_CONTROL_PATTERNS.map((p) => p.id);
export function pickRandomPatternIds(
count: number,
exclude: string[] = [],
): string[] {
const pool = PEN_CONTROL_PATTERNS.map((p) => p.id).filter(
(id) => !exclude.includes(id),
);
const n = Math.max(0, Math.min(count, pool.length));
const shuffled = [...pool].sort(() => Math.random() - 0.5);
return shuffled.slice(0, n);
}
@@ -0,0 +1,169 @@
import { BaseDrawService } from '../../../core/draw/baseDraw';
import { drawTianZiGrid } from '../../shared/drawUtils';
import { getPenControlPattern } from '../data/penControlPatterns';
import type { PenControlSheetData } from '../generators/penControlGenerator';
import {
drawPenControlInCell,
type PenControlCellStyle,
} from '../shared/penControlDrawUtils';
/** 控笔练习固定 8 列 × 10 行 */
const LAYOUT = {
topGap: 17,
leftMargin: 40,
rightMargin: 40,
bottomMargin: 40,
targetCols: 8,
targetRows: 10,
minColGap: 6,
rowGap: 8,
} as const;
export default class PenControlDrawService extends BaseDrawService {
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
) {
super(canvas, ctx, {
title: '控笔组合练习',
subTitle: '每种图形占两行田字格',
...options,
});
}
getMaxGridLayout(): { maxRow: number; maxCol: number } {
return { maxRow: LAYOUT.targetRows, maxCol: LAYOUT.targetCols };
}
computeSheetLayout(maxRow: number, maxCol: number) {
const content = this.getContentRect();
const { minColGap, rowGap } = LAYOUT;
const cellByWidth =
(content.width - (maxCol - 1) * minColGap) / maxCol;
const cellByHeight =
(content.height - (maxRow - 1) * rowGap) / maxRow;
const cellSize = Math.max(
16,
Math.floor(Math.min(cellByWidth, cellByHeight)),
);
const actualColGap =
maxCol > 1
? (content.width - maxCol * cellSize) / (maxCol - 1)
: 0;
const actualRowGap =
maxRow > 1
? Math.min(
rowGap,
(content.height - maxRow * cellSize) / (maxRow - 1),
)
: 0;
return {
maxRow,
maxCol,
cellSize,
startX: content.left,
startY: content.top,
colGap: actualColGap,
rowGap: actualRowGap,
};
}
async draw(data: PenControlSheetData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
const layout = this.computeSheetLayout(
data.layout.maxRow,
data.layout.maxCol,
);
this.drawSheet({ ...data, layout });
this.drawPrintFooter();
}
private getContentRect() {
const topGap = LAYOUT.topGap;
const leftMargin = LAYOUT.leftMargin;
const rightMargin = LAYOUT.rightMargin;
const bottomMargin = LAYOUT.bottomMargin;
const contentTop = this.currentY + topGap;
const contentWidth = this.canvasWidth - leftMargin - rightMargin;
const contentHeight =
this.canvasHeight - contentTop - bottomMargin;
return {
top: contentTop,
left: leftMargin,
width: contentWidth,
height: contentHeight,
};
}
private drawSheet(data: PenControlSheetData) {
const { ctx } = this;
const { layout, blocks } = data;
const {
startX,
startY,
cellSize,
colGap,
rowGap,
maxCol,
maxRow,
} = layout;
for (let row = 0; row < maxRow; row++) {
for (let col = 0; col < maxCol; col++) {
const cx = startX + col * (cellSize + colGap) + cellSize / 2;
const cy = startY + row * (cellSize + rowGap) + cellSize / 2;
drawTianZiGrid({ ctx, cx, cy, size: cellSize });
}
}
let globalRow = 0;
for (const block of blocks) {
for (const rowSpec of block.rows) {
if (globalRow >= maxRow) break;
const pattern = getPenControlPattern(block.patternId);
if (pattern) {
for (let col = 0; col < maxCol; col++) {
const cx =
startX + col * (cellSize + colGap) + cellSize / 2;
const cy =
startY +
globalRow * (cellSize + rowGap) +
cellSize / 2;
const style: PenControlCellStyle =
rowSpec.cells[col] ?? 'guide';
const dashed =
rowSpec.role === 'pair-second' && col >= 1;
drawPenControlInCell(
ctx,
pattern,
cx,
cy,
cellSize,
style,
{ dashed },
);
}
}
globalRow++;
}
if (globalRow >= maxRow) break;
}
}
/** 供页面在绘制前计算 layout */
buildLayoutForGenerate(): ReturnType<
PenControlDrawService['computeSheetLayout']
> {
const { maxRow, maxCol } = this.getMaxGridLayout();
return this.computeSheetLayout(maxRow, maxCol);
}
}
@@ -0,0 +1,123 @@
import {
getPenControlPattern,
PEN_CONTROL_PATTERN_IDS,
} from '../data/penControlPatterns';
import type { PenControlCellStyle } from '../shared/penControlDrawUtils';
export const PEN_CONTROL_MODE = 'pen-control-mix' as const;
export type PenControlMode = typeof PEN_CONTROL_MODE;
export const ROWS_PER_PATTERN = 2;
export const MIN_PATTERNS = 0;
export const MAX_PATTERNS = 5;
export const DEFAULT_PATTERN_COUNT = 5;
export interface PenControlRowSpec {
role: 'pair-first' | 'pair-second';
cells: PenControlCellStyle[];
}
export interface PenControlPatternBlock {
patternId: string;
patternName: string;
rows: [PenControlRowSpec, PenControlRowSpec];
}
export interface PenControlSheetLayout {
maxRow: number;
maxCol: number;
cellSize: number;
startX: number;
startY: number;
colGap: number;
rowGap: number;
}
export interface PenControlSheetData {
mode: PenControlMode;
patternCount: number;
blocks: PenControlPatternBlock[];
layout: PenControlSheetLayout;
}
export function clampPatternCount(
requested: number,
maxRow: number,
): number {
const capByRows = Math.floor(maxRow / ROWS_PER_PATTERN);
const capped = Math.min(
MAX_PATTERNS,
Math.max(MIN_PATTERNS, requested),
Math.max(MIN_PATTERNS, capByRows),
);
return capped;
}
function buildRowCells(
maxCol: number,
_role: 'pair-first' | 'pair-second',
): PenControlCellStyle[] {
const cells: PenControlCellStyle[] = [];
for (let c = 0; c < maxCol; c++) {
cells.push(c === 0 ? 'reference' : 'guide');
}
return cells;
}
function normalizePatternIds(
patternIds: string[],
patternCount: number,
): string[] {
const valid = patternIds.filter((id) =>
PEN_CONTROL_PATTERN_IDS.includes(id),
);
const unique: string[] = [];
for (const id of valid) {
if (!unique.includes(id)) unique.push(id);
}
if (unique.length >= patternCount) {
return unique.slice(0, patternCount);
}
const rest = PEN_CONTROL_PATTERN_IDS.filter((id) => !unique.includes(id));
const shuffled = [...rest].sort(() => Math.random() - 0.5);
while (unique.length < patternCount && shuffled.length > 0) {
unique.push(shuffled.shift()!);
}
return unique.slice(0, patternCount);
}
export function generatePenControlMix(options: {
patternIds: string[];
patternCount: number;
layout: PenControlSheetLayout;
}): PenControlSheetData {
const { layout } = options;
const patternCount = clampPatternCount(
options.patternCount,
layout.maxRow,
);
const ids = normalizePatternIds(options.patternIds, patternCount);
const blocks: PenControlPatternBlock[] = ids.map((patternId) => {
const pattern = getPenControlPattern(patternId);
const pairFirst = buildRowCells(layout.maxCol, 'pair-first');
const pairSecond = buildRowCells(layout.maxCol, 'pair-second');
return {
patternId,
patternName: pattern?.name ?? patternId,
rows: [
{ role: 'pair-first', cells: pairFirst },
{ role: 'pair-second', cells: pairSecond },
],
};
});
return {
mode: PEN_CONTROL_MODE,
patternCount: blocks.length,
blocks,
layout,
};
}
@@ -0,0 +1,69 @@
import type { DebugPublishMeta } from '../../utils/debugPublish';
import { inferGradeFromAge } from '../../utils/debugPublish';
import { PEN_CONTROL_MODE } from './generators/penControlGenerator';
interface PenControlWorksheetDefinition {
id: typeof PEN_CONTROL_MODE;
icon: string;
title: string;
subtitle: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
sortOrder: number;
}
export const PEN_CONTROL_WORKSHEET_DEFINITIONS = [
{
id: PEN_CONTROL_MODE,
icon: 'edit',
title: '控笔组合练习',
subtitle: '3–6 种图形,每种占两行田字格',
ageMin: 3,
ageMax: 6,
difficulty: 1,
tags: ['控笔', '运笔', '田字格', '学前'],
sortOrder: 40,
},
] as const satisfies ReadonlyArray<PenControlWorksheetDefinition>;
type PenControlWorksheetRow = (typeof PEN_CONTROL_WORKSHEET_DEFINITIONS)[number];
const WORKSHEET_BY_ID = Object.fromEntries(
PEN_CONTROL_WORKSHEET_DEFINITIONS.map((m) => [m.id, m]),
) as Record<string, PenControlWorksheetRow>;
export const PEN_CONTROL_WORKSHEET_ID = PEN_CONTROL_WORKSHEET_DEFINITIONS[0].id;
export function getModeInfo(id: string) {
const m = WORKSHEET_BY_ID[id];
return m ? { title: m.title, desc: m.subtitle } : undefined;
}
export function isValidMode(id: string): boolean {
return id in WORKSHEET_BY_ID;
}
export function getPublishMetaByMode(id: string): DebugPublishMeta | null {
const m = WORKSHEET_BY_ID[id];
if (!m) return null;
return {
id: m.id,
title: m.title,
subtitle: m.subtitle,
category: 'chinese',
subcategory: 'pen-control',
path: `/chinesePages/penControlSheet/penControlSheet?id=${m.id}`,
ageMin: m.ageMin,
ageMax: m.ageMax,
grade: inferGradeFromAge(m.ageMin, m.ageMax),
difficulty: m.difficulty,
previewImg: '',
tags: [...m.tags],
isNew: true,
isHot: false,
sortOrder: m.sortOrder,
status: 'draft',
};
}
@@ -0,0 +1,16 @@
{
"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",
"preview-footer-actions": "../../components3.0/preview-footer-actions/preview-footer-actions",
"debug-publish-tools": "../../components3.0/debug-publish-tools/debug-publish-tools",
"toy-icon": "../../toy/icon/icon",
"preview-card": "../../components3.0/preview-card/preview-card"
}
}
@@ -0,0 +1,123 @@
@import '../../style/theme.less';
page {
background-color: @bg-page;
}
.pc-page {
min-height: 100vh;
padding: 0 @page-padding-x;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.pc-main {
padding-top: 24rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
.pc-section {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.pc-section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.pc-section-title {
font-size: 30rpx;
font-weight: 700;
color: #6d3b00;
padding-left: 8rpx;
}
.pc-pattern-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24rpx;
}
.pc-pattern-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 28rpx 12rpx;
border-radius: @radius-lg;
background: rgba(255, 255, 255, 0.6);
border: 4rpx solid rgba(179, 172, 159, 0.1);
box-shadow: @shadow;
transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s,
background 0.12s;
}
.pc-pattern-card--active {
background: #ffffff;
border-color: @brand;
box-shadow: @shadow;
}
.pc-pattern-card--pressed {
transform: scale(0.95);
}
.pc-pattern-card__name {
font-size: 28rpx;
font-weight: 700;
color: @text-title;
line-height: 1.3;
}
.pc-pattern-card__cat {
font-size: 20rpx;
color: @text-secondary;
text-align: center;
line-height: 1.3;
text-transform: capitalize;
}
.pc-pattern-card--active .pc-pattern-card__name {
color: @text-title;
}
.pc-pattern-card__check {
position: absolute;
top: 50%;
left: 18rpx;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 32rpx;
height: 32rpx;
border-radius: 50%;
background-color: #9cd343;
}
.pc-shuffle-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 24rpx;
border-radius: 24rpx;
background: #f8f0e0;
}
.pc-shuffle-btn--hover {
opacity: 0.85;
}
.pc-shuffle-btn__text {
font-size: 28rpx;
font-weight: 600;
color: @text-secondary;
}
@@ -0,0 +1,235 @@
import PenControlDrawService from './draw/penControlDrawService';
import {
PEN_CONTROL_PATTERNS,
pickRandomPatternIds,
} from './data/penControlPatterns';
import {
DEFAULT_PATTERN_COUNT,
generatePenControlMix,
MAX_PATTERNS,
} from './generators/penControlGenerator';
import {
getModeInfo,
getPublishMetaByMode,
isValidMode,
PEN_CONTROL_WORKSHEET_ID,
} from './penControlSheet.config';
import { createPage, type CanvasDataState } from '../../base/pageMixin';
import { defaultShareConfig } from '../../config/config';
import type { DebugPublishMeta } from '../../utils/debugPublish';
import {
addFavorite,
removeFavorite,
batchCheckFavorited,
} from '../../utils/favorites';
const pageInfoLookup = getModeInfo;
function pickRandomPatternSet(count: number, current: string[]): string[] {
const next = pickRandomPatternIds(count, current);
if (next.length >= count) return next.slice(0, count);
return pickRandomPatternIds(count);
}
function buildSelectedMap(ids: string[]): Record<string, boolean> {
const map: Record<string, boolean> = {};
for (const id of ids) map[id] = true;
return map;
}
type PageData = CanvasDataState & {
worksheetId: string;
selectedPatternIds: string[];
selectedMap: Record<string, boolean>;
patternList: typeof PEN_CONTROL_PATTERNS;
isPreviewFavorite: boolean;
isDevEnv: boolean;
debugPublishVisible: boolean;
debugPublishLoading: boolean;
debugPublishMeta: DebugPublishMeta | null;
};
createPage(
{
canvas: null as Canvas | null,
ctx: null as RenderingContext | null,
boxHeight: 0,
boxWidth: 0,
drawService: null as PenControlDrawService | null,
_favoritedMap: {} as Record<string, boolean>,
data: {
pageTitle: '控笔组合练习',
functionId: PEN_CONTROL_WORKSHEET_ID,
hasContent: false,
showShareDialog: false,
worksheetId: PEN_CONTROL_WORKSHEET_ID,
selectedPatternIds: [] as string[],
selectedMap: {} as Record<string, boolean>,
patternList: PEN_CONTROL_PATTERNS,
isPreviewFavorite: false,
isDevEnv: false,
debugPublishVisible: false,
debugPublishLoading: false,
debugPublishMeta: null,
} as unknown as PageData,
onLoad(options: { id?: string }) {
this.syncDebugPublishEnv();
console.log('PEN_CONTROL_PATTERNS', PEN_CONTROL_PATTERNS);
const worksheetId =
options.id && isValidMode(options.id)
? options.id
: PEN_CONTROL_WORKSHEET_ID;
const initialPatterns = PEN_CONTROL_PATTERNS.slice(
0,
DEFAULT_PATTERN_COUNT,
).map((p) => p.id);
console.log('initialPatterns', initialPatterns);
this.setData({
worksheetId,
functionId: worksheetId,
selectedPatternIds: initialPatterns,
selectedMap: buildSelectedMap(initialPatterns),
});
this.initPageInfo(worksheetId, '控笔练习');
this.loadFavoritedMap();
},
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
this.initCanvasFromComponent(e.detail, {
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
opts?: Record<string, unknown>,
) => new PenControlDrawService(canvas, ctx, opts),
drawServiceOptions: {
title: this.data.pageTitle,
},
onCanvasReady: () => {
this.drawCanvas();
},
});
},
async drawCanvas() {
if (!this.drawService) return;
try {
const layout = (
this.drawService as PenControlDrawService
).buildLayoutForGenerate();
const data = generatePenControlMix({
patternIds: this.data.selectedPatternIds,
patternCount: this.data.selectedPatternIds.length,
layout,
});
const ids = data.blocks.map((b) => b.patternId);
if (ids.join(',') !== this.data.selectedPatternIds.join(',')) {
this.setData({
selectedPatternIds: ids,
selectedMap: buildSelectedMap(ids),
});
}
await (this.drawService as PenControlDrawService).draw(data);
this.setData({ hasContent: true });
} catch (e) {
console.error('penControlSheet draw failed', e);
this.setData({ hasContent: false });
}
},
onTogglePattern(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string;
if (!id) return;
let ids = [...this.data.selectedPatternIds];
const idx = ids.indexOf(id);
if (idx >= 0) {
ids.splice(idx, 1);
} else {
if (ids.length >= MAX_PATTERNS) {
wx.showToast({
title: `最多选择 ${MAX_PATTERNS}`,
icon: 'none',
});
return;
}
ids.push(id);
}
this.setData(
{
selectedPatternIds: ids,
selectedMap: buildSelectedMap(ids),
},
() => this.drawCanvas(),
);
},
onPreviewRefresh() {
const count =
this.data.selectedPatternIds.length || DEFAULT_PATTERN_COUNT;
const next = pickRandomPatternSet(
count,
this.data.selectedPatternIds,
);
this.setData(
{
selectedPatternIds: next,
selectedMap: buildSelectedMap(next),
},
() => this.drawCanvas(),
);
},
onShufflePatterns() {
this.onPreviewRefresh();
},
async onPreviewFavorite() {
const next = !this.data.isPreviewFavorite;
this.setData({ isPreviewFavorite: next });
const id = this.data.worksheetId;
if (id) {
this._favoritedMap[id] = next;
if (next) {
addFavorite(id);
} else {
removeFavorite(id);
}
}
wx.showToast({
title: next ? '收藏成功' : '已取消收藏',
icon: 'none',
});
},
async loadFavoritedMap() {
const ids = [PEN_CONTROL_WORKSHEET_ID];
this._favoritedMap = await batchCheckFavorited(ids);
if (this._favoritedMap[this.data.worksheetId]) {
this.setData({ isPreviewFavorite: true });
}
},
getPublishMeta(): DebugPublishMeta {
const meta = getPublishMetaByMode(this.data.worksheetId);
if (!meta) {
throw new Error('当前题型配置不存在');
}
return meta;
},
},
{
shareConfig: defaultShareConfig,
pageInfoLookup,
},
);
@@ -0,0 +1,77 @@
<nav-bar title="控笔组合练习" />
<view class="pc-page">
<view class="pc-main">
<preview-card
id="previewCard"
showRefresh="{{true}}"
showFavorite="{{true}}"
favorited="{{isPreviewFavorite}}"
bind:canvas-ready="onCanvasReady"
bind:refresh="onPreviewRefresh"
bind:favorite="onPreviewFavorite" />
<view class="pc-section">
<view class="pc-section-header">
<text class="pc-section-title"
>选择图形 ({{selectedPatternIds.length}}/5)</text
>
</view>
<view class="pc-pattern-grid">
<view
wx:for="{{patternList}}"
wx:key="id"
class="pc-pattern-card {{selectedMap[item.id] ? 'pc-pattern-card--active' : ''}}"
data-id="{{item.id}}"
hover-class="pc-pattern-card--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onTogglePattern">
<view
wx:if="{{selectedMap[item.id]}}"
class="pc-pattern-card__check">
<toy-icon name="check" size="20rpx" color="#fff" />
</view>
<text class="pc-pattern-card__name">{{item.name}}</text>
<text class="pc-pattern-card__cat"
>{{item.categoryLabel}}</text
>
</view>
</view>
</view>
<view
class="pc-shuffle-btn"
hover-class="pc-shuffle-btn--hover"
hover-start-time="0"
hover-stay-time="70"
bindtap="onShufflePatterns">
<toy-icon
name="refresh"
size="40rpx"
color="#605b50"
custom-class="pc-shuffle-btn__icon" />
<text class="pc-shuffle-btn__text">换一批图形</text>
</view>
</view>
</view>
<preview-footer-actions
disabled="{{!hasContent}}"
bind:primary="exportToPrint"
bind:secondary="onShare" />
<debug-publish-tools
wx:if="{{isDevEnv && hasContent}}"
id="debugPublishTools"
visible="{{debugPublishVisible}}"
loading="{{debugPublishLoading}}"
meta="{{debugPublishMeta}}"
bind:open="onOpenDebugPublish"
bind:close="onCloseDebugPublish"
bind:confirm="onConfirmDebugPublish" />
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
@@ -0,0 +1,103 @@
import { TRACING_COLORS } from '../../../core/data/tracingStyles';
import { drawSvgPath } from '../../shared/drawUtils';
import type {
PenControlPattern,
PenControlRenderMode,
} from '../data/penControlPatterns';
export const PEN_CONTROL_VIEW_SIZE = 100;
export interface PenControlTransform {
xOffset: number;
yOffset: number;
scale: number;
}
export function getPenControlTransform(
width: number,
height: number,
padding: number,
): PenControlTransform {
const availableWidth = width - 2 * padding;
const availableHeight = height - 2 * padding;
const scale = Math.min(
availableWidth / PEN_CONTROL_VIEW_SIZE,
availableHeight / PEN_CONTROL_VIEW_SIZE,
);
const scaledW = PEN_CONTROL_VIEW_SIZE * scale;
const scaledH = PEN_CONTROL_VIEW_SIZE * scale;
const xOffset = padding + (availableWidth - scaledW) / 2;
const yOffset = padding + (availableHeight - scaledH) / 2;
return { xOffset, yOffset, scale };
}
export type PenControlCellStyle = 'reference' | 'guide';
function colorForStyle(style: PenControlCellStyle): string {
return style === 'reference' ? TRACING_COLORS.strong : TRACING_COLORS.guide;
}
function drawPathWithMode(
ctx: RenderingContext,
pathD: string,
render: PenControlRenderMode,
color: string,
lineWidth: number,
dashed: boolean,
) {
ctx.beginPath();
drawSvgPath(ctx, pathD);
if (render === 'fill' || render === 'both') {
ctx.fillStyle = color;
ctx.fill();
}
if (render === 'stroke' || render === 'both') {
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
if (dashed) {
// setLineDash 接收一个数组,表示虚线的样式。
// 第一个参数 (lineWidth * 2.6):表示每段实线的长度
// 第二个参数 (lineWidth * 2.4):表示每段虚线的间隙长度
// 旧虚线效果不明显,调整为更清晰的虚线样式
ctx.setLineDash([lineWidth * 1.8, lineWidth * 2.3]);
} else {
ctx.setLineDash([]);
}
ctx.stroke();
}
}
/**
* 在田字格中心绘制控笔图形
*/
export function drawPenControlInCell(
ctx: RenderingContext,
pattern: PenControlPattern,
cx: number,
cy: number,
cellSize: number,
style: PenControlCellStyle,
options?: { dashed?: boolean },
) {
const color = colorForStyle(style);
const padding = cellSize * 0.1;
const transform = getPenControlTransform(cellSize, cellSize, padding);
const lineWidth =
(cellSize * 0.0236 * (pattern.strokeScale ?? 1)) / transform.scale;
const dashed = options?.dashed === true;
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.translate(cx - cellSize / 2, cy - cellSize / 2);
ctx.translate(transform.xOffset, transform.yOffset);
ctx.scale(transform.scale, transform.scale);
for (const pathD of pattern.paths) {
drawPathWithMode(ctx, pathD, pattern.render, color, lineWidth, dashed);
}
ctx.setLineDash([]);
ctx.restore();
}
+7 -2
View File
@@ -42,10 +42,10 @@ export function getScalingTransform(
}
/**
* SVG 路径解析与绘制(支持 M/L/Q/Z 命令)
* SVG 路径解析与绘制(支持 M/L/Q/C/Z 命令)
*/
export function drawSvgPath(ctx: RenderingContext, pathD: string) {
const commandRegex = /([MLQZ])([^MLQZ]*?)(?=[MLQZ]|$)/gi;
const commandRegex = /([MLQCZ])([^MLQCZ]*?)(?=[MLQCZ]|$)/gi;
const commands: Array<{ cmd: string; coords: string }> = [];
let match;
while ((match = commandRegex.exec(pathD)) !== null) {
@@ -69,6 +69,11 @@ export function drawSvgPath(ctx: RenderingContext, pathD: string) {
case 'Q':
if (v.length >= 4) ctx.quadraticCurveTo(v[0], v[1], v[2], v[3]);
break;
case 'C':
if (v.length >= 6) {
ctx.bezierCurveTo(v[0], v[1], v[2], v[3], v[4], v[5]);
}
break;
case 'Z':
ctx.closePath();
break;