feat: 英文字母描红开发
This commit is contained in:
+60
-2
@@ -697,7 +697,65 @@ interface WeekPlan {
|
|||||||
| 热点响应 | 随时 | 追节日/热点快速上线应景内容 |
|
| 热点响应 | 随时 | 追节日/热点快速上线应景内容 |
|
||||||
| 版本迭代 | 每 2-4 周 | 新排版模板、新生成算法、UI 改进等需发版内容 |
|
| 版本迭代 | 每 2-4 周 | 新排版模板、新生成算法、UI 改进等需发版内容 |
|
||||||
|
|
||||||
### 7.3 分包优化
|
### 7.3 内容交付形态决策框架(客户端生成 vs 云存储)
|
||||||
|
|
||||||
|
> 与 [技术架构设计文档](./技术架构设计文档.md) 第七章 §7.4(涂色卡/迷宫等素材型内容方案中的类型 A/B/C)一致,本节从产品视角给出**可执行的判断标准**,避免「像字母描红这种看似固定的内容是否该预生成图放云存储」这类决策反复讨论。
|
||||||
|
|
||||||
|
#### 7.3.1 决策树(产品侧)
|
||||||
|
|
||||||
|
规划新题型或新资料时,按顺序回答下列问题:
|
||||||
|
|
||||||
|
```
|
||||||
|
新内容 / 新题型
|
||||||
|
│
|
||||||
|
├─ 能否通过算法 + 参数(含字库/字母表/随机种子)在客户端产出整页可打印结果?
|
||||||
|
│ ├─ 能 → 【客户端动态生成】
|
||||||
|
│ │ 例:加减法、迷宫路径、字母/拼音/数字描红、识字卡选字组合、控笔虚线…
|
||||||
|
│ │
|
||||||
|
│ └─ 否 → 是否依赖设计师绘制的线稿、展开图、贴纸图等「不可替代的美术素材」?
|
||||||
|
│ ├─ 是 → 【云存储素材 + 客户端排版导出】
|
||||||
|
│ │ 例:主题涂色卡、折纸展开图、手工模板、装饰贴纸…
|
||||||
|
│ │
|
||||||
|
│ └─ 否 → 【混合型】
|
||||||
|
│ 算法生成结构 + 云存储皮肤/底图
|
||||||
|
│ 例:迷宫(路径算法 + 主题边框)、闪卡(版式 + 配图 URL)…
|
||||||
|
```
|
||||||
|
|
||||||
|
**一句话原则**:能算出来的不要囤成静态图;只有「画出来才有价值」的才上云存储。
|
||||||
|
|
||||||
|
#### 7.3.2 典型内容对照表
|
||||||
|
|
||||||
|
| 内容类型 | 推荐交付形态 | 简要理由 |
|
||||||
|
| ------------------ | ---------------- | ------------------------------------------------------------------------ |
|
||||||
|
| 字母 / 拼音描红 | 客户端生成 | 字形规则、参数多(每页字数、重复次数、大小写组合);预生成图组合爆炸 |
|
||||||
|
| 数字描红 | 客户端生成 | 同上;与现有数学/练字能力一致 |
|
||||||
|
| 汉字描红(练字) | 客户端生成 | 依赖笔画数据 + 田字格排版;用户自选字表 |
|
||||||
|
| 运算类、规律类题型 | 客户端生成 | 需随机「换一批」;零边际服务器成本 |
|
||||||
|
| 主题涂色卡线稿 | 云存储 + 排版 | 美术品质依赖设计稿,无法靠通用算法还原 |
|
||||||
|
| 折纸 / 手工模板 | 云存储 + 排版 | 展开图与步骤图为设计资产 |
|
||||||
|
| 贴纸、装饰素材 | 云存储 | 纯素材分发 |
|
||||||
|
| 迷宫、找不同等 | 混合型 | 结构或差异点可算法生成,主题视觉可素材化 |
|
||||||
|
|
||||||
|
#### 7.3.3 与模板引擎的对应关系
|
||||||
|
|
||||||
|
| 交付形态 | 常见模板类型(技术文档用语) | 产品侧理解 |
|
||||||
|
| ------------ | ---------------------------- | ------------------------------ |
|
||||||
|
| 客户端生成 | `grid-exercise`、`tracing-writing`、`sequence-pattern` 等 | 选模板 + 生成器 + 用户可调参数 |
|
||||||
|
| 云存储素材 | `full-page-asset` + `static-asset` | 配置素材 URL,按需下载后套 A4 |
|
||||||
|
| 混合型 | 自定义生成器 + 素材字段 | 产品需拆清「哪部分算、哪部分画」 |
|
||||||
|
|
||||||
|
#### 7.3.4 决策时需同步考虑的约束
|
||||||
|
|
||||||
|
| 约束 | 说明 |
|
||||||
|
| ------------ | -------------------------------------------------------------------- |
|
||||||
|
| 成本与规模 | 客户端生成主路径不增加下载带宽;大量预生成高清 PNG 会放大存储与流量成本 |
|
||||||
|
| 离线体验 | 纯客户端生成可在弱网或无网下完成预览(依赖已内置/已缓存的数据与字体) |
|
||||||
|
| 运营迭代 | JSON 配置 + 素材上架即可扩展客户端生成题型变体;云素材适合快速换主题 |
|
||||||
|
| 视觉统一 | 描红类建议统一字体与格线规范;云素材需建立插画/线稿风格指南 |
|
||||||
|
|
||||||
|
产品评审新需求时,建议在需求单中**显式勾选**交付形态(客户端生成 / 云素材 / 混合),并与设计、研发对齐模板类型与是否需要新增生成器。
|
||||||
|
|
||||||
|
### 7.4 分包优化
|
||||||
|
|
||||||
```
|
```
|
||||||
当前分包结构: 建议优化后:
|
当前分包结构: 建议优化后:
|
||||||
@@ -713,7 +771,7 @@ interface WeekPlan {
|
|||||||
└── craftPages (创意手工)
|
└── craftPages (创意手工)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.4 数据模型扩展
|
### 7.5 数据模型扩展
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface WorksheetConfig {
|
interface WorksheetConfig {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,377 @@
|
|||||||
|
# 字母描红 — 预览、配置与模板引擎设计
|
||||||
|
|
||||||
|
> 配套文档:[产品设计文档](../产品设计文档.md) | [技术架构设计文档](../技术架构设计文档.md)
|
||||||
|
> 版本:v1.0
|
||||||
|
> 最后更新:2026-04-03
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、目标与范围
|
||||||
|
|
||||||
|
本文档说明「英语字母描红」在小程序内的**预览与配置页**如何设计,以及如何对齐 [技术架构设计文档](../技术架构设计文档.md) 第五章的**模板引擎**(`tracing-writing` + `letter-tracing`)。
|
||||||
|
|
||||||
|
覆盖四种练习形态:
|
||||||
|
|
||||||
|
1. **单字母学习页**:选定一个字母,含书写示意、阅读句(如 *L is for Lion*)、配图,下方四线三格描红行。
|
||||||
|
2. **整页 26 字母**:26 个字母各占一行(或等价满版排列),四线三格内描红。
|
||||||
|
3. **上大小写下小写**:上半区展示大写 A–Z,下半区展示小写 a–z。
|
||||||
|
4. **两列 26 字母**:一页内左列 A–M、右列 N–Z(或同构小写),每行含示范与描红格。
|
||||||
|
|
||||||
|
交付形态遵循 [产品设计文档](../产品设计文档.md) §7.3:**客户端动态生成**,不预生成整页 PNG 上云存储。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、四种子类型的模板引擎归类
|
||||||
|
|
||||||
|
从产品视角是四种题型;从引擎视角可收敛为**同一排版模板 + 同一数据生成器**,通过 `generatorConfig.mode` 区分。
|
||||||
|
|
||||||
|
|
||||||
|
| 用户可见类型 | 排版本质 | 模板 `template` | 生成器 `generator` |
|
||||||
|
| --------- | ------------------ | ----------------- | ---------------- |
|
||||||
|
| 单字母学习页 | 教学区 + 描红区(混合型纵向分区) | `tracing-writing` | `letter-tracing` |
|
||||||
|
| 26 字母整页描红 | 纯描红满版(多行) | `tracing-writing` | `letter-tracing` |
|
||||||
|
| 上大写 / 下小写 | 纯描红双区块(展示 + 轻描红) | `tracing-writing` | `letter-tracing` |
|
||||||
|
| 两列 26 字母 | 纯描红双栏 | `tracing-writing` | `letter-tracing` |
|
||||||
|
|
||||||
|
|
||||||
|
**结论**:一个 `TracingWritingRenderer`(扩展四线三格能力)+ 一个 `LetterTracingGenerator`,用 `mode` 参数驱动四种版面,无需四种独立页面类型(除非产品坚持拆入口卡片)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、Mode 定义与版面结构
|
||||||
|
|
||||||
|
### 3.1 `single-letter`(单字母学习页)
|
||||||
|
|
||||||
|
- **上部(约 40%~45% 内容高)**:教学区
|
||||||
|
- 配图(与字母绑定的词汇插图,内置 26 组映射 + 资源 key / CDN URL)
|
||||||
|
- 大写 + 小写展示
|
||||||
|
- 阅读句:`*X* is for *Word`*
|
||||||
|
- 书写顺序示意(可用简化箭头/步骤编号,首版可弱化)
|
||||||
|
- **下部(约 50%)**:描红区
|
||||||
|
- 至少两行四线三格:一行大写、一行小写(或按配置多行)
|
||||||
|
- 每行:示范格(实线/深色)+ 若干渐淡虚线格 + 可选空白格
|
||||||
|
|
||||||
|
### 3.2 `full-alphabet`(26 字母整页)
|
||||||
|
|
||||||
|
- 26 行(大写或二选一为小写),每行对应一个字母。
|
||||||
|
- 每行:四线三格横排多个单元格——首格示范,后续为渐淡描红或空白。
|
||||||
|
- 行高由可用高度 / 26 自动换算,保证打印可读(需与 `fontSize`、DPI 导出策略一致)。
|
||||||
|
|
||||||
|
### 3.3 `upper-lower-split`(上大写、下小写)
|
||||||
|
|
||||||
|
- 上区块:标题如「大写字母」+ 两行(或紧凑网格)排完 A–Z,每字母一格四线三格,偏**展示**(描红可弱:首格实线 + 空白)。
|
||||||
|
- 分隔线。
|
||||||
|
- 下区块:标题如「小写字母」+ 同理排完 a–z。
|
||||||
|
|
||||||
|
### 3.4 `two-column`(两列)
|
||||||
|
|
||||||
|
- 左列 A–M(或 a–m),右列 N–Z(或 n–z)。
|
||||||
|
- 每行结构与 `full-alphabet` 类似:示范 + 多格描红。
|
||||||
|
- 中间留白或细分隔线,避免幼儿视觉混淆。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、四线三格绘制约定
|
||||||
|
|
||||||
|
用于英文字母书写指导(区别于汉字田字格):
|
||||||
|
|
||||||
|
- **顶线**:大写字母上沿参考。
|
||||||
|
- **中虚线**:小写无升部字母的上沿(x-height 概念)。
|
||||||
|
- **基线**:字母落笔主参考线。
|
||||||
|
- **底线**:降部(如 g、p、y)下沿参考。
|
||||||
|
|
||||||
|
大写主体落在顶线—基线之间;小写主体多在中虚线—基线之间,降部延伸至底线。渲染器需实现 `drawFourLineGrid(cellRect)`,并与 `fontSize`、基线对齐算法统一。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、预览与配置页(产品 UI)
|
||||||
|
|
||||||
|
### 5.1 页面结构(与通用生成页一致)
|
||||||
|
|
||||||
|
对齐产品设计中的生成/预览页:**A4 预览区(Canvas)+ 参数面板 + 底部操作**(换一批 / 保存相册 / 分享等)。
|
||||||
|
|
||||||
|
### **预览/配置页面 UI 设计**
|
||||||
|
|
||||||
|
所有4种类型**共用一个通用 worksheet 页面**(`pages/worksheet/worksheet`),通过 `worksheetId` 加载不同 JSON 配置。但字母描红也可以做成一个**聚合入口页** + 通用 worksheet 页的组合,体验更好:
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
|
||||||
|
│ ← 返回 英语字母描红 ❤️ │
|
||||||
|
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
|
||||||
|
│ │
|
||||||
|
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
|
||||||
|
│ │ │ │
|
||||||
|
|
||||||
|
│ │ A4 打印效果预览区 │ │
|
||||||
|
|
||||||
|
│ │ (Canvas 实时渲染) │ │
|
||||||
|
|
||||||
|
│ │ │ │
|
||||||
|
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
|
||||||
|
│ │
|
||||||
|
|
||||||
|
│ ⚙️ 参数设置 │
|
||||||
|
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
|
||||||
|
│ │ │ │
|
||||||
|
|
||||||
|
│ │ 练习类型: │ │
|
||||||
|
|
||||||
|
│ │ ┌──────┐┌──────┐┌──────┐ │ │
|
||||||
|
|
||||||
|
│ │ │ 单字母 ││ 整页 ││大小写│ │ │ ← 核心切换
|
||||||
|
|
||||||
|
│ │ │ 学习页 ││ 描红 ││对照 │ │ │
|
||||||
|
|
||||||
|
│ │ └──────┘└──────┘└──────┘ │ │
|
||||||
|
|
||||||
|
│ │ ┌──────┐ │ │
|
||||||
|
|
||||||
|
│ │ │ 两列 │ │ │
|
||||||
|
|
||||||
|
│ │ │ 描红 │ │ │
|
||||||
|
|
||||||
|
│ │ └──────┘ │ │
|
||||||
|
|
||||||
|
│ │ │ │
|
||||||
|
|
||||||
|
│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │
|
||||||
|
|
||||||
|
│ │ │ │
|
||||||
|
|
||||||
|
│ │ 大小写:[大写 ▼] │ │ ← mode 2/4 时显示
|
||||||
|
|
||||||
|
│ │ │ │
|
||||||
|
|
||||||
|
│ │ 选择字母:A [选中] B [选中]... │ │ ← mode 1 时显示
|
||||||
|
|
||||||
|
│ │ │ │
|
||||||
|
|
||||||
|
│ │ 每行描红格数:[5 ▼] │ │ ← mode 1/2/4 时显示
|
||||||
|
|
||||||
|
│ │ │ │
|
||||||
|
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
|
||||||
|
│ │
|
||||||
|
|
||||||
|
│ ┌──────────┐ ┌──────────────┐ │
|
||||||
|
|
||||||
|
│ │ 🔄 换一批 │ │ 📥 保存到相册 │ │
|
||||||
|
|
||||||
|
│ └──────────┘ └──────────────┘ │
|
||||||
|
|
||||||
|
│ │
|
||||||
|
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
|
||||||
|
### 5.2 参数项与 Mode 的显隐
|
||||||
|
|
||||||
|
|
||||||
|
| 配置项 | `single-letter` | `full-alphabet` | `upper-lower-split` | `two-column` |
|
||||||
|
| ---------- | --------------- | --------------- | ------------------- | ------------ |
|
||||||
|
| 练习类型(mode) | 显示 | 显示 | 显示 | 显示 |
|
||||||
|
| 选择字母 | 显示(单选) | 隐藏 | 隐藏 | 隐藏 |
|
||||||
|
| 大小写 | 隐藏(默认双写) | 显示 | 隐藏(固定双区) | 显示 |
|
||||||
|
| 每行描红格数 | 显示 | 显示 | 隐藏或固定为 1 | 显示 |
|
||||||
|
| 换一批 | 可切换下一字母 | 可不展示或禁用 | 可不展示 | 可不展示 |
|
||||||
|
|
||||||
|
|
||||||
|
参数面板由 `WorksheetConfig.userConfigurable` 描述;前端根据当前 `mode` **过滤**不适用字段(或在 JSON 层拆成 4 条配置,每条只含本 mode 的 `userConfigurable`)。
|
||||||
|
|
||||||
|
### 5.3 入口策略
|
||||||
|
|
||||||
|
- **推荐**:发现页 / 英语分类下 **4 张卡片**,对应 4 个 `worksheet id`,均进入**同一通用 worksheet 页面**,仅默认 `generatorConfig` 不同。
|
||||||
|
- **可选**:单页内提供「练习类型」Segment,切换 `mode` 并合并默认参数后重绘(与多卡片二选一或并存)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、生成器设计:`letter-tracing`
|
||||||
|
|
||||||
|
### 6.1 配置(建议字段)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type LetterTracingMode =
|
||||||
|
| 'single-letter'
|
||||||
|
| 'full-alphabet'
|
||||||
|
| 'upper-lower-split'
|
||||||
|
| 'two-column';
|
||||||
|
|
||||||
|
interface LetterTracingGeneratorConfig {
|
||||||
|
mode: LetterTracingMode;
|
||||||
|
/** full-alphabet / two-column 使用 */
|
||||||
|
letterCase: 'upper' | 'lower';
|
||||||
|
/** single-letter 使用,如 'L' */
|
||||||
|
selectedLetter?: string;
|
||||||
|
/** 每行描红单元数量(示范 + 渐淡 + 空白计在同一行内) */
|
||||||
|
repetitions: number;
|
||||||
|
/** 渐淡策略 */
|
||||||
|
fadePattern: 'gradient' | 'first-only';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 输出数据结构(示意)
|
||||||
|
|
||||||
|
生成器产出**与平台无关**的数据,供 `TracingWritingRenderer` 消费:
|
||||||
|
|
||||||
|
- `single-letter`:`{ teaching: { upper, lower, word, sentence, imageRef }, rows: TracingRow[] }`
|
||||||
|
- `full-alphabet` / `two-column`:`{ columns?: 1 | 2, rows: TracingRow[] }`
|
||||||
|
- `upper-lower-split`:`{ upperBlock: TracingRow[] | GlyphCell[], lowerBlock: ... }`
|
||||||
|
|
||||||
|
`TracingRow` 建议包含:`displayChar`、`cells: { char, opacity, isGuide }[]`、`gridType: 'four-line'`。
|
||||||
|
|
||||||
|
### 6.3 词汇与配图数据
|
||||||
|
|
||||||
|
- 维护 `Record<'A'..'Z', { word: string; imageKey: string }>`(或云端下发)。
|
||||||
|
- 图片走内置分包资源或云存储 URL,由 [产品设计文档](../产品设计文档.md) §7.3 中「云素材」规则约束:**插图是设计资产,可 CDN;整页描红仍客户端拼版**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、渲染器设计:`tracing-writing` 扩展
|
||||||
|
|
||||||
|
### 7.1 职责
|
||||||
|
|
||||||
|
- 沿用现有 `BaseTemplateRenderer` 流程:背景、页眉、说明文案、内容区、页脚。
|
||||||
|
- 根据 `data.mode` 分支调用 `drawSingleLetterPage` / `drawFullAlphabetPage` / `drawUpperLowerSplitPage` / `drawTwoColumnPage`。
|
||||||
|
- 统一使用 **A4 逻辑坐标**(与技术文档 §7.1 Canvas 管线一致)。
|
||||||
|
|
||||||
|
### 7.2 与汉字描红的差异
|
||||||
|
|
||||||
|
|
||||||
|
| 维度 | 汉字练字(`character-tracing`) | 字母描红(`letter-tracing`) |
|
||||||
|
| --- | ------------------------- | ---------------------- |
|
||||||
|
| 格子 | 田字格 | 四线三格 |
|
||||||
|
| 字形 | SVG 笔画路径 | 优先 `fillText` + 字体轮廓 |
|
||||||
|
| 版面 | 多字多格排布 | 四种 mode 分区逻辑 |
|
||||||
|
|
||||||
|
|
||||||
|
### 7.3 参考现有实现
|
||||||
|
|
||||||
|
- `service/wordDrawService.ts`:布局分区、渐淡格思路可对齐。
|
||||||
|
- `mathPages/shared/service/numberWriteDraw.ts`:四线三格线型可参考。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、WorksheetConfig 示例(四条)
|
||||||
|
|
||||||
|
以下可与 `core/data/worksheets.ts` 或云端 `worksheets` 集合对齐;`id` 供路由与统计使用。
|
||||||
|
|
||||||
|
**1)单字母学习页**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "letter-tracing-single",
|
||||||
|
"title": "字母学习页",
|
||||||
|
"category": "english",
|
||||||
|
"subcategory": "letter-tracing",
|
||||||
|
"template": "tracing-writing",
|
||||||
|
"generator": "letter-tracing",
|
||||||
|
"generatorConfig": {
|
||||||
|
"mode": "single-letter",
|
||||||
|
"selectedLetter": "A",
|
||||||
|
"repetitions": 5,
|
||||||
|
"fadePattern": "gradient"
|
||||||
|
},
|
||||||
|
"layoutConfig": {
|
||||||
|
"showInstruction": true,
|
||||||
|
"instructionText": "看一看,读一读,再描一描"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2)26 字母整页**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "letter-tracing-full",
|
||||||
|
"title": "26 字母描红",
|
||||||
|
"category": "english",
|
||||||
|
"subcategory": "letter-tracing",
|
||||||
|
"template": "tracing-writing",
|
||||||
|
"generator": "letter-tracing",
|
||||||
|
"generatorConfig": {
|
||||||
|
"mode": "full-alphabet",
|
||||||
|
"letterCase": "upper",
|
||||||
|
"repetitions": 8,
|
||||||
|
"fadePattern": "gradient"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3)大小写对照**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "letter-tracing-upper-lower",
|
||||||
|
"title": "大小写字母对照",
|
||||||
|
"category": "english",
|
||||||
|
"subcategory": "letter-tracing",
|
||||||
|
"template": "tracing-writing",
|
||||||
|
"generator": "letter-tracing",
|
||||||
|
"generatorConfig": {
|
||||||
|
"mode": "upper-lower-split",
|
||||||
|
"repetitions": 1,
|
||||||
|
"fadePattern": "first-only"
|
||||||
|
},
|
||||||
|
"layoutConfig": {
|
||||||
|
"columns": 13
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4)两列描红**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "letter-tracing-two-column",
|
||||||
|
"title": "字母描红(两列)",
|
||||||
|
"category": "english",
|
||||||
|
"subcategory": "letter-tracing",
|
||||||
|
"template": "tracing-writing",
|
||||||
|
"generator": "letter-tracing",
|
||||||
|
"generatorConfig": {
|
||||||
|
"mode": "two-column",
|
||||||
|
"letterCase": "upper",
|
||||||
|
"repetitions": 4,
|
||||||
|
"fadePattern": "gradient"
|
||||||
|
},
|
||||||
|
"layoutConfig": {
|
||||||
|
"columns": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
完整 `userConfigurable` 数组可在实现阶段按 §5.2 补全,并同步云库字段定义。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、实现落地清单(工程侧)
|
||||||
|
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
| ------------------------ | -------------------------------------------------------------------------- |
|
||||||
|
| `LetterTracingGenerator` | `core/generators/` 或英语分包 `shared/generators/`,注册到 `generatorRegistry` |
|
||||||
|
| `TracingWritingRenderer` | 扩展四线三格 + 四种 `draw*Page` |
|
||||||
|
| 字母词汇与配图映射 | `core/data/alphabet.ts` 或云端配置 |
|
||||||
|
| 通用 worksheet 页 | `pages/worksheet/worksheet`(或过渡期 `englishPages` 专用页再迁) |
|
||||||
|
| `ALL_WORKSHEETS` | 增加 4 条 `WorksheetDefinition`,`category` 对齐英语,`legacyPage` 可指向 worksheet 路由 |
|
||||||
|
| 分包 | 按需新增 `englishPages`,避免主包膨胀 |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、修订记录
|
||||||
|
|
||||||
|
|
||||||
|
| 日期 | 版本 | 说明 |
|
||||||
|
| ---------- | ---- | ---- |
|
||||||
|
| 2026-04-03 | v1.0 | 初稿入库 |
|
||||||
|
|
||||||
|
|
||||||
+17
-2
@@ -4,10 +4,15 @@
|
|||||||
"pages/age/age",
|
"pages/age/age",
|
||||||
"pages/favorites/favorites",
|
"pages/favorites/favorites",
|
||||||
"pages/profile/profile",
|
"pages/profile/profile",
|
||||||
"pages/guide/guide",
|
|
||||||
"pages/index/index"
|
"pages/index/index"
|
||||||
],
|
],
|
||||||
"subPackages": [
|
"subPackages": [
|
||||||
|
{
|
||||||
|
"root": "supportPages",
|
||||||
|
"name": "supportPages",
|
||||||
|
"pages": ["guide/guide", "debug/debug"],
|
||||||
|
"independent": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"root": "mathPages",
|
"root": "mathPages",
|
||||||
"name": "mathPages",
|
"name": "mathPages",
|
||||||
@@ -31,6 +36,12 @@
|
|||||||
],
|
],
|
||||||
"independent": false
|
"independent": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"root": "englishPages",
|
||||||
|
"name": "englishPages",
|
||||||
|
"pages": ["letterTracing/letterTracing"],
|
||||||
|
"independent": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"root": "focusPages",
|
"root": "focusPages",
|
||||||
"name": "focusPages",
|
"name": "focusPages",
|
||||||
@@ -53,7 +64,11 @@
|
|||||||
"preloadRule": {
|
"preloadRule": {
|
||||||
"pages/home/home": {
|
"pages/home/home": {
|
||||||
"network": "all",
|
"network": "all",
|
||||||
"packages": ["mathPages", "focusPages"]
|
"packages": ["mathPages", "focusPages", "englishPages"]
|
||||||
|
},
|
||||||
|
"pages/profile/profile": {
|
||||||
|
"network": "all",
|
||||||
|
"packages": ["supportPages"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"window": {},
|
"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>
|
||||||
@@ -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('');
|
||||||
@@ -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: '横竖撇' },
|
{ 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 } },
|
{ 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: 'A–M / N–Z 两列练习',
|
||||||
|
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
|
export const MATH_FUNCTION_TYPES: MathFunctionType[] = ALL_WORKSHEETS.filter(
|
||||||
.filter((w) => w.category === 'math')
|
(w) => w.category === 'math',
|
||||||
|
)
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
.map((w) => ({
|
.map((w) => ({
|
||||||
id: w.id,
|
id: w.id,
|
||||||
@@ -268,8 +398,9 @@ export const MATH_FUNCTION_TYPES: MathFunctionType[] = ALL_WORKSHEETS
|
|||||||
img: w.img,
|
img: w.img,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const FOCUS_FUNCTION_TYPES: FocusFunctionType[] = ALL_WORKSHEETS
|
export const FOCUS_FUNCTION_TYPES: FocusFunctionType[] = ALL_WORKSHEETS.filter(
|
||||||
.filter((w) => w.category === 'focus')
|
(w) => w.category === 'focus',
|
||||||
|
)
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
.map((w) => ({
|
.map((w) => ({
|
||||||
id: w.id,
|
id: w.id,
|
||||||
@@ -281,7 +412,9 @@ export const FOCUS_FUNCTION_TYPES: FocusFunctionType[] = ALL_WORKSHEETS
|
|||||||
img: w.img,
|
img: w.img,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function getWorksheetsByCategory(category: string): WorksheetDefinition[] {
|
export function getWorksheetsByCategory(
|
||||||
|
category: string,
|
||||||
|
): WorksheetDefinition[] {
|
||||||
if (category === 'all') return ALL_WORKSHEETS;
|
if (category === 'all') return ALL_WORKSHEETS;
|
||||||
return ALL_WORKSHEETS.filter((w) => w.category === category);
|
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: 'A–M / N–Z 两列练习',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,7 @@
|
|||||||
"navigationBarBackgroundColor": "#FEF6E7",
|
"navigationBarBackgroundColor": "#FEF6E7",
|
||||||
"backgroundColor": "#FEF6E7",
|
"backgroundColor": "#FEF6E7",
|
||||||
"enablePullDownRefresh": false,
|
"enablePullDownRefresh": false,
|
||||||
"usingComponents": {}
|
"usingComponents": {
|
||||||
|
"nav-bar": "../../components3.0/nav-bar/nav-bar"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,35 +15,6 @@
|
|||||||
box-sizing: border-box;
|
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 {
|
.age-body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { AGE_BANDS, AgeBandKey } from '../../core/data/difficulty';
|
import { AGE_BANDS, AgeBandKey } from '../../core/data/difficulty';
|
||||||
import { NAV_INNER_PX } from '../../utils/navMetrics';
|
|
||||||
|
|
||||||
type AbilityItem = {
|
type AbilityItem = {
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -186,8 +185,6 @@ const DEFAULT_AGE: AgeBandKey = '5-6';
|
|||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
statusBarHeight: 0,
|
|
||||||
navBlockHeight: 0,
|
|
||||||
ageTabs: AGE_BANDS.map((b) => ({
|
ageTabs: AGE_BANDS.map((b) => ({
|
||||||
key: b.key,
|
key: b.key,
|
||||||
rangeText: b.label.replace(/\s*岁\s*$/, ''),
|
rangeText: b.label.replace(/\s*岁\s*$/, ''),
|
||||||
@@ -201,11 +198,6 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
onLoad() {
|
onLoad() {
|
||||||
const win = wx.getWindowInfo();
|
|
||||||
this.setData({
|
|
||||||
statusBarHeight: win.statusBarHeight,
|
|
||||||
navBlockHeight: win.statusBarHeight + NAV_INNER_PX,
|
|
||||||
});
|
|
||||||
this.applyAge(DEFAULT_AGE);
|
this.applyAge(DEFAULT_AGE);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
<view class="age-page">
|
<view class="age-page">
|
||||||
<view class="age-nav" style="padding-top: {{statusBarHeight}}px;">
|
<nav-bar title="按年龄学" />
|
||||||
<view class="age-nav__inner">
|
|
||||||
<text class="age-nav__title">按年龄学</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="age-body" style="padding-top: {{navBlockHeight}}px;">
|
<view class="age-body">
|
||||||
<scroll-view class="age-tab-scroll" scroll-x enable-flex>
|
<scroll-view class="age-tab-scroll" scroll-x enable-flex>
|
||||||
<view class="age-tab-scroll__inner">
|
<view class="age-tab-scroll__inner">
|
||||||
<view class="age-tab-row">
|
<view class="age-tab-row">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"navigationBarBackgroundColor": "#F8F0E0",
|
"navigationBarBackgroundColor": "#F8F0E0",
|
||||||
"backgroundColor": "#FEF6E7",
|
"backgroundColor": "#FEF6E7",
|
||||||
"usingComponents": {
|
"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;
|
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 {
|
.fav-nav__clear {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { NAV_INNER_PX } from '../../utils/navMetrics';
|
import { NAV_INNER_PX } from '../../utils/navMetrics';
|
||||||
|
|
||||||
|
const { statusBarHeight } = wx.getWindowInfo();
|
||||||
|
const NAV_BLOCK_HEIGHT = statusBarHeight + NAV_INNER_PX;
|
||||||
|
|
||||||
type FavoritesTab = 'favorites' | 'downloads';
|
type FavoritesTab = 'favorites' | 'downloads';
|
||||||
|
|
||||||
type FavoriteItem =
|
type FavoriteItem =
|
||||||
@@ -114,9 +117,6 @@ const MOCK_DOWNLOADS: DownloadSection[] = [
|
|||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
statusBarHeight: 0,
|
|
||||||
navBlockHeight: 0,
|
|
||||||
/** 纵向 scroll-view 可视高度(窗口高 − 顶栏占位,避免内容滚入导航下) */
|
|
||||||
favScrollHeight: 0,
|
favScrollHeight: 0,
|
||||||
activeTab: 'favorites' as FavoritesTab,
|
activeTab: 'favorites' as FavoritesTab,
|
||||||
favoriteList: MOCK_FAVORITES as FavoriteItem[],
|
favoriteList: MOCK_FAVORITES as FavoriteItem[],
|
||||||
@@ -125,11 +125,8 @@ Page({
|
|||||||
|
|
||||||
onLoad() {
|
onLoad() {
|
||||||
const win = wx.getWindowInfo();
|
const win = wx.getWindowInfo();
|
||||||
const navBlockHeight = win.statusBarHeight + NAV_INNER_PX;
|
|
||||||
this.setData({
|
this.setData({
|
||||||
statusBarHeight: win.statusBarHeight,
|
favScrollHeight: win.windowHeight - NAV_BLOCK_HEIGHT,
|
||||||
navBlockHeight,
|
|
||||||
favScrollHeight: win.windowHeight - navBlockHeight,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
<view class="fav-page">
|
<view class="fav-page">
|
||||||
<view class="fav-nav" style="padding-top: {{statusBarHeight}}px;">
|
<nav-bar title="我的收藏" />
|
||||||
<view class="fav-nav__inner">
|
|
||||||
<text class="fav-nav__title">我的收藏</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<scroll-view
|
<scroll-view
|
||||||
class="fav-scroll"
|
class="fav-scroll"
|
||||||
style="height: {{favScrollHeight}}px; margin-top: {{navBlockHeight}}px;"
|
style="height: {{favScrollHeight}}px;"
|
||||||
scroll-y
|
scroll-y
|
||||||
enhanced
|
enhanced
|
||||||
show-scrollbar="{{false}}">
|
show-scrollbar="{{false}}">
|
||||||
|
|||||||
@@ -284,9 +284,9 @@ export const HOME_PRODUCT_TARGET_DATA: HomeDisplayDataset = {
|
|||||||
ageBand: '4-6岁',
|
ageBand: '4-6岁',
|
||||||
difficulty: '入门',
|
difficulty: '入门',
|
||||||
icon: '🔠',
|
icon: '🔠',
|
||||||
path: '',
|
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-single',
|
||||||
available: false,
|
available: true,
|
||||||
source: '产品设计文档 P1-17',
|
source: 'letter-tracing',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'target-english-2',
|
id: 'target-english-2',
|
||||||
@@ -721,21 +721,55 @@ export const HOME_CURRENT_CAPABILITY_DATA: HomeDisplayDataset = {
|
|||||||
{
|
{
|
||||||
id: 'english',
|
id: 'english',
|
||||||
title: '英语启蒙',
|
title: '英语启蒙',
|
||||||
subtitle:
|
subtitle: '字母描红等已上线,其余能力持续补充',
|
||||||
'当前暂无真实英语页面,先保留结构占位,便于首页保持完整信息架构',
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 'current-english-1',
|
id: 'current-english-1',
|
||||||
title: '英语启蒙即将上线',
|
title: '字母学习页',
|
||||||
subtitle: '字母描红、闪卡、自然拼读',
|
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',
|
category: 'english',
|
||||||
ageBand: '4-7岁',
|
ageBand: '4-7岁',
|
||||||
difficulty: '入门',
|
difficulty: '入门',
|
||||||
icon: '🔤',
|
icon: '🔤',
|
||||||
badge: 'Soon',
|
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-full',
|
||||||
path: '',
|
available: true,
|
||||||
available: false,
|
source: 'letter-tracing-full',
|
||||||
source: '产品目标结构保留位',
|
},
|
||||||
|
{
|
||||||
|
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: 'A–M / N–Z',
|
||||||
|
category: 'english',
|
||||||
|
ageBand: '4-7岁',
|
||||||
|
difficulty: '入门',
|
||||||
|
icon: '✏️',
|
||||||
|
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-two-column',
|
||||||
|
available: true,
|
||||||
|
source: 'letter-tracing-two-column',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"navigationBarBackgroundColor": "#F8F0E0",
|
"navigationBarBackgroundColor": "#F8F0E0",
|
||||||
"backgroundColor": "#F8F0E0",
|
"backgroundColor": "#F8F0E0",
|
||||||
"usingComponents": {
|
"usingComponents": {
|
||||||
"van-icon": "@vant/weapp/icon/index"
|
"van-icon": "@vant/weapp/icon/index",
|
||||||
|
"nav-bar": "../../components3.0/nav-bar/nav-bar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,44 +14,6 @@ page {
|
|||||||
box-sizing: border-box;
|
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 {
|
.profile-body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { getTodayDownloadCount } from '../../utils/downloadPrint';
|
import { getTodayDownloadCount } from '../../utils/downloadPrint';
|
||||||
import { NAV_INNER_PX } from '../../utils/navMetrics';
|
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
statusBarHeight: 0,
|
|
||||||
/** 状态栏 + 导航条高度,用于固定顶栏占位 */
|
|
||||||
navBlockHeight: 0,
|
|
||||||
userName: '微信用户',
|
userName: '微信用户',
|
||||||
avatarUrl: '',
|
avatarUrl: '',
|
||||||
printCount: 0,
|
printCount: 0,
|
||||||
@@ -14,14 +10,9 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
onLoad() {
|
onLoad() {
|
||||||
const win = wx.getWindowInfo();
|
|
||||||
const info = wx.getAccountInfoSync();
|
const info = wx.getAccountInfoSync();
|
||||||
const version = info.miniProgram.version || '开发版';
|
const version = info.miniProgram.version || '开发版';
|
||||||
this.setData({
|
this.setData({ version });
|
||||||
statusBarHeight: win.statusBarHeight,
|
|
||||||
navBlockHeight: win.statusBarHeight + NAV_INNER_PX,
|
|
||||||
version,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onShow() {
|
onShow() {
|
||||||
@@ -46,7 +37,7 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
onGoToGuide() {
|
onGoToGuide() {
|
||||||
wx.navigateTo({ url: '/pages/guide/guide' });
|
wx.navigateTo({ url: '/supportPages/guide/guide' });
|
||||||
},
|
},
|
||||||
|
|
||||||
onGoToPrintSettings() {
|
onGoToPrintSettings() {
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
<view class="profile-page">
|
<view class="profile-page">
|
||||||
<view class="profile-nav" style="padding-top: {{statusBarHeight}}px;">
|
<nav-bar title="个人中心" />
|
||||||
<view class="profile-nav__inner">
|
|
||||||
<text class="profile-nav__title">个人中心</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="profile-body" style="padding-top: {{navBlockHeight}}px;">
|
<view class="profile-body">
|
||||||
<view class="profile-main">
|
<view class="profile-main">
|
||||||
<!-- 用户卡(Stitch:白底大圆角 + 顶条淡黄) -->
|
<!-- 用户卡(Stitch:白底大圆角 + 顶条淡黄) -->
|
||||||
<view class="profile-hero">
|
<view class="profile-hero">
|
||||||
|
|||||||
Reference in New Issue
Block a user