feat: 英文字母描红开发

This commit is contained in:
R524809
2026-04-03 15:21:04 +08:00
parent 206c44cf6d
commit 1ef6770810
37 changed files with 1812 additions and 179 deletions
@@ -0,0 +1,13 @@
{
"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"
}
}
@@ -0,0 +1,307 @@
@import '../../style/theme3.less';
@lt-share-green: #9cd343;
page {
background-color: @bg-page;
}
.lt-page {
min-height: 100vh;
padding: 0 @page-padding-x;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.lt-main {
padding-top: 24rpx;
display: flex;
flex-direction: column;
gap: @section-gap;
}
/* ===== 预览卡 ===== */
.lt-preview-wrap {
position: relative;
}
.lt-preview-card {
background: @bg-white;
border-radius: @radius;
box-shadow: @shadow;
padding: 48rpx @page-padding-inner-x 32rpx;
border: @border;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: stretch;
}
.lt-preview-kicker {
display: block;
text-align: center;
font-size: 22rpx;
font-weight: 700;
color: @text-gray;
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 24rpx;
}
.lt-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;
}
.lt-canvas {
max-width: 100%;
border-radius: @radius-sm;
box-shadow: @shadow;
}
/* ===== 参数面板 ===== */
.lt-params {
background: @bg-header;
border-radius: @radius;
padding: 40rpx @page-padding-inner-x;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 40rpx;
border: @border;
box-shadow: @shadow;
}
.lt-params-head {
display: flex;
flex-direction: row;
align-items: center;
gap: 12rpx;
}
.lt-params-emoji {
font-size: 36rpx;
line-height: 1;
}
.lt-params-title {
font-size: @fs-section-head-title;
font-weight: 700;
color: @text-title;
letter-spacing: @ls-nav-title;
}
.lt-field-label {
display: block;
font-size: 26rpx;
font-weight: 700;
color: @text-secondary;
margin-bottom: 16rpx;
margin-left: 4rpx;
}
/* ===== 练习类型:2×2 网格选择器 ===== */
.lt-mode-seg {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
}
.lt-mode-item {
display: flex;
align-items: center;
justify-content: center;
padding: 24rpx 16rpx;
border-radius: @radius-sm;
background: @bg-white;
border: 2rpx solid rgba(50, 46, 37, 0.1);
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.04);
transition: all 0.2s ease;
}
.lt-mode-item--active {
background: @brand;
border-color: rgba(255, 215, 9, 0.55);
box-shadow: @shadow;
}
.lt-mode-item__text {
font-size: 26rpx;
font-weight: 600;
color: @text-secondary;
line-height: 1.35;
text-align: center;
}
.lt-mode-item--active .lt-mode-item__text {
color: @text-selected-btn;
font-weight: 700;
}
/* ===== 字母选择网格:7 列 ===== */
.lt-letter-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 12rpx;
}
.lt-letter-cell {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: @radius-sm;
background: @bg-white;
border: 2rpx solid rgba(50, 46, 37, 0.1);
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.04);
transition: all 0.15s ease;
}
.lt-letter-cell--active {
background: @brand;
border-color: rgba(255, 215, 9, 0.55);
box-shadow: @shadow;
transform: scale(1.05);
}
.lt-letter-text {
font-size: 32rpx;
font-weight: 700;
color: @text-secondary;
}
.lt-letter-cell--active .lt-letter-text {
color: @text-selected-btn;
}
/* ===== 大小写切换 ===== */
.lt-chip-row {
display: flex;
flex-direction: row;
gap: 16rpx;
}
.lt-chip {
flex: 1;
text-align: center;
padding: 24rpx 20rpx;
font-size: 28rpx;
font-weight: 600;
color: @text-secondary;
background: @bg-white;
border-radius: @radius-sm;
border: 2rpx solid rgba(50, 46, 37, 0.1);
box-shadow: 0 2rpx 8rpx rgba(50, 46, 37, 0.04);
}
.lt-chip--active {
background: @brand;
color: @text-selected-btn;
font-weight: 700;
border-color: rgba(255, 215, 9, 0.55);
box-shadow: @shadow;
}
/* ===== 换一批 ===== */
.lt-shuffle-btn {
width: 100%;
padding: 28rpx 24rpx;
margin: 0;
border-radius: @radius;
border: 6rpx solid @bg-header;
background: transparent;
font-size: 32rpx;
font-weight: 700;
color: @text-secondary;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
box-sizing: border-box;
&::after {
border: none;
}
}
.lt-shuffle-btn:active {
background: @bg-header;
transform: scale(0.98);
}
/* ===== 底栏 ===== */
.lt-bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: flex;
flex-direction: row;
align-items: center;
gap: 20rpx;
padding: 24rpx @page-padding-x;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: @border;
box-shadow: 0 -8rpx 32rpx rgba(50, 46, 37, 0.06);
box-sizing: border-box;
}
.lt-bottom-share,
.lt-bottom-save {
margin: 0;
height: 96rpx;
line-height: 96rpx;
border-radius: @radius-sm;
font-size: 30rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 0;
box-sizing: border-box;
&::after {
border: none;
}
}
.lt-bottom-share {
flex: 0 0 35%;
width: 35%;
background: @lt-share-green;
color: @text-white;
box-shadow: @shadow;
}
.lt-bottom-save {
flex: 1;
min-width: 0;
background: @brand;
color: @text-selected-btn;
font-weight: 800;
box-shadow: @shadow-lg;
}
.lt-bottom-save[disabled] {
opacity: 0.45;
}
.lt-bottom-icon {
font-size: 36rpx;
line-height: 1;
}
.lt-spacer-bottom {
height: 24rpx;
}
@@ -0,0 +1,197 @@
import LetterTracingDraw from '../shared/service/letterTracingDraw';
import { createPage, type CanvasDataState } from '../../base/pageMixin';
import { defaultShareConfig } from '../../config/config';
import { getWorksheetById } from '../../core/data/worksheets';
import {
generateLetterTracing,
mergeLetterTracingConfig,
type LetterTracingGeneratorConfig,
type LetterTracingMode,
} from '../../core/generators/letter-tracing-generator';
import { LETTERS_UPPER } from '../../core/data/alphabet';
const PAGE_META: Record<string, { title: string; desc: string }> = {
'letter-tracing-single': {
title: '字母学习页',
desc: '单字母配图与描红',
},
'letter-tracing-full': {
title: '26 字母描红',
desc: '整页字母书写练习',
},
'letter-tracing-upper-lower': {
title: '大小写字母对照',
desc: '一页认识 A-Z 与 a-z',
},
'letter-tracing-two-column': {
title: '字母描红(两列)',
desc: 'AM / NZ 两列练习',
},
};
function pageInfoLookup(id: string) {
return PAGE_META[id];
}
const MODE_OPTIONS = [
{ id: 'letter-tracing-single', label: '单字母学习页' },
{ id: 'letter-tracing-full', label: '整页描红' },
{ id: 'letter-tracing-upper-lower', label: '大小写对照' },
{ id: 'letter-tracing-two-column', label: '两列描红' },
] as const;
type PageData = CanvasDataState & {
worksheetId: string;
traceMode: LetterTracingMode;
selectedLetter: string;
letterCaseLower: boolean;
showLetterPicker: boolean;
showCaseToggle: boolean;
showNextLetter: boolean;
letterGrid: string[];
modeOptions: ReadonlyArray<{ id: string; label: string }>;
};
createPage(
{
canvas: null as Canvas | null,
ctx: null as RenderingContext | null,
boxHeight: 0,
boxWidth: 0,
drawService: null as LetterTracingDraw | null,
data: {
pageTitle: '字母描红',
functionId: '',
hasContent: false,
showShareDialog: false,
worksheetId: 'letter-tracing-single',
traceMode: 'single-letter',
selectedLetter: 'A',
letterCaseLower: false,
showLetterPicker: true,
showCaseToggle: false,
showNextLetter: true,
letterGrid: LETTERS_UPPER,
modeOptions: [...MODE_OPTIONS],
} as unknown as PageData,
onLoad(options: { id?: string }) {
const worksheetId =
options.id && PAGE_META[options.id]
? options.id
: 'letter-tracing-single';
const def = getWorksheetById(worksheetId);
const base = (def?.generatorConfig ??
{}) as Partial<LetterTracingGeneratorConfig>;
const merged = mergeLetterTracingConfig(base, {});
const traceMode = merged.mode;
const showLetterPicker = traceMode === 'single-letter';
const showCaseToggle =
traceMode === 'full-alphabet' || traceMode === 'two-column';
const showNextLetter = traceMode === 'single-letter';
this.setData({
worksheetId,
functionId: worksheetId,
traceMode,
selectedLetter: normalizePick(merged.selectedLetter),
letterCaseLower: merged.letterCase === 'lower',
showLetterPicker,
showCaseToggle,
showNextLetter,
});
this.initPageInfo(worksheetId, PAGE_META[worksheetId]?.title ?? '字母描红');
},
onSelectMode(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string | undefined;
if (!id || id === this.data.worksheetId) return;
wx.redirectTo({
url: `/englishPages/letterTracing/letterTracing?id=${id}`,
});
},
onReady() {
this.initCanvas({
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
) => new LetterTracingDraw(canvas, ctx, options),
drawServiceOptions: {
title: this.data.pageTitle,
subTitle: this.data.subTitle || '',
},
onCanvasReady: () => {
this.drawCanvas();
},
});
},
buildRuntimeConfig(): LetterTracingGeneratorConfig {
const def = getWorksheetById(this.data.worksheetId);
const base = (def?.generatorConfig ??
{}) as Partial<LetterTracingGeneratorConfig>;
const letterCase =
this.data.traceMode === 'single-letter'
? (base.letterCase ?? 'upper')
: this.data.letterCaseLower
? 'lower'
: 'upper';
return mergeLetterTracingConfig(base, {
selectedLetter: this.data.selectedLetter,
letterCase,
});
},
async drawCanvas() {
if (!this.ctx || !this.drawService) return;
try {
const config = this.buildRuntimeConfig();
const data = generateLetterTracing(config);
await (this.drawService as LetterTracingDraw).draw(data);
this.setData({ hasContent: true });
} catch (e) {
console.error('letterTracing draw failed', e);
this.setData({ hasContent: false });
}
},
onSelectLetter(e: WechatMiniprogram.TouchEvent) {
const letter = e.currentTarget.dataset.letter as string;
if (!letter) return;
this.setData({ selectedLetter: letter });
this.drawCanvas();
},
onSelectCaseUpper() {
this.setData({ letterCaseLower: false });
this.drawCanvas();
},
onSelectCaseLower() {
this.setData({ letterCaseLower: true });
this.drawCanvas();
},
onNextLetter() {
const list = LETTERS_UPPER;
const i = list.indexOf(this.data.selectedLetter);
const next = list[(i + 1) % list.length];
this.setData({ selectedLetter: next });
this.drawCanvas();
},
},
{
shareConfig: defaultShareConfig,
pageInfoLookup,
},
);
function normalizePick(c?: string): string {
if (!c || c.length === 0) return 'A';
return c.trim().toUpperCase().charAt(0);
}
@@ -0,0 +1,100 @@
<nav-bar title="英语字母描红" />
<view class="lt-page">
<view class="lt-main">
<view class="lt-preview-wrap">
<view class="lt-preview-card">
<text class="lt-preview-kicker">打印预览</text>
<view id="canvasWrapper" class="lt-canvas-wrap">
<canvas
type="2d"
id="canvasContent"
class="lt-canvas"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
</view>
</view>
</view>
<view class="lt-params">
<view class="lt-params-head">
<text class="lt-params-emoji">⚙️</text>
<text class="lt-params-title">参数设置</text>
</view>
<view class="param-block">
<text class="lt-field-label">练习类型</text>
<view class="lt-mode-seg">
<view
wx:for="{{modeOptions}}"
wx:key="id"
class="lt-mode-item {{worksheetId === item.id ? 'lt-mode-item--active' : ''}}"
data-id="{{item.id}}"
bindtap="onSelectMode">
<text class="lt-mode-item__text">{{item.label}}</text>
</view>
</view>
</view>
<view wx:if="{{showLetterPicker}}" class="param-block">
<text class="lt-field-label">选择字母</text>
<view class="lt-letter-grid">
<view
wx:for="{{letterGrid}}"
wx:key="*this"
class="lt-letter-cell {{selectedLetter === item ? 'lt-letter-cell--active' : ''}}"
data-letter="{{item}}"
bind:tap="onSelectLetter">
<text class="lt-letter-text">{{item}}</text>
</view>
</view>
</view>
<view wx:if="{{showCaseToggle}}" class="param-block">
<text class="lt-field-label">大小写</text>
<view class="lt-chip-row">
<view
class="lt-chip {{!letterCaseLower ? 'lt-chip--active' : ''}}"
bind:tap="onSelectCaseUpper">
大写 A–Z
</view>
<view
class="lt-chip {{letterCaseLower ? 'lt-chip--active' : ''}}"
bind:tap="onSelectCaseLower">
小写 a–z
</view>
</view>
</view>
</view>
<button
wx:if="{{showNextLetter}}"
hover-class="none"
class="lt-shuffle-btn"
bindtap="onNextLetter">
🔄 换一批
</button>
<draw-ad type="mathDraw"></draw-ad>
<view class="lt-spacer-bottom"></view>
</view>
</view>
<view class="lt-bottom-bar">
<button class="lt-bottom-share" hover-class="none" open-type="share">
<text class="lt-bottom-icon">↗</text>
分享
</button>
<button
class="lt-bottom-save"
hover-class="none"
disabled="{{!hasContent}}"
bindtap="exportToPrint">
<text class="lt-bottom-icon">⬇</text>
保存到相册
</button>
</view>
<share-guide-popup
show="{{showShareDialog}}"
bind:onClose="onCloseShareDialog"
bind:onShareSuccess="onShareSuccess" />
@@ -0,0 +1,249 @@
import { BaseDrawService } from '../../../service/baseDraw';
import type {
LetterTracingData,
TracingRow,
} from '../../../core/generators/letter-tracing-generator';
const M = 24;
const BLUE = '#4A90E2';
const ORANGE = '#FF8C42';
/**
* 字母描红绘制(tracing-writing + letter-tracing 的客户端实现)
*/
export default class LetterTracingDraw extends BaseDrawService {
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, unknown>,
) {
super(canvas, ctx, options);
}
async draw(data: LetterTracingData) {
this.prepareDraw();
await this.drawHeaderAndDivider();
switch (data.mode) {
case 'single-letter':
this.drawSingleLetter(data);
break;
case 'full-alphabet':
this.drawFullAlphabet(data.rows);
break;
case 'upper-lower-split':
this.drawUpperLowerSplit(data.upperGlyphs, data.lowerGlyphs);
break;
case 'two-column':
this.drawTwoColumn(data.leftRows, data.rightRows);
break;
}
}
/** 四线三格:顶线、中虚线、基线、底线 */
private drawFourLines(x: number, y: number, w: number, h: number) {
const ctx = this.ctx;
const yTop = y;
const yMid = y + h * 0.28;
const yBase = y + h * 0.62;
const yBot = y + h;
ctx.strokeStyle = BLUE;
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(x, yTop);
ctx.lineTo(x + w, yTop);
ctx.stroke();
ctx.strokeStyle = ORANGE;
ctx.setLineDash([6, 6]);
ctx.beginPath();
ctx.moveTo(x, yMid);
ctx.lineTo(x + w, yMid);
ctx.stroke();
ctx.strokeStyle = BLUE;
ctx.setLineDash([]);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(x, yBase);
ctx.lineTo(x + w, yBase);
ctx.stroke();
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, yBot);
ctx.lineTo(x + w, yBot);
ctx.stroke();
}
private drawCharInCell(
ch: string,
x: number,
y: number,
w: number,
h: number,
opacity: number,
bold: boolean,
) {
const ctx = this.ctx;
if (opacity <= 0) return;
const fontSize = Math.min(h * 0.72, w * 0.85);
ctx.save();
ctx.globalAlpha = opacity;
ctx.fillStyle = bold ? '#111' : '#666';
ctx.font = `${bold ? 'bold ' : ''}${fontSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
const baselineY = y + h * 0.62;
ctx.fillText(ch, x + w / 2, baselineY);
ctx.restore();
}
private drawTracingRow(
row: TracingRow,
startX: number,
y: number,
rowH: number,
totalWidth: number,
) {
const gap = 4;
const n = row.cells.length;
const cellW = (totalWidth - gap * (n - 1)) / n;
for (let i = 0; i < n; i++) {
const cx = startX + i * (cellW + gap);
this.drawFourLines(cx, y, cellW, rowH);
const cell = row.cells[i];
this.drawCharInCell(
cell.char,
cx,
y,
cellW,
rowH,
cell.opacity,
cell.isGuide,
);
}
}
private drawSingleLetter(
data: Extract<LetterTracingData, { mode: 'single-letter' }>,
) {
const ctx = this.ctx;
const { teaching, rows } = data;
const cw = this.canvasWidth;
let y = this.currentY + 8;
const teachH = Math.min(220, (this.canvasHeight - y) * 0.42);
// 教学区背景
ctx.fillStyle = '#f8fafc';
ctx.fillRect(M, y, cw - M * 2, teachH);
// 左侧 emoji
ctx.font = `${Math.min(88, teachH * 0.45)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(teaching.emoji, M + (cw - M * 2) * 0.22, y + teachH / 2);
// 右侧文字
const tx = M + (cw - M * 2) * 0.42;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillStyle = '#111';
ctx.font = 'bold 44px sans-serif';
ctx.fillText(`${teaching.upper} ${teaching.lower}`, tx, y + 16);
ctx.font = '22px sans-serif';
ctx.fillStyle = '#333';
ctx.fillText(teaching.sentence, tx, y + 72);
ctx.fillStyle = '#666';
ctx.font = '18px sans-serif';
ctx.fillText(teaching.word, tx, y + 104);
y += teachH + 12;
this.drawDashedDivider(y, M, '#ccc');
y += 14;
const rest = this.canvasHeight - y - M;
const rowH = Math.max(36, (rest - 8) / rows.length);
const rowWidth = cw - M * 2;
for (let r = 0; r < rows.length; r++) {
this.drawTracingRow(rows[r], M, y + r * (rowH + 10), rowH, rowWidth);
}
}
private drawFullAlphabet(rows: TracingRow[]) {
const y0 = this.currentY + 6;
const bottom = this.canvasHeight - M;
const totalH = bottom - y0;
const rowH = Math.max(14, totalH / rows.length - 2);
const rowWidth = this.canvasWidth - M * 2;
for (let i = 0; i < rows.length; i++) {
const y = y0 + i * (rowH + 2);
this.drawTracingRow(rows[i], M, y, rowH, rowWidth);
}
}
private drawUpperLowerSplit(upper: string[], lower: string[]) {
const ctx = this.ctx;
const cw = this.canvasWidth;
let y = this.currentY + 6;
const innerW = cw - M * 2;
const drawBlockTitle = (t: string) => {
ctx.fillStyle = '#111';
ctx.font = 'bold 18px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(t, M, y);
y += 26;
};
const drawGlyphGrid = (chars: string[], cols: number) => {
const rows = Math.ceil(chars.length / cols);
const cellW = (innerW - (cols - 1) * 4) / cols;
const cellH = 32;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const idx = r * cols + c;
if (idx >= chars.length) break;
const cx = M + c * (cellW + 4);
this.drawFourLines(cx, y, cellW, cellH);
this.drawCharInCell(chars[idx], cx, y, cellW, cellH, 0.85, true);
}
y += cellH + 6;
}
};
drawBlockTitle('大写字母 Uppercase');
drawGlyphGrid(upper, 13);
y += 8;
this.drawDashedDivider(y, M, '#bbb');
y += 14;
drawBlockTitle('小写字母 Lowercase');
drawGlyphGrid(lower, 13);
}
private drawTwoColumn(leftRows: TracingRow[], rightRows: TracingRow[]) {
const y0 = this.currentY + 6;
const bottom = this.canvasHeight - M;
const totalH = bottom - y0;
const maxRows = Math.max(leftRows.length, rightRows.length);
const rowH = Math.max(16, totalH / maxRows - 3);
const gapCol = 16;
const colW = (this.canvasWidth - M * 2 - gapCol) / 2;
for (let i = 0; i < leftRows.length; i++) {
const y = y0 + i * (rowH + 3);
this.drawTracingRow(leftRows[i], M, y, rowH, colW);
}
const xR = M + colW + gapCol;
for (let i = 0; i < rightRows.length; i++) {
const y = y0 + i * (rowH + 3);
this.drawTracingRow(rightRows[i], xR, y, rowH, colW);
}
}
}