From 1ef6770810ebdf3196f97ec8bedfb82d22d08c7e Mon Sep 17 00:00:00 2001 From: R524809 Date: Fri, 3 Apr 2026 15:21:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=8B=B1=E6=96=87=E5=AD=97=E6=AF=8D?= =?UTF-8?q?=E6=8F=8F=E7=BA=A2=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/产品设计文档.md | 62 ++- .../stitch-refs/letter-tracing-stitch-preview.png | Bin 0 -> 26209 bytes docs/生成方案/字母描红设计方案.md | 377 ++++++++++++++++++ miniprogram/app.json | 19 +- .../components3.0/nav-bar/nav-bar.json | 3 + .../components3.0/nav-bar/nav-bar.less | 44 ++ miniprogram/components3.0/nav-bar/nav-bar.ts | 22 + .../components3.0/nav-bar/nav-bar.wxml | 10 + miniprogram/core/data/alphabet.ts | 39 ++ miniprogram/core/data/worksheets.ts | 143 ++++++- .../generators/letter-tracing-generator.ts | 172 ++++++++ .../letterTracing/letterTracing.json | 13 + .../letterTracing/letterTracing.less | 307 ++++++++++++++ .../letterTracing/letterTracing.ts | 197 +++++++++ .../letterTracing/letterTracing.wxml | 100 +++++ .../shared/service/letterTracingDraw.ts | 249 ++++++++++++ miniprogram/pages/age/age.json | 4 +- miniprogram/pages/age/age.less | 29 -- miniprogram/pages/age/age.ts | 8 - miniprogram/pages/age/age.wxml | 8 +- miniprogram/pages/favorites/favorites.json | 3 +- miniprogram/pages/favorites/favorites.less | 45 --- miniprogram/pages/favorites/favorites.ts | 11 +- miniprogram/pages/favorites/favorites.wxml | 8 +- miniprogram/pages/home/home.data.ts | 56 ++- miniprogram/pages/profile/profile.json | 3 +- miniprogram/pages/profile/profile.less | 38 -- miniprogram/pages/profile/profile.ts | 13 +- miniprogram/pages/profile/profile.wxml | 8 +- .../{pages => supportPages}/debug/debug.json | 0 .../{pages => supportPages}/debug/debug.less | 0 .../{pages => supportPages}/debug/debug.ts | 0 .../{pages => supportPages}/debug/debug.wxml | 0 .../{pages => supportPages}/guide/guide.json | 0 .../{pages => supportPages}/guide/guide.less | 0 .../{pages => supportPages}/guide/guide.ts | 0 .../{pages => supportPages}/guide/guide.wxml | 0 37 files changed, 1812 insertions(+), 179 deletions(-) create mode 100644 docs/生成方案/stitch-refs/letter-tracing-stitch-preview.png create mode 100644 docs/生成方案/字母描红设计方案.md create mode 100644 miniprogram/components3.0/nav-bar/nav-bar.json create mode 100644 miniprogram/components3.0/nav-bar/nav-bar.less create mode 100644 miniprogram/components3.0/nav-bar/nav-bar.ts create mode 100644 miniprogram/components3.0/nav-bar/nav-bar.wxml create mode 100644 miniprogram/core/data/alphabet.ts create mode 100644 miniprogram/core/generators/letter-tracing-generator.ts create mode 100644 miniprogram/englishPages/letterTracing/letterTracing.json create mode 100644 miniprogram/englishPages/letterTracing/letterTracing.less create mode 100644 miniprogram/englishPages/letterTracing/letterTracing.ts create mode 100644 miniprogram/englishPages/letterTracing/letterTracing.wxml create mode 100644 miniprogram/englishPages/shared/service/letterTracingDraw.ts rename miniprogram/{pages => supportPages}/debug/debug.json (100%) rename miniprogram/{pages => supportPages}/debug/debug.less (100%) rename miniprogram/{pages => supportPages}/debug/debug.ts (100%) rename miniprogram/{pages => supportPages}/debug/debug.wxml (100%) rename miniprogram/{pages => supportPages}/guide/guide.json (100%) rename miniprogram/{pages => supportPages}/guide/guide.less (100%) rename miniprogram/{pages => supportPages}/guide/guide.ts (100%) rename miniprogram/{pages => supportPages}/guide/guide.wxml (100%) diff --git a/docs/产品设计文档.md b/docs/产品设计文档.md index a9ee78e..b8573d7 100644 --- a/docs/产品设计文档.md +++ b/docs/产品设计文档.md @@ -697,7 +697,65 @@ interface WeekPlan { | 热点响应 | 随时 | 追节日/热点快速上线应景内容 | | 版本迭代 | 每 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 (创意手工) ``` -### 7.4 数据模型扩展 +### 7.5 数据模型扩展 ```typescript interface WorksheetConfig { diff --git a/docs/生成方案/stitch-refs/letter-tracing-stitch-preview.png b/docs/生成方案/stitch-refs/letter-tracing-stitch-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..309270fbe12b25dc829a03d8c790f24f90029556 GIT binary patch literal 26209 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 literal 0 HcmV?d00001 diff --git a/docs/生成方案/字母描红设计方案.md b/docs/生成方案/字母描红设计方案.md new file mode 100644 index 0000000..9228f9f --- /dev/null +++ b/docs/生成方案/字母描红设计方案.md @@ -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 | 初稿入库 | + + diff --git a/miniprogram/app.json b/miniprogram/app.json index dca1fff..67d3d3a 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -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": {}, diff --git a/miniprogram/components3.0/nav-bar/nav-bar.json b/miniprogram/components3.0/nav-bar/nav-bar.json new file mode 100644 index 0000000..10e7f9d --- /dev/null +++ b/miniprogram/components3.0/nav-bar/nav-bar.json @@ -0,0 +1,3 @@ +{ + "component": true +} diff --git a/miniprogram/components3.0/nav-bar/nav-bar.less b/miniprogram/components3.0/nav-bar/nav-bar.less new file mode 100644 index 0000000..0b9d2d8 --- /dev/null +++ b/miniprogram/components3.0/nav-bar/nav-bar.less @@ -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; +} diff --git a/miniprogram/components3.0/nav-bar/nav-bar.ts b/miniprogram/components3.0/nav-bar/nav-bar.ts new file mode 100644 index 0000000..d549a3b --- /dev/null +++ b/miniprogram/components3.0/nav-bar/nav-bar.ts @@ -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, + }); + }, + }, +}); diff --git a/miniprogram/components3.0/nav-bar/nav-bar.wxml b/miniprogram/components3.0/nav-bar/nav-bar.wxml new file mode 100644 index 0000000..c53510d --- /dev/null +++ b/miniprogram/components3.0/nav-bar/nav-bar.wxml @@ -0,0 +1,10 @@ + + + + {{title}} + + + + + + diff --git a/miniprogram/core/data/alphabet.ts b/miniprogram/core/data/alphabet.ts new file mode 100644 index 0000000..de8ce90 --- /dev/null +++ b/miniprogram/core/data/alphabet.ts @@ -0,0 +1,39 @@ +/** + * 26 字母与示例词(首版用 emoji 占位配图,后续可换 imageUrl) + */ +export interface AlphabetEntry { + word: string; + /** 与单词相关的 emoji,Canvas 上直接绘制,无需图片资源 */ + emoji: string; +} + +export const ALPHABET_BY_LETTER: Record = { + 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(''); diff --git a/miniprogram/core/data/worksheets.ts b/miniprogram/core/data/worksheets.ts index cd4df26..d267823 100644 --- a/miniprogram/core/data/worksheets.ts +++ b/miniprogram/core/data/worksheets.ts @@ -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: '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 - .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); } diff --git a/miniprogram/core/generators/letter-tracing-generator.ts b/miniprogram/core/generators/letter-tracing-generator.ts new file mode 100644 index 0000000..4e73039 --- /dev/null +++ b/miniprogram/core/generators/letter-tracing-generator.ts @@ -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, + overrides: Partial, +): 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', + }; +} diff --git a/miniprogram/englishPages/letterTracing/letterTracing.json b/miniprogram/englishPages/letterTracing/letterTracing.json new file mode 100644 index 0000000..0f978fc --- /dev/null +++ b/miniprogram/englishPages/letterTracing/letterTracing.json @@ -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" + } +} diff --git a/miniprogram/englishPages/letterTracing/letterTracing.less b/miniprogram/englishPages/letterTracing/letterTracing.less new file mode 100644 index 0000000..c6b5208 --- /dev/null +++ b/miniprogram/englishPages/letterTracing/letterTracing.less @@ -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; +} diff --git a/miniprogram/englishPages/letterTracing/letterTracing.ts b/miniprogram/englishPages/letterTracing/letterTracing.ts new file mode 100644 index 0000000..152ba54 --- /dev/null +++ b/miniprogram/englishPages/letterTracing/letterTracing.ts @@ -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 = { + '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; + 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, + ) => 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; + 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); +} diff --git a/miniprogram/englishPages/letterTracing/letterTracing.wxml b/miniprogram/englishPages/letterTracing/letterTracing.wxml new file mode 100644 index 0000000..de1c762 --- /dev/null +++ b/miniprogram/englishPages/letterTracing/letterTracing.wxml @@ -0,0 +1,100 @@ + + + + + + + 打印预览 + + + + + + + + + ⚙️ + 参数设置 + + + + 练习类型 + + + {{item.label}} + + + + + + 选择字母 + + + {{item}} + + + + + + 大小写 + + + 大写 A–Z + + + 小写 a–z + + + + + + + + + + + + + + + + + + diff --git a/miniprogram/englishPages/shared/service/letterTracingDraw.ts b/miniprogram/englishPages/shared/service/letterTracingDraw.ts new file mode 100644 index 0000000..aa932f0 --- /dev/null +++ b/miniprogram/englishPages/shared/service/letterTracingDraw.ts @@ -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, + ) { + 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, + ) { + 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); + } + } +} diff --git a/miniprogram/pages/age/age.json b/miniprogram/pages/age/age.json index 37e2fd0..5ad12ef 100644 --- a/miniprogram/pages/age/age.json +++ b/miniprogram/pages/age/age.json @@ -4,5 +4,7 @@ "navigationBarBackgroundColor": "#FEF6E7", "backgroundColor": "#FEF6E7", "enablePullDownRefresh": false, - "usingComponents": {} + "usingComponents": { + "nav-bar": "../../components3.0/nav-bar/nav-bar" + } } diff --git a/miniprogram/pages/age/age.less b/miniprogram/pages/age/age.less index 59200f2..ec8153b 100644 --- a/miniprogram/pages/age/age.less +++ b/miniprogram/pages/age/age.less @@ -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; diff --git a/miniprogram/pages/age/age.ts b/miniprogram/pages/age/age.ts index d8f14e7..2a15fa3 100644 --- a/miniprogram/pages/age/age.ts +++ b/miniprogram/pages/age/age.ts @@ -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); }, diff --git a/miniprogram/pages/age/age.wxml b/miniprogram/pages/age/age.wxml index f3f36c5..3e8a996 100644 --- a/miniprogram/pages/age/age.wxml +++ b/miniprogram/pages/age/age.wxml @@ -1,11 +1,7 @@ - - - 按年龄学 - - + - + diff --git a/miniprogram/pages/favorites/favorites.json b/miniprogram/pages/favorites/favorites.json index b6ffb39..958b6cf 100644 --- a/miniprogram/pages/favorites/favorites.json +++ b/miniprogram/pages/favorites/favorites.json @@ -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" } } diff --git a/miniprogram/pages/favorites/favorites.less b/miniprogram/pages/favorites/favorites.less index ce5dc07..3ca37c4 100644 --- a/miniprogram/pages/favorites/favorites.less +++ b/miniprogram/pages/favorites/favorites.less @@ -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; diff --git a/miniprogram/pages/favorites/favorites.ts b/miniprogram/pages/favorites/favorites.ts index 6ebcc71..822eecf 100644 --- a/miniprogram/pages/favorites/favorites.ts +++ b/miniprogram/pages/favorites/favorites.ts @@ -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, }); }, diff --git a/miniprogram/pages/favorites/favorites.wxml b/miniprogram/pages/favorites/favorites.wxml index 53925aa..6a79311 100644 --- a/miniprogram/pages/favorites/favorites.wxml +++ b/miniprogram/pages/favorites/favorites.wxml @@ -1,13 +1,9 @@ - - - 我的收藏 - - + diff --git a/miniprogram/pages/home/home.data.ts b/miniprogram/pages/home/home.data.ts index f0b070f..b02e3a9 100644 --- a/miniprogram/pages/home/home.data.ts +++ b/miniprogram/pages/home/home.data.ts @@ -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: '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', }, ], }, diff --git a/miniprogram/pages/profile/profile.json b/miniprogram/pages/profile/profile.json index e05eddf..988b185 100644 --- a/miniprogram/pages/profile/profile.json +++ b/miniprogram/pages/profile/profile.json @@ -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" } } diff --git a/miniprogram/pages/profile/profile.less b/miniprogram/pages/profile/profile.less index 2170ca9..0120ecb 100644 --- a/miniprogram/pages/profile/profile.less +++ b/miniprogram/pages/profile/profile.less @@ -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; diff --git a/miniprogram/pages/profile/profile.ts b/miniprogram/pages/profile/profile.ts index 22c9680..e1db4e9 100644 --- a/miniprogram/pages/profile/profile.ts +++ b/miniprogram/pages/profile/profile.ts @@ -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() { diff --git a/miniprogram/pages/profile/profile.wxml b/miniprogram/pages/profile/profile.wxml index 32885a4..61e6df8 100644 --- a/miniprogram/pages/profile/profile.wxml +++ b/miniprogram/pages/profile/profile.wxml @@ -1,11 +1,7 @@ - - - 个人中心 - - + - + diff --git a/miniprogram/pages/debug/debug.json b/miniprogram/supportPages/debug/debug.json similarity index 100% rename from miniprogram/pages/debug/debug.json rename to miniprogram/supportPages/debug/debug.json diff --git a/miniprogram/pages/debug/debug.less b/miniprogram/supportPages/debug/debug.less similarity index 100% rename from miniprogram/pages/debug/debug.less rename to miniprogram/supportPages/debug/debug.less diff --git a/miniprogram/pages/debug/debug.ts b/miniprogram/supportPages/debug/debug.ts similarity index 100% rename from miniprogram/pages/debug/debug.ts rename to miniprogram/supportPages/debug/debug.ts diff --git a/miniprogram/pages/debug/debug.wxml b/miniprogram/supportPages/debug/debug.wxml similarity index 100% rename from miniprogram/pages/debug/debug.wxml rename to miniprogram/supportPages/debug/debug.wxml diff --git a/miniprogram/pages/guide/guide.json b/miniprogram/supportPages/guide/guide.json similarity index 100% rename from miniprogram/pages/guide/guide.json rename to miniprogram/supportPages/guide/guide.json diff --git a/miniprogram/pages/guide/guide.less b/miniprogram/supportPages/guide/guide.less similarity index 100% rename from miniprogram/pages/guide/guide.less rename to miniprogram/supportPages/guide/guide.less diff --git a/miniprogram/pages/guide/guide.ts b/miniprogram/supportPages/guide/guide.ts similarity index 100% rename from miniprogram/pages/guide/guide.ts rename to miniprogram/supportPages/guide/guide.ts diff --git a/miniprogram/pages/guide/guide.wxml b/miniprogram/supportPages/guide/guide.wxml similarity index 100% rename from miniprogram/pages/guide/guide.wxml rename to miniprogram/supportPages/guide/guide.wxml