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
+17 -2
View File
@@ -4,10 +4,15 @@
"pages/age/age",
"pages/favorites/favorites",
"pages/profile/profile",
"pages/guide/guide",
"pages/index/index"
],
"subPackages": [
{
"root": "supportPages",
"name": "supportPages",
"pages": ["guide/guide", "debug/debug"],
"independent": false
},
{
"root": "mathPages",
"name": "mathPages",
@@ -31,6 +36,12 @@
],
"independent": false
},
{
"root": "englishPages",
"name": "englishPages",
"pages": ["letterTracing/letterTracing"],
"independent": false
},
{
"root": "focusPages",
"name": "focusPages",
@@ -53,7 +64,11 @@
"preloadRule": {
"pages/home/home": {
"network": "all",
"packages": ["mathPages", "focusPages"]
"packages": ["mathPages", "focusPages", "englishPages"]
},
"pages/profile/profile": {
"network": "all",
"packages": ["supportPages"]
}
},
"window": {},
@@ -0,0 +1,3 @@
{
"component": true
}
@@ -0,0 +1,44 @@
@import '../../style/theme3.less';
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: fade(@bg-header, 85%);
backdrop-filter: blur(40rpx);
-webkit-backdrop-filter: blur(40rpx);
box-shadow: 0 2rpx 12rpx rgba(50, 46, 37, 0.05);
}
.nav-bar__inner {
height: @nav-inner-height;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
padding: 0 @page-padding-x;
box-sizing: border-box;
}
.nav-bar__title {
font-size: @fs-nav-title;
font-weight: @fw-nav-title;
color: @color-nav-title;
letter-spacing: @ls-nav-title;
flex: 1;
min-width: 0;
}
.nav-bar__right {
display: flex;
flex-direction: row;
align-items: center;
margin-left: auto;
}
.nav-bar__placeholder {
width: 100%;
flex-shrink: 0;
}
@@ -0,0 +1,22 @@
import { NAV_INNER_PX } from '../../utils/navMetrics';
Component({
options: { multipleSlots: true },
properties: {
title: { type: String, value: '' },
background: { type: String, value: '' },
},
data: {
statusBarHeight: 0,
navBlockHeight: 0,
},
lifetimes: {
attached() {
const { statusBarHeight } = wx.getWindowInfo();
this.setData({
statusBarHeight,
navBlockHeight: statusBarHeight + NAV_INNER_PX,
});
},
},
});
@@ -0,0 +1,10 @@
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px; {{background ? 'background:' + background + ';' : ''}}">
<view class="nav-bar__inner">
<slot name="left"></slot>
<text class="nav-bar__title">{{title}}</text>
<view class="nav-bar__right">
<slot name="right"></slot>
</view>
</view>
</view>
<view class="nav-bar__placeholder" style="height: {{navBlockHeight}}px;"></view>
+39
View File
@@ -0,0 +1,39 @@
/**
* 26 字母与示例词(首版用 emoji 占位配图,后续可换 imageUrl)
*/
export interface AlphabetEntry {
word: string;
/** 与单词相关的 emoji,Canvas 上直接绘制,无需图片资源 */
emoji: string;
}
export const ALPHABET_BY_LETTER: Record<string, AlphabetEntry> = {
A: { word: 'Apple', emoji: '🍎' },
B: { word: 'Ball', emoji: '⚽' },
C: { word: 'Cat', emoji: '🐱' },
D: { word: 'Dog', emoji: '🐶' },
E: { word: 'Egg', emoji: '🥚' },
F: { word: 'Fish', emoji: '🐟' },
G: { word: 'Girl', emoji: '👧' },
H: { word: 'House', emoji: '🏠' },
I: { word: 'Ice', emoji: '🧊' },
J: { word: 'Juice', emoji: '🧃' },
K: { word: 'Kite', emoji: '🪁' },
L: { word: 'Lion', emoji: '🦁' },
M: { word: 'Moon', emoji: '🌙' },
N: { word: 'Nest', emoji: '🪺' },
O: { word: 'Orange', emoji: '🍊' },
P: { word: 'Pig', emoji: '🐷' },
Q: { word: 'Queen', emoji: '👑' },
R: { word: 'Rain', emoji: '🌧️' },
S: { word: 'Sun', emoji: '☀️' },
T: { word: 'Tree', emoji: '🌳' },
U: { word: 'Umbrella', emoji: '☂️' },
V: { word: 'Van', emoji: '🚐' },
W: { word: 'Water', emoji: '💧' },
X: { word: 'X-ray', emoji: '🦴' },
Y: { word: 'Yacht', emoji: '⛵' },
Z: { word: 'Zebra', emoji: '🦓' },
};
export const LETTERS_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
+138 -5
View File
@@ -254,10 +254,140 @@ export const ALL_WORKSHEETS: WorksheetDefinition[] = [
{ id: 'copybook', title: '练字帖', desc: '选字生成田字格笔顺练字帖', category: 'chinese', page: 'copyBook', subpackage: '', icon: '✏️', img: '', ageRange: [4, 7], difficulty: 'basic', previewBg: '#fff8e1', previewText: '横竖撇' },
{ subcategory: 'copybook', template: 'tracing-writing', generator: 'character-tracing', sortOrder: 41, generatorConfig: { functionId: 'copybook' }, layoutConfig: { ...L.trace } },
),
sheet(
{
id: 'letter-tracing-single',
title: '字母学习页',
desc: '单字母配图、阅读句与四线三格描红',
category: 'english',
page: 'letterTracing',
subpackage: 'englishPages',
icon: '🔠',
img: '',
ageRange: [4, 7],
difficulty: 'beginner',
previewBg: '#e8f4ff',
previewText: 'Aa',
},
{
subcategory: 'letter-tracing',
template: 'tracing-writing',
generator: 'letter-tracing',
sortOrder: 42,
layoutConfig: {
...L.trace,
instructionText: '看一看,读一读,再描一描',
},
generatorConfig: {
mode: 'single-letter',
letterCase: 'upper',
selectedLetter: 'A',
repetitions: 5,
fadePattern: 'gradient',
},
},
),
sheet(
{
id: 'letter-tracing-full',
title: '26 字母描红',
desc: '整页四线三格逐字母描红',
category: 'english',
page: 'letterTracing',
subpackage: 'englishPages',
icon: '🔤',
img: '',
ageRange: [4, 7],
difficulty: 'beginner',
previewBg: '#e8f4ff',
previewText: 'A→Z',
},
{
subcategory: 'letter-tracing',
template: 'tracing-writing',
generator: 'letter-tracing',
sortOrder: 43,
layoutConfig: { ...L.trace, instructionText: '沿虚线描写字母' },
generatorConfig: {
mode: 'full-alphabet',
letterCase: 'upper',
repetitions: 8,
fadePattern: 'gradient',
},
},
),
sheet(
{
id: 'letter-tracing-upper-lower',
title: '大小写字母对照',
desc: '上半部大写、下半部小写总览',
category: 'english',
page: 'letterTracing',
subpackage: 'englishPages',
icon: '🔡',
img: '',
ageRange: [4, 7],
difficulty: 'beginner',
previewBg: '#e8f4ff',
previewText: 'Aa Bb',
},
{
subcategory: 'letter-tracing',
template: 'tracing-writing',
generator: 'letter-tracing',
sortOrder: 44,
layoutConfig: {
...L.trace,
columns: 13,
instructionText: '认识大写和小写字母',
},
generatorConfig: {
mode: 'upper-lower-split',
letterCase: 'both',
repetitions: 1,
fadePattern: 'first-only',
},
},
),
sheet(
{
id: 'letter-tracing-two-column',
title: '字母描红(两列)',
desc: 'AM / NZ 两列练习',
category: 'english',
page: 'letterTracing',
subpackage: 'englishPages',
icon: '✏️',
img: '',
ageRange: [4, 7],
difficulty: 'beginner',
previewBg: '#e8f4ff',
previewText: 'A|N',
},
{
subcategory: 'letter-tracing',
template: 'tracing-writing',
generator: 'letter-tracing',
sortOrder: 45,
layoutConfig: {
...L.trace,
columns: 2,
instructionText: '沿虚线描写字母',
},
generatorConfig: {
mode: 'two-column',
letterCase: 'upper',
repetitions: 4,
fadePattern: 'gradient',
},
},
),
];
export const MATH_FUNCTION_TYPES: MathFunctionType[] = ALL_WORKSHEETS
.filter((w) => w.category === 'math')
export const MATH_FUNCTION_TYPES: MathFunctionType[] = ALL_WORKSHEETS.filter(
(w) => w.category === 'math',
)
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((w) => ({
id: w.id,
@@ -268,8 +398,9 @@ export const MATH_FUNCTION_TYPES: MathFunctionType[] = ALL_WORKSHEETS
img: w.img,
}));
export const FOCUS_FUNCTION_TYPES: FocusFunctionType[] = ALL_WORKSHEETS
.filter((w) => w.category === 'focus')
export const FOCUS_FUNCTION_TYPES: FocusFunctionType[] = ALL_WORKSHEETS.filter(
(w) => w.category === 'focus',
)
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((w) => ({
id: w.id,
@@ -281,7 +412,9 @@ export const FOCUS_FUNCTION_TYPES: FocusFunctionType[] = ALL_WORKSHEETS
img: w.img,
}));
export function getWorksheetsByCategory(category: string): WorksheetDefinition[] {
export function getWorksheetsByCategory(
category: string,
): WorksheetDefinition[] {
if (category === 'all') return ALL_WORKSHEETS;
return ALL_WORKSHEETS.filter((w) => w.category === category);
}
@@ -0,0 +1,172 @@
import {
ALPHABET_BY_LETTER,
LETTERS_UPPER,
} from '../data/alphabet';
export type LetterTracingMode =
| 'single-letter'
| 'full-alphabet'
| 'upper-lower-split'
| 'two-column';
export type LetterCaseMode = 'upper' | 'lower' | 'both';
export interface LetterTracingGeneratorConfig {
mode: LetterTracingMode;
letterCase: LetterCaseMode;
selectedLetter?: string;
repetitions: number;
fadePattern: 'gradient' | 'first-only';
}
export interface TracingCell {
char: string;
opacity: number;
isGuide: boolean;
}
export interface TracingRow {
displayChar: string;
cells: TracingCell[];
}
export interface TeachingBlock {
upper: string;
lower: string;
word: string;
sentence: string;
emoji: string;
}
export type LetterTracingData =
| {
mode: 'single-letter';
teaching: TeachingBlock;
rows: TracingRow[];
}
| {
mode: 'full-alphabet';
rows: TracingRow[];
}
| {
mode: 'upper-lower-split';
upperGlyphs: string[];
lowerGlyphs: string[];
}
| {
mode: 'two-column';
leftRows: TracingRow[];
rightRows: TracingRow[];
};
function normalizeLetter(c: string): string {
const u = c.trim().toUpperCase();
return u.length ? u[0] : 'A';
}
function buildCells(
char: string,
count: number,
fadePattern: 'gradient' | 'first-only',
): TracingCell[] {
const n = Math.max(1, Math.min(12, count));
const cells: TracingCell[] = [];
for (let i = 0; i < n; i++) {
let opacity: number;
if (fadePattern === 'first-only') {
opacity = i === 0 ? 1 : 0;
} else if (n <= 1) {
opacity = 1;
} else {
opacity = Math.max(0.12, 1 - i / (n - 1));
}
cells.push({ char, opacity, isGuide: i === 0 });
}
return cells;
}
function rowForChar(
ch: string,
repetitions: number,
fadePattern: 'gradient' | 'first-only',
): TracingRow {
return {
displayChar: ch,
cells: buildCells(ch, repetitions, fadePattern),
};
}
export function generateLetterTracing(
config: LetterTracingGeneratorConfig,
): LetterTracingData {
const { mode, letterCase, repetitions, fadePattern } = config;
if (mode === 'single-letter') {
const key = normalizeLetter(config.selectedLetter ?? 'A');
const meta = ALPHABET_BY_LETTER[key] ?? ALPHABET_BY_LETTER.A;
const upper = key;
const lower = key.toLowerCase();
const teaching: TeachingBlock = {
upper,
lower,
word: meta.word,
sentence: `${upper} is for ${meta.word}`,
emoji: meta.emoji,
};
const rows: TracingRow[] = [
rowForChar(upper, repetitions, fadePattern),
rowForChar(lower, repetitions, fadePattern),
];
return { mode: 'single-letter', teaching, rows };
}
if (mode === 'full-alphabet') {
const rows = LETTERS_UPPER.map((L) => {
const ch = letterCase === 'lower' ? L.toLowerCase() : L;
return rowForChar(ch, repetitions, fadePattern);
});
return { mode: 'full-alphabet', rows };
}
if (mode === 'upper-lower-split') {
return {
mode: 'upper-lower-split',
upperGlyphs: [...LETTERS_UPPER],
lowerGlyphs: LETTERS_UPPER.map((L) => L.toLowerCase()),
};
}
// two-column
const left = LETTERS_UPPER.slice(0, 13);
const right = LETTERS_UPPER.slice(13);
const mapChar = (L: string) =>
letterCase === 'lower' ? L.toLowerCase() : L;
const leftRows = left.map((L) =>
rowForChar(mapChar(L), repetitions, fadePattern),
);
const rightRows = right.map((L) =>
rowForChar(mapChar(L), repetitions, fadePattern),
);
return { mode: 'two-column', leftRows, rightRows };
}
/** 与 WorksheetDefinition.generatorConfig 合并后的运行时配置 */
export function mergeLetterTracingConfig(
base: Partial<LetterTracingGeneratorConfig>,
overrides: Partial<LetterTracingGeneratorConfig>,
): LetterTracingGeneratorConfig {
return {
mode: (overrides.mode ?? base.mode ?? 'single-letter') as LetterTracingMode,
letterCase: (overrides.letterCase ??
base.letterCase ??
'upper') as LetterCaseMode,
selectedLetter: overrides.selectedLetter ?? base.selectedLetter,
repetitions: Math.max(
1,
(overrides.repetitions ?? base.repetitions ?? 5) as number,
),
fadePattern: (overrides.fadePattern ??
base.fadePattern ??
'gradient') as 'gradient' | 'first-only',
};
}
@@ -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);
}
}
}
+3 -1
View File
@@ -4,5 +4,7 @@
"navigationBarBackgroundColor": "#FEF6E7",
"backgroundColor": "#FEF6E7",
"enablePullDownRefresh": false,
"usingComponents": {}
"usingComponents": {
"nav-bar": "../../components3.0/nav-bar/nav-bar"
}
}
-29
View File
@@ -15,35 +15,6 @@
box-sizing: border-box;
}
.age-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: fade(@bg-header, 85%);
backdrop-filter: blur(40rpx);
-webkit-backdrop-filter: blur(40rpx);
box-shadow: 0 2rpx 12rpx rgba(50, 46, 37, 0.05);
}
.age-nav__inner {
height: @nav-inner-height;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
padding: 0 @page-padding-x;
box-sizing: border-box;
}
.age-nav__title {
font-size: @fs-nav-title;
font-weight: @fw-nav-title;
color: @color-nav-title;
letter-spacing: @ls-nav-title;
}
.age-body {
width: 100%;
box-sizing: border-box;
-8
View File
@@ -1,5 +1,4 @@
import { AGE_BANDS, AgeBandKey } from '../../core/data/difficulty';
import { NAV_INNER_PX } from '../../utils/navMetrics';
type AbilityItem = {
icon: string;
@@ -186,8 +185,6 @@ const DEFAULT_AGE: AgeBandKey = '5-6';
Page({
data: {
statusBarHeight: 0,
navBlockHeight: 0,
ageTabs: AGE_BANDS.map((b) => ({
key: b.key,
rangeText: b.label.replace(/\s*岁\s*$/, ''),
@@ -201,11 +198,6 @@ Page({
},
onLoad() {
const win = wx.getWindowInfo();
this.setData({
statusBarHeight: win.statusBarHeight,
navBlockHeight: win.statusBarHeight + NAV_INNER_PX,
});
this.applyAge(DEFAULT_AGE);
},
+2 -6
View File
@@ -1,11 +1,7 @@
<view class="age-page">
<view class="age-nav" style="padding-top: {{statusBarHeight}}px;">
<view class="age-nav__inner">
<text class="age-nav__title">按年龄学</text>
</view>
</view>
<nav-bar title="按年龄学" />
<view class="age-body" style="padding-top: {{navBlockHeight}}px;">
<view class="age-body">
<scroll-view class="age-tab-scroll" scroll-x enable-flex>
<view class="age-tab-scroll__inner">
<view class="age-tab-row">
+2 -1
View File
@@ -5,6 +5,7 @@
"navigationBarBackgroundColor": "#F8F0E0",
"backgroundColor": "#FEF6E7",
"usingComponents": {
"van-icon": "@vant/weapp/icon/index"
"van-icon": "@vant/weapp/icon/index",
"nav-bar": "../../components3.0/nav-bar/nav-bar"
}
}
@@ -12,51 +12,6 @@
box-sizing: border-box;
}
.fav-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
flex-shrink: 0;
background: fade(@bg-header, 82%);
backdrop-filter: blur(40rpx);
-webkit-backdrop-filter: blur(40rpx);
box-shadow: 0 2rpx 12rpx rgba(50, 46, 37, 0.05);
}
.fav-nav__inner {
height: @nav-inner-height;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
padding: 0 @page-padding-x;
box-sizing: border-box;
}
.fav-nav__title {
font-size: @fs-nav-title;
font-weight: @fw-nav-title;
color: @color-nav-title;
letter-spacing: @ls-nav-title;
}
.fav-nav__actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
min-width: 120rpx;
}
.fav-nav__icon-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 8rpx;
border-radius: 50%;
}
.fav-nav__clear {
font-size: 26rpx;
+4 -7
View File
@@ -1,5 +1,8 @@
import { NAV_INNER_PX } from '../../utils/navMetrics';
const { statusBarHeight } = wx.getWindowInfo();
const NAV_BLOCK_HEIGHT = statusBarHeight + NAV_INNER_PX;
type FavoritesTab = 'favorites' | 'downloads';
type FavoriteItem =
@@ -114,9 +117,6 @@ const MOCK_DOWNLOADS: DownloadSection[] = [
Page({
data: {
statusBarHeight: 0,
navBlockHeight: 0,
/** 纵向 scroll-view 可视高度(窗口高 − 顶栏占位,避免内容滚入导航下) */
favScrollHeight: 0,
activeTab: 'favorites' as FavoritesTab,
favoriteList: MOCK_FAVORITES as FavoriteItem[],
@@ -125,11 +125,8 @@ Page({
onLoad() {
const win = wx.getWindowInfo();
const navBlockHeight = win.statusBarHeight + NAV_INNER_PX;
this.setData({
statusBarHeight: win.statusBarHeight,
navBlockHeight,
favScrollHeight: win.windowHeight - navBlockHeight,
favScrollHeight: win.windowHeight - NAV_BLOCK_HEIGHT,
});
},
+2 -6
View File
@@ -1,13 +1,9 @@
<view class="fav-page">
<view class="fav-nav" style="padding-top: {{statusBarHeight}}px;">
<view class="fav-nav__inner">
<text class="fav-nav__title">我的收藏</text>
</view>
</view>
<nav-bar title="我的收藏" />
<scroll-view
class="fav-scroll"
style="height: {{favScrollHeight}}px; margin-top: {{navBlockHeight}}px;"
style="height: {{favScrollHeight}}px;"
scroll-y
enhanced
show-scrollbar="{{false}}">
+45 -11
View File
@@ -284,9 +284,9 @@ export const HOME_PRODUCT_TARGET_DATA: HomeDisplayDataset = {
ageBand: '4-6岁',
difficulty: '入门',
icon: '🔠',
path: '',
available: false,
source: '产品设计文档 P1-17',
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-single',
available: true,
source: 'letter-tracing',
},
{
id: 'target-english-2',
@@ -721,21 +721,55 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
{
id: 'english',
title: '英语启蒙',
subtitle:
'当前暂无真实英语页面,先保留结构占位,便于首页保持完整信息架构',
subtitle: '字母描红等已上线,其余能力持续补充',
items: [
{
id: 'current-english-1',
title: '英语启蒙即将上线',
subtitle: '字母描红、闪卡、自然拼读',
title: '字母学习页',
subtitle: '字母配图与描红',
category: 'english',
ageBand: '4-7岁',
difficulty: '入门',
icon: '🔠',
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-single',
available: true,
source: 'letter-tracing-single',
},
{
id: 'current-english-2',
title: '26 字母描红',
subtitle: '整页四线三格练习',
category: 'english',
ageBand: '4-7岁',
difficulty: '入门',
icon: '🔤',
badge: 'Soon',
path: '',
available: false,
source: '产品目标结构保留位',
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-full',
available: true,
source: 'letter-tracing-full',
},
{
id: 'current-english-3',
title: '大小写字母对照',
subtitle: '一页认识 A-Z 与 a-z',
category: 'english',
ageBand: '4-7岁',
difficulty: '入门',
icon: '🔡',
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-upper-lower',
available: true,
source: 'letter-tracing-upper-lower',
},
{
id: 'current-english-4',
title: '字母描红(两列)',
subtitle: 'AM / NZ',
category: 'english',
ageBand: '4-7岁',
difficulty: '入门',
icon: '✏️',
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-two-column',
available: true,
source: 'letter-tracing-two-column',
},
],
},
+2 -1
View File
@@ -5,6 +5,7 @@
"navigationBarBackgroundColor": "#F8F0E0",
"backgroundColor": "#F8F0E0",
"usingComponents": {
"van-icon": "@vant/weapp/icon/index"
"van-icon": "@vant/weapp/icon/index",
"nav-bar": "../../components3.0/nav-bar/nav-bar"
}
}
-38
View File
@@ -14,44 +14,6 @@ page {
box-sizing: border-box;
}
/* 顶栏:固定于顶部,正文区独立滚动 */
.profile-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
flex-shrink: 0;
background: fade(@bg-header, 85%);
backdrop-filter: blur(40rpx);
-webkit-backdrop-filter: blur(40rpx);
box-shadow: 0 2rpx 12rpx rgba(50, 46, 37, 0.05);
}
.profile-nav__inner {
height: @nav-inner-height;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
padding: 0 @page-padding-x;
box-sizing: border-box;
}
.profile-nav__title {
font-size: @fs-nav-title;
font-weight: @fw-nav-title;
color: @color-nav-title;
letter-spacing: @ls-nav-title;
}
.profile-nav__brand {
font-size: 40rpx;
font-weight: 800;
color: @brand;
letter-spacing: -0.04em;
}
.profile-body {
width: 100%;
box-sizing: border-box;
+2 -11
View File
@@ -1,11 +1,7 @@
import { getTodayDownloadCount } from '../../utils/downloadPrint';
import { NAV_INNER_PX } from '../../utils/navMetrics';
Page({
data: {
statusBarHeight: 0,
/** 状态栏 + 导航条高度,用于固定顶栏占位 */
navBlockHeight: 0,
userName: '微信用户',
avatarUrl: '',
printCount: 0,
@@ -14,14 +10,9 @@ Page({
},
onLoad() {
const win = wx.getWindowInfo();
const info = wx.getAccountInfoSync();
const version = info.miniProgram.version || '开发版';
this.setData({
statusBarHeight: win.statusBarHeight,
navBlockHeight: win.statusBarHeight + NAV_INNER_PX,
version,
});
this.setData({ version });
},
onShow() {
@@ -46,7 +37,7 @@ Page({
},
onGoToGuide() {
wx.navigateTo({ url: '/pages/guide/guide' });
wx.navigateTo({ url: '/supportPages/guide/guide' });
},
onGoToPrintSettings() {
+2 -6
View File
@@ -1,11 +1,7 @@
<view class="profile-page">
<view class="profile-nav" style="padding-top: {{statusBarHeight}}px;">
<view class="profile-nav__inner">
<text class="profile-nav__title">个人中心</text>
</view>
</view>
<nav-bar title="个人中心" />
<view class="profile-body" style="padding-top: {{navBlockHeight}}px;">
<view class="profile-body">
<view class="profile-main">
<!-- 用户卡(Stitch:白底大圆角 + 顶条淡黄) -->
<view class="profile-hero">