feat: 涂色识字

This commit is contained in:
R524809
2026-05-08 18:36:45 +08:00
parent 85ee5bb49a
commit b85afde7c0
25 changed files with 1205 additions and 441 deletions
@@ -11,13 +11,8 @@
"preview-footer-actions": "../../components3.0/preview-footer-actions/preview-footer-actions",
"debug-publish-tools": "../../components3.0/debug-publish-tools/debug-publish-tools",
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
"draw-ad": "../../components/draw-ad/draw-ad",
"toy-icon": "../../toy/icon/icon",
"toy-button": "../../toy/button-v2/button",
"word-input": "../../components/word-input/word-input",
"word-card": "../../components/word-card/word-card",
"color-picker": "../../components/color-picker/color-picker",
"word-picker": "../../components/word-picker/word-picker",
"empty-state": "../../components/empty-state/empty-state"
"word-picker": "../../components/word-picker/word-picker"
}
}
@@ -8,7 +8,7 @@
.wc-main {
padding: @page-padding-x;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
}
.wc-section {
@@ -17,85 +17,246 @@
.wc-section-title {
display: block;
font-size: 26rpx;
font-size: 30rpx;
font-weight: 700;
color: @text-secondary;
letter-spacing: 0.04em;
color: @text-title;
letter-spacing: 0.02em;
margin-bottom: 20rpx;
}
/* 输入行 */
.wc-input-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 16rpx;
/* 内容设置面板 */
.wc-content-panel {
background: rgba(240, 231, 214, 0.5);
border-radius: @radius-lg;
padding: 32rpx;
}
/* 文字卡片网格 */
.wc-card-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
/* 表单字段 */
.wc-field {
margin-bottom: 32rpx;
&:last-child {
margin-bottom: 0;
}
}
.wc-field-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 4rpx;
margin-bottom: 16rpx;
}
.wc-field-label {
font-size: 24rpx;
font-weight: 600;
color: @text-secondary;
}
.wc-field-hint {
font-size: 20rpx;
font-weight: 500;
color: rgba(96, 91, 80, 0.6);
}
/* 输入框 */
.wc-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.wc-text-input {
width: 100%;
height: 96rpx;
background: #ffffff;
border: 2rpx solid rgba(179, 172, 159, 0.3);
border-radius: @radius-lg;
padding: 0 88rpx 0 28rpx;
font-size: 28rpx;
font-weight: 500;
color: @text-title;
box-shadow: @shadow;
}
.wc-input-clear {
position: absolute;
right: 20rpx;
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
/* 颜色自定义 */
.wc-color-scroll {
white-space: nowrap;
width: 100%;
}
.wc-color-list {
display: inline-flex;
gap: 24rpx;
padding-bottom: 12rpx;
}
/* 操作按钮行 */
.wc-action-row {
display: flex;
flex-direction: row;
.wc-color-item {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
flex-shrink: 0;
}
/* 空状态 */
.wc-empty {
padding: 48rpx 0;
.wc-color-word {
width: 64rpx;
height: 64rpx;
background: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 28rpx;
color: @text-title;
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.05);
border: 2rpx solid rgba(179, 172, 159, 0.1);
}
.wc-color-dot {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
box-shadow: 0 0 0 4rpx #ffffff, 0 2rpx 4rpx rgba(0, 0, 0, 0.05);
}
.wc-color-dot:active {
box-shadow: 0 0 0 4rpx #ffffff, 0 0 0 8rpx @brand, 0 2rpx 4rpx rgba(0, 0, 0, 0.05);
}
.wc-color-add {
flex-direction: row;
justify-content: center;
align-items: center;
align-self: stretch;
}
.wc-color-add-btn {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
border: 2rpx dashed rgba(179, 172, 159, 0.3);
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
/* 分类标签 */
.wc-category-scroll {
white-space: nowrap;
width: 100%;
margin-top: 8rpx;
}
.wc-category-list {
display: inline-flex;
gap: 16rpx;
padding-bottom: 8rpx;
}
.wc-category-tag {
display: inline-flex;
align-items: center;
gap: 10rpx;
flex-shrink: 0;
padding: 20rpx 28rpx;
background: #ffffff;
border: 2rpx solid rgba(179, 172, 159, 0.2);
border-radius: 36rpx;
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.05);
transition: transform 0.12s;
&:active {
transform: scale(0.95);
}
}
.wc-category-tag-icon {
font-size: 28rpx;
line-height: 1;
}
.wc-category-tag-text {
font-size: 22rpx;
font-weight: 700;
color: #6c5a00;
}
.wc-category-tag--more {
.wc-category-tag-icon {
font-size: 24rpx;
letter-spacing: 2rpx;
color: #6c5a00;
}
}
/* 模板选择网格(2列) */
.wc-mode-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
gap: 32rpx;
}
.wc-mode-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 32rpx 16rpx;
gap: 16rpx;
padding: 40rpx 16rpx;
border-radius: @radius-lg;
background: @bg-header;
background: rgba(255, 255, 255, 0.6);
border: 2rpx solid rgba(179, 172, 159, 0.1);
box-shadow: @shadow;
transition: transform 0.12s, box-shadow 0.12s;
}
.wc-mode-card--active {
background: @brand;
box-shadow: @shadow, 0 0 0 4rpx fade(@brand, 30%);
background: #ffffff;
border: 4rpx solid @brand;
box-shadow: @shadow;
}
.wc-mode-card--pressed {
transform: scale(0.96);
transform: scale(0.95);
}
.wc-mode-card__icon {
margin-bottom: 4rpx;
.wc-mode-card__icon-wrap {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background: rgba(255, 198, 151, 0.3);
display: flex;
align-items: center;
justify-content: center;
}
.wc-mode-card__icon-wrap--active {
background: @brand-light;
}
.wc-mode-card__label {
font-size: 26rpx;
font-size: 28rpx;
font-weight: 700;
color: @text-title;
line-height: 1.3;
}
.wc-mode-card--active .wc-mode-card__label {
color: @text-selected-btn;
}
.wc-mode-card__sub {
font-size: 20rpx;
color: @text-secondary;
@@ -103,6 +264,19 @@
line-height: 1.3;
}
.wc-mode-card--active .wc-mode-card__sub {
color: fade(@text-selected-btn, 75%);
.wc-mode-card--active .wc-mode-card__label {
color: @text-title;
}
.wc-mode-card__check {
position: absolute;
top: 12rpx;
right: 12rpx;
display: flex;
align-items: center;
justify-content: center;
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background-color: #6c5a00;
}
@@ -22,6 +22,13 @@ const COLOR_MAP: Record<string, string> = {
: '#FF69B4', : '#A52A2A', : '#808080',
};
const MAX_WORDS = 3;
const CATEGORY_TAGS = WORDS.slice(0, MAX_WORDS).map((cat) => ({
categoryId: cat.categoryId,
icon: cat.icon,
categoryName: cat.categoryName,
}));
const pageInfoLookup = getModeInfo;
// PLACEHOLDER_TS_PART2
@@ -30,10 +37,13 @@ type PageData = CanvasDataState & {
worksheetId: string;
cardList: CardItem[];
selectedWords: string[];
inputValue: string;
showColorPopup: boolean;
currentKey: number;
currentColor: string;
showSelectWordPopup: boolean;
pickerCurrentTab: number;
categoryTags: typeof CATEGORY_TAGS;
modeOptions: typeof WORD_COLORING_MODE_OPTIONS;
isPreviewFavorite: boolean;
isDevEnv: boolean;
@@ -59,10 +69,13 @@ createPage(
worksheetId: 'word-coloring-grid',
cardList: [] as CardItem[],
selectedWords: [] as string[],
inputValue: '',
showColorPopup: false,
currentKey: 0,
currentColor: '',
showSelectWordPopup: false,
pickerCurrentTab: 0,
categoryTags: CATEGORY_TAGS,
modeOptions: WORD_COLORING_MODE_OPTIONS,
isPreviewFavorite: false,
isDevEnv: false,
@@ -84,6 +97,7 @@ createPage(
});
this.initPageInfo(worksheetId, '涂色识字');
this.loadFavoritedMap();
this.refreshCardList();
},
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
@@ -174,75 +188,83 @@ createPage(
word,
color: COLOR_MAP[word] || colors[i % colors.length].hex,
}));
this.setData({ cardList, selectedWords: selected }, () => {
const inputValue = selected.join('');
this.setData({ cardList, selectedWords: selected, inputValue }, () => {
this.drawCanvas();
});
},
/** 清空文字卡片 */
clearCardList() {
this.setData({ cardList: [], selectedWords: [], hasContent: false });
this.setData({ cardList: [], selectedWords: [], inputValue: '', hasContent: false });
},
// PLACEHOLDER_TS_PART4
/** 输入确认:添加文字卡片 */
onConfirmInput(e: WechatMiniprogram.Input) {
const { cardList } = this.data;
const { value } = e.detail;
const characters = value.split('');
const remainingSlots = 6 - cardList.length;
if (remainingSlots <= 0) {
wx.showToast({ title: '最多只能添加6个字哦', icon: 'none' });
return;
/** 输入框内容变化:同步 cardList */
onInputChange(e: WechatMiniprogram.Input) {
const value = e.detail.value as string;
this.syncCardsFromInput(value);
},
/** 输入框确认 */
onInputConfirm(e: WechatMiniprogram.Input) {
const value = e.detail.value as string;
this.syncCardsFromInput(value);
},
/** 清空输入框及卡片 */
onClearInput() {
this.setData({ inputValue: '', cardList: [], selectedWords: [], hasContent: false });
if (this.drawService) {
this.drawCanvas();
}
},
const usedColors = cardList.map((item) => item.color);
const usedWords = cardList.map((item) => item.word);
const allColors = WATER_COLORS.basic12.map((c) => c.hex);
const available = allColors.filter((c) => !usedColors.includes(c));
const shuffled = [...available].sort(() => Math.random() - 0.5);
/** 将输入文本同步为 cardList */
syncCardsFromInput(value: string) {
const chars = [...new Set(value.split('').filter((c: string) => c.trim()))].slice(0, 6);
const oldCardMap = new Map<string, string>(this.data.cardList.map((c: CardItem) => [c.word, c.color]));
const allColors = WATER_COLORS.basic12.map((c: { hex: string }) => c.hex);
const usedColors = new Set<string>();
const newChars: string[] = [];
for (const char of characters) {
if (newChars.length >= remainingSlots) break;
if (!usedWords.includes(char) && !newChars.includes(char)) {
newChars.push(char);
const cardList = chars.map((char) => {
const existing = oldCardMap.get(char);
if (existing) {
usedColors.add(existing);
return { word: char, color: existing };
}
}
const newCards = newChars.map((char, i) => {
const mapped = COLOR_MAP[char];
const color =
mapped && !usedColors.includes(mapped)
? mapped
: shuffled[i % shuffled.length];
if (mapped && !usedColors.has(mapped)) {
usedColors.add(mapped);
return { word: char, color: mapped };
}
const available = allColors.filter((c) => !usedColors.has(c));
const shuffled = [...available].sort(() => Math.random() - 0.5);
const color = shuffled[0] || allColors[Math.floor(Math.random() * allColors.length)];
usedColors.add(color);
return { word: char, color };
});
const updated = [...cardList, ...newCards];
this.setData(
{ cardList: updated, selectedWords: updated.map((c) => c.word) },
{ inputValue: value, cardList, selectedWords: chars },
() => this.drawCanvas(),
);
},
/** 删除单个文字卡片 */
deleteWordCard(e: WechatMiniprogram.CustomEvent) {
const { key } = e.detail;
const updated = this.data.cardList.filter((_, i) => i !== key);
this.setData(
{ cardList: updated, selectedWords: updated.map((c) => c.word) },
() => this.drawCanvas(),
);
},
/** 颜色选择相关 */
onColorTap(e: WechatMiniprogram.CustomEvent) {
const { key, color } = e.detail;
/** 颜色自定义中的颜色点点击 */
onColorDotTap(e: WechatMiniprogram.TouchEvent) {
const key = Number(e.currentTarget.dataset.key);
const color = e.currentTarget.dataset.color as string;
this.setData({ showColorPopup: true, currentKey: key, currentColor: color });
},
/** 分类标签点击:打开弹窗定位到对应分类 */
onCategoryTap(e: WechatMiniprogram.TouchEvent) {
const categoryId = Number(e.currentTarget.dataset.categoryId);
this.setData({ showSelectWordPopup: true, pickerCurrentTab: categoryId });
},
onCloseColorPopup() {
this.setData({ showColorPopup: false, currentKey: 0, currentColor: '' });
},
@@ -259,7 +281,7 @@ createPage(
/** 文字选择弹窗 */
openSelectWordPopup() {
this.setData({ showSelectWordPopup: true });
this.setData({ showSelectWordPopup: true, pickerCurrentTab: 0 });
},
closeSelectWordPopup() {
@@ -268,12 +290,30 @@ createPage(
onChangeWord(e: WechatMiniprogram.CustomEvent) {
const selectedWords = e.detail.selectedWords as string[];
const colors = [...WATER_COLORS.basic12];
const cardList = selectedWords.map((word, i) => ({
word,
color: COLOR_MAP[word] || colors[i % colors.length].hex,
}));
this.setData({ showSelectWordPopup: false, cardList, selectedWords }, () => {
const oldCardMap = new Map<string, string>(this.data.cardList.map((c: CardItem) => [c.word, c.color]));
const allColors = WATER_COLORS.basic12.map((c: { hex: string }) => c.hex);
const usedColors = new Set<string>();
const cardList = selectedWords.map((word) => {
const existing = oldCardMap.get(word);
if (existing) {
usedColors.add(existing);
return { word, color: existing };
}
const mapped = COLOR_MAP[word];
if (mapped && !usedColors.has(mapped)) {
usedColors.add(mapped);
return { word, color: mapped };
}
const available = allColors.filter((c) => !usedColors.has(c));
const shuffled = [...available].sort(() => Math.random() - 0.5);
const color = shuffled[0] || allColors[Math.floor(Math.random() * allColors.length)];
usedColors.add(color);
return { word, color };
});
const inputValue = selectedWords.join('');
this.setData({ showSelectWordPopup: false, cardList, selectedWords, inputValue }, () => {
this.drawCanvas();
});
},
@@ -11,67 +11,102 @@
bind:refresh="onPreviewRefresh"
bind:favorite="onPreviewFavorite" />
<!-- 文字输入区域 -->
<!-- 内容设置 -->
<view class="wc-section">
<text class="wc-section-title">添加文字</text>
<view class="wc-input-row">
<word-input bind:onConfirm="onConfirmInput" width="380rpx" />
<toy-button
type="green"
size="small"
bind:click="openSelectWordPopup"
width="180rpx">
选择文字
</toy-button>
<text class="wc-section-title">内容设置</text>
<view class="wc-content-panel">
<!-- 自定义文字 -->
<view class="wc-field">
<view class="wc-field-header">
<text class="wc-field-label">自定义文字</text>
<text class="wc-field-hint">最多输入6个字</text>
</view>
<view class="wc-input-wrapper">
<input
class="wc-text-input"
type="text"
maxlength="6"
placeholder="输入想要练习的汉字"
value="{{inputValue}}"
bindinput="onInputChange"
bindconfirm="onInputConfirm" />
<view
wx:if="{{inputValue.length > 0}}"
class="wc-input-clear"
bindtap="onClearInput">
<toy-icon
name="close-bold"
size="36rpx"
color="#605b50" />
</view>
</view>
</view>
<!-- 颜色自定义 -->
<view class="wc-field">
<view class="wc-field-header">
<text class="wc-field-label">颜色自定义</text>
<text class="wc-field-hint">点击圆点修改颜色</text>
</view>
<scroll-view scroll-x class="wc-color-scroll">
<view class="wc-color-list">
<view
wx:for="{{cardList}}"
wx:key="index"
class="wc-color-item">
<view class="wc-color-word">{{item.word}}</view>
<view
class="wc-color-dot"
style="background-color: {{item.color}}"
data-key="{{index}}"
data-color="{{item.color}}"
bindtap="onColorDotTap"></view>
</view>
<!-- 添加按钮 -->
<view class="wc-color-item wc-color-add">
<view
class="wc-color-add-btn"
bindtap="openSelectWordPopup">
<toy-icon
name="plus"
size="36rpx"
color="rgba(96,91,80,0.4)" />
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 分类标签 -->
<scroll-view scroll-x class="wc-category-scroll">
<view class="wc-category-list">
<view
wx:for="{{categoryTags}}"
wx:key="categoryId"
class="wc-category-tag"
data-category-id="{{item.categoryId}}"
bindtap="onCategoryTap">
<text class="wc-category-tag-icon"
>{{item.icon}}</text
>
<text class="wc-category-tag-text"
>{{item.categoryName}}</text
>
</view>
<view
class="wc-category-tag wc-category-tag--more"
bindtap="openSelectWordPopup">
<text class="wc-category-tag-icon">···</text>
<text class="wc-category-tag-text">更多分类</text>
</view>
</view>
</scroll-view>
</view>
</view>
<!-- 文字卡片 -->
<view wx:if="{{cardList.length > 0}}" class="wc-section">
<view class="wc-card-grid">
<word-card
wx:for="{{cardList}}"
wx:key="index"
key="{{index}}"
word="{{item.word}}"
color="{{item.color}}"
bind:onDelete="deleteWordCard"
bind:onColorTap="onColorTap" />
</view>
</view>
<view wx:elif="{{!hasContent}}" class="wc-empty">
<empty-state
title="还没有添加文字"
desc="输入文字或点击选择文字,开始创建涂鸦卡"
hint="提示:最多可以同时添加 6 个文字" />
</view>
<!-- 操作按钮 -->
<!-- 模板选择 -->
<view class="wc-section">
<view class="wc-action-row">
<toy-button
type="primary"
bind:click="refreshCardList"
width="380rpx"
icon="refresh"
icon-class-prefix="toy-icon">
随机生成
</toy-button>
<toy-button
disabled="{{cardList.length <= 0}}"
type="white"
bind:click="clearCardList"
width="180rpx"
icon="clear"
icon-class-prefix="toy-icon">
清空
</toy-button>
</view>
</view>
<!-- 模板选择(2列卡片网格) -->
<view class="wc-section">
<text class="wc-section-title">选择模板</text>
<text class="wc-section-title">模板选择</text>
<view class="wc-mode-grid">
<view
wx:for="{{modeOptions}}"
@@ -82,18 +117,23 @@
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectMode">
<toy-icon
name="{{item.icon}}"
size="42rpx"
color="{{worksheetId === item.id ? '#453900' : '#605b50'}}"
custom-class="wc-mode-card__icon" />
<view
class="wc-mode-card__icon-wrap {{worksheetId === item.id ? 'wc-mode-card__icon-wrap--active' : ''}}">
<toy-icon
name="{{item.icon}}"
size="56rpx"
color="{{worksheetId === item.id ? '#6c5a00' : '#8a4c00'}}" />
</view>
<text class="wc-mode-card__label">{{item.title}}</text>
<text class="wc-mode-card__sub">{{item.subtitle}}</text>
<view
wx:if="{{worksheetId === item.id}}"
class="wc-mode-card__check">
<toy-icon name="check" size="28rpx" color="#fff" />
</view>
</view>
</view>
</view>
<draw-ad type="mathDraw"></draw-ad>
</view>
</view>
@@ -126,5 +166,6 @@
<word-picker
show="{{showSelectWordPopup}}"
cardList="{{cardList}}"
currentTab="{{pickerCurrentTab}}"
bind:onClose="closeSelectWordPopup"
bind:onChange="onChangeWord" />