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" />