feat: 开发练字页

This commit is contained in:
R524809
2025-09-30 17:48:31 +08:00
parent 3c6218616c
commit 9df15e1ab4
8 changed files with 451 additions and 1 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"navigationBarTitleText": "练字",
"usingComponents": {
"toy-button": "/ui/button/button",
"van-icon": "@vant/weapp/icon/index",
"word-input": "/components/word-input/word-input",
"word-card": "/components/word-card/word-card",
"word-picker": "/components/word-picker/word-picker"
}
}
+147
View File
@@ -0,0 +1,147 @@
page {
background-color: #f6f6f6;
}
.page-container {
background-color: #f6f6f6;
padding: 0 24rpx;
box-sizing: border-box;
}
.wrapper {
background-color: #ffffff;
border-radius: 20rpx;
padding: 36rpx 24rpx;
box-shadow: 0 6rpx 8rpx rgba(0, 0, 0, 0.15);
margin: 30rpx 0;
display: flex;
flex-direction: column;
.wrapper-title {
font-size: 32rpx;
color: #141414;
margin-bottom: 36rpx;
font-weight: bold;
text-align: center;
}
.word-card-box {
padding: 34rpx 0;
.word-list-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 40rpx;
text-align: center;
.empty-icon {
width: 120rpx;
height: 120rpx;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
box-shadow: 0 4rpx 12rpx rgba(59, 130, 246, 0.15);
.toy-icon {
font-size: 48rpx;
color: #3b82f6;
}
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #374151;
margin-bottom: 16rpx;
line-height: 1.4;
}
.empty-desc {
font-size: 26rpx;
color: #6b7280;
line-height: 1.6;
margin-bottom: 32rpx;
max-width: 400rpx;
}
}
.word-cards-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 40rpx;
}
}
.word-input-box {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
.tianzige {
display: grid;
grid-auto-rows: min-content;
row-gap: 12rpx;
}
.tzg-row {
display: grid;
grid-template-columns: repeat(12, 1fr);
column-gap: 12rpx;
}
.tzg-cell {
position: relative;
background: #fff;
border: 4rpx solid #333;
height: 120rpx;
border-radius: 6rpx;
overflow: hidden;
}
.tzg-word {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 72rpx;
color: #222;
font-family: 'Kaiti', 'KaiTi', 'STKaiti', 'FZKai-Z03S', serif;
}
.mid-line {
position: absolute;
background: repeating-linear-gradient(to right,
rgba(0, 0, 0, 0.25),
rgba(0, 0, 0, 0.25) 2rpx,
transparent 2rpx,
transparent 6rpx);
}
.mid-line.h {
left: 0;
right: 0;
top: 50%;
height: 2rpx;
transform: translateY(-50%);
}
.mid-line.v {
top: 0;
bottom: 0;
left: 50%;
width: 2rpx;
transform: translateX(-50%);
}
.empty-tip {
text-align: center;
color: #999;
font-size: 28rpx;
}
+91
View File
@@ -0,0 +1,91 @@
Page({
data: {
// 选中字卡列表,复用 index 页的结构 [{ word, color }]
cardList: [] as { word: string; color: string }[],
showSelectWordPopup: false,
// 田字格配置
rows: 10,
cols: 12,
rowsArray: [] as number[],
colsArray: [] as number[],
rowHeaderWords: [] as string[],
},
onLoad() {
this.initGrid();
},
// 输入组件回调
onConfirmInput(e: any) {
const value: string = (e && e.detail && e.detail.value) || '';
const text = (value || '').trim();
if (!text) return;
if (!this.isChineseText(text)) {
wx.showToast({ title: '请输入汉字', icon: 'none' });
return;
}
const chars = this.splitToSingleChars(text);
const { cardList } = this.data as any;
const exists = new Set(cardList.map((c: any) => c.word));
const newList = [...cardList];
chars.forEach((ch) => {
if (!exists.has(ch)) {
newList.push({ word: ch, color: '#141414' });
}
});
this.setData({ cardList: newList }, () => this.updateRowHeaderWords());
},
// 选择面板
openSelectWordPopup() {
this.setData({ showSelectWordPopup: true });
},
closeSelectWordPopup() {
this.setData({ showSelectWordPopup: false });
},
onChangeWord(e: any) {
const { cardList } = e.detail || {};
this.setData({ cardList }, () => this.updateRowHeaderWords());
},
// 删除与颜色
deleteWordCard(e: any) {
const index = e.detail && e.detail.index;
const { cardList } = this.data as any;
const next = cardList.filter((_: any, i: number) => i !== index);
this.setData({ cardList: next }, () => this.updateRowHeaderWords());
},
onColorTap() { },
// 校验 & 分割
isChineseText(text: string): boolean {
const chineseRegex = /^[\u4e00-\u9fff]+$/;
return chineseRegex.test(text);
},
splitToSingleChars(text: string): string[] {
const chineseRegex = /[\u4e00-\u9fff]/g;
const matches = text.match(chineseRegex);
return matches || [];
},
// 田字格
initGrid() {
const { rows, cols } = this.data as any;
const rowsArray = Array.from({ length: rows }, (_, i) => i);
const colsArray = Array.from({ length: cols }, (_, i) => i);
this.setData({ rowsArray, colsArray });
},
updateRowHeaderWords() {
const { rows, cardList } = this.data as any;
const rowHeaderWords: string[] = [];
for (let i = 0; i < rows; i++) {
rowHeaderWords.push((cardList[i] && cardList[i].word) || '');
}
this.setData({ rowHeaderWords });
},
});
+67
View File
@@ -0,0 +1,67 @@
<view class="page-container">
<view class="wrapper">
<text class="wrapper-title">自定义练字</text>
<view class="word-input-box">
<word-input bind:onConfirm="onConfirmInput" width="410rpx" />
<toy-button
type="green"
bind:click="openSelectWordPopup"
width="200rpx">
选择文字
</toy-button>
</view>
<view class="word-card-box">
<view class="word-cards-grid" wx:if="{{cardList.length > 0}}">
<word-card
wx:key="index"
wx:for="{{cardList}}"
wx:for-item="item"
wx:for-index="index"
word="{{item.word}}"
color="{{item.color}}"
bind:onDelete="deleteWordCard"
bind:onColorTap="onColorTap" />
</view>
<view class="word-list-empty" wx:if="{{!cardList.length}}">
<view class="empty-icon">
<text class="toy-icon toy-icon-translate"></text>
</view>
<view class="empty-title">还没有添加文字</view>
<view class="empty-desc"
>请在上方输入文字或点击"选择文字"按钮<br />开始创建你的练字田字格吧!</view
>
</view>
</view>
</view>
<view id="previewWrapper" class="wrapper">
<text class="wrapper-title">预览练字田字格</text>
<view class="tianzige" wx:if="{{rowHeaderWords.length > 0}}">
<view
class="tzg-row"
wx:for="{{rowsArray}}"
wx:for-index="rowIndex"
wx:key="rowIndex">
<view
class="tzg-cell"
wx:for="{{colsArray}}"
wx:for-index="colIndex"
wx:key="{{rowIndex}}-{{colIndex}}">
<text wx:if="{{colIndex === 0}}" class="tzg-word"
>{{rowHeaderWords[rowIndex]}}</text
>
<view class="mid-line h"></view>
<view class="mid-line v"></view>
</view>
</view>
</view>
<view wx:else class="empty-tip">请选择或输入汉字后生成田字格</view>
</view>
<word-picker
show="{{showSelectWordPopup}}"
cardList="{{cardList}}"
bind:onClose="closeSelectWordPopup"
bind:onChange="onChangeWord" />
</view>
+78
View File
@@ -10,6 +10,84 @@ page {
overflow-y: auto;
}
.preview-section {
margin-top: 24rpx;
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
box-shadow: 0 6rpx 8rpx rgba(0, 0, 0, 0.15);
}
.section-title {
display: block;
text-align: center;
font-size: 32rpx;
font-weight: bold;
margin-bottom: 24rpx;
}
.tianzige {
display: grid;
grid-auto-rows: min-content;
row-gap: 12rpx;
}
.tzg-row {
display: grid;
grid-template-columns: repeat(12, 1fr);
column-gap: 12rpx;
}
.tzg-cell {
position: relative;
background: #fff;
border: 4rpx solid #333;
height: 120rpx;
border-radius: 6rpx;
overflow: hidden;
}
.tzg-word {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 72rpx;
color: #222;
font-family: 'Kaiti', 'KaiTi', 'STKaiti', 'FZKai-Z03S', serif;
}
.mid-line {
position: absolute;
background: repeating-linear-gradient(to right,
rgba(0, 0, 0, 0.25),
rgba(0, 0, 0, 0.25) 2rpx,
transparent 2rpx,
transparent 6rpx);
}
.mid-line.h {
left: 0;
right: 0;
top: 50%;
height: 2rpx;
transform: translateY(-50%);
}
.mid-line.v {
top: 0;
bottom: 0;
left: 50%;
width: 2rpx;
transform: translateX(-50%);
}
.empty-tip {
text-align: center;
color: #999;
font-size: 28rpx;
}
/* 输入区域样式 */
.input-section {
background-color: #fff;
+31
View File
@@ -3,10 +3,17 @@ Page({
inputWord: '', // 输入框中的汉字
wordList: [] as string[], // 选中的汉字列表
availableWords: [] as string[], // 从grade3.json中提取的前30个汉字
// 田字格配置
rows: 10,
cols: 12,
rowsArray: [] as number[],
colsArray: [] as number[],
rowHeaderWords: [] as string[],
},
onLoad() {
this.loadGrade3Words();
this.initGrid();
},
// 加载grade3.json中的前30个汉字
@@ -70,6 +77,8 @@ Page({
this.setData({
wordList: newWordList,
inputWord: '' // 清空输入框
}, () => {
this.updateRowHeaderWords();
});
wx.showToast({
@@ -128,6 +137,8 @@ Page({
const newWordList = wordList.filter((_, i) => i !== index);
this.setData({
wordList: newWordList
}, () => {
this.updateRowHeaderWords();
});
wx.showToast({
title: '已移除',
@@ -139,10 +150,30 @@ Page({
onClearAll() {
this.setData({
wordList: []
}, () => {
this.updateRowHeaderWords();
});
wx.showToast({
title: '已清空',
icon: 'success'
});
},
// 初始化田字格行列数组
initGrid() {
const { rows, cols } = this.data as any;
const rowsArray = Array.from({ length: rows }, (_, i) => i);
const colsArray = Array.from({ length: cols }, (_, i) => i);
this.setData({ rowsArray, colsArray });
},
// 根据选中的汉字生成每行首字数组
updateRowHeaderWords() {
const { rows, wordList } = this.data as any;
const rowHeaderWords: string[] = [];
for (let i = 0; i < rows; i++) {
rowHeaderWords.push(wordList[i] || '');
}
this.setData({ rowHeaderWords });
},
});
+25
View File
@@ -61,3 +61,28 @@
</view>
</view>
</view>
<!-- 预览:田字格 -->
<view class="preview-section">
<text class="section-title">预览练字田字格</text>
<view wx:if="{{rowHeaderWords.length > 0}}" class="tianzige">
<view
class="tzg-row"
wx:for="{{rowsArray}}"
wx:for-index="rowIndex"
wx:key="rowIndex">
<view
class="tzg-cell"
wx:for="{{colsArray}}"
wx:for-index="colIndex"
wx:key="{{rowIndex}}-{{colIndex}}">
<text wx:if="{{colIndex === 0}}" class="tzg-word"
>{{rowHeaderWords[rowIndex]}}</text
>
<view class="mid-line h"></view>
<view class="mid-line v"></view>
</view>
</view>
</view>
<view wx:else class="empty-tip">请选择或输入汉字后生成田字格</view>
</view>