feat:绘制图形

This commit is contained in:
2025-09-12 16:56:00 +08:00
parent 3b4b2dcd64
commit 6acc5d676c
27 changed files with 76464 additions and 112 deletions
+26
View File
@@ -0,0 +1,26 @@
import fs from 'fs';
// 读取grade3.json文件
const data = JSON.parse(
fs.readFileSync('miniprogram/demo/grade3.json', 'utf8'),
);
// 提取所有汉字
const hanzi = Object.keys(data);
// 生成TypeScript代码
const output = `/**
* 从 grade3.json 中提取的所有汉字
* 总共 ${hanzi.length} 个汉字
*/
export const SUPPORT_HANZI: string[] = [
${hanzi.map((h) => `'${h}'`).join(', ')}
];
`;
// 写入文件
fs.writeFileSync('miniprogram/constants/copyWords.ts', output);
console.log(`Generated copyWords.ts with ${hanzi.length} hanzi`);
console.log('First 10:', hanzi.slice(0, 10));
console.log('Last 10:', hanzi.slice(-10));
BIN
View File
Binary file not shown.
+7 -1
View File
@@ -3,7 +3,7 @@
"pages/index/index", "pages/index/index",
"pages/shape/index", "pages/shape/index",
"pages/debug/debug", "pages/debug/debug",
"demoPages/shapePrint/index" "pages/wordDemo/index"
], ],
"window": {}, "window": {},
"style": "v2", "style": "v2",
@@ -40,6 +40,12 @@
"iconPath": "assets/tabBar/icon-shape.png", "iconPath": "assets/tabBar/icon-shape.png",
"selectedIconPath": "assets/tabBar/icon-shape-active.png", "selectedIconPath": "assets/tabBar/icon-shape-active.png",
"text": "图形" "text": "图形"
},
{
"pagePath": "pages/wordDemo/index",
"iconPath": "assets/tabBar/icon-word.png",
"selectedIconPath": "assets/tabBar/icon-word-active.png",
"text": "练字"
} }
] ]
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

@@ -38,6 +38,6 @@
position: absolute; position: absolute;
top: -16rpx; top: -16rpx;
right: -16rpx; right: -16rpx;
color: rgba(255, 0, 0); color: #ff0000;
} }
} }
@@ -77,6 +77,18 @@
width: 48rpx; width: 48rpx;
height: 48rpx; height: 48rpx;
} }
&.checked-disabled {
border: 1px solid #c2c2c2;
}
.img-shape-checked-disabled {
position: absolute;
bottom: -2rpx;
right: -2rpx;
width: 48rpx;
height: 48rpx;
}
} }
} }
} }
@@ -10,6 +10,10 @@ Component({
type: Boolean, type: Boolean,
value: false, value: false,
}, },
selectedShapeList: { // 已选中的shapeList,用于初始化
type: Array,
value: [],
},
selectedShapeIndex: { selectedShapeIndex: {
type: Number, type: Number,
value: -1, value: -1,
@@ -24,39 +28,72 @@ Component({
totalSelected: 0, totalSelected: 0,
}, },
lifetimes: { lifetimes: {
attached() { // attached() {
// 处理 SVG 数据,转换为 data URL // // 处理 SVG 数据,转换为 data URL
const shapesWithDataUrl = SHAPES.map(shape => { // const shapesWithDataUrl = SHAPES.map(shape => {
const completeSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">${shape.svg}</svg>`; // const completeSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">${shape.svg}</svg>`;
const svgDataUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(completeSvg)}`; // const svgDataUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(completeSvg)}`;
return { // return {
...shape, // ...shape,
svgDataUrl: svgDataUri // svgDataUrl: svgDataUri
}; // };
}); // });
// this.setData({
// shapes: shapesWithDataUrl
// });
// }
},
observers: {
// selectedShapeIndex(index: number) {
// const { shapes } = this.data;
// const newShapes = shapes.map((item, i) => ({
// ...item,
// checked: i === index
// }));
// this.setData({
// singleMode: index !== -1,
// totalSelected: index !== -1 ? 1 : 0,
// shapes: newShapes
// });
// },
'selectedShapeList, selectedShapeIndex': function (selectedShapeList: ShapeCard[], index: number) {
let newShapes = [];
let checkedIds: string[] = selectedShapeList.map(item => item.id);
if (index !== -1) { // 单选模式
let singleShapeId = selectedShapeList[index].id;
newShapes = SHAPES.map(shape => {
const completeSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">${shape.svg}</svg>`;
const svgDataUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(completeSvg)}`;
return {
...shape,
svgDataUrl: svgDataUri,
checked: singleShapeId === shape.id,
checkedDisabled: singleShapeId !== shape.id && checkedIds.includes(shape.id)
}
});
} else { // 多选模式
newShapes = SHAPES.map(shape => {
const completeSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">${shape.svg}</svg>`;
const svgDataUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(completeSvg)}`;
return {
...shape,
svgDataUrl: svgDataUri,
checked: checkedIds.includes(shape.id)
}
});
}
this.setData({ this.setData({
shapes: shapesWithDataUrl shapes: newShapes,
totalSelected: newShapes.filter(item => item.checked).length,
singleMode: index !== -1,
}); });
} }
}, },
observers: {
selectedShapeIndex(newVal: number) {
this.setData({
singleMode: newVal !== -1
});
},
},
// pageLifetimes: {
// show: function () {
// console.log('show singleMode', this.data.singleMode);
// },
// hide: function () {
// console.log('hide singleMode', this.data.singleMode);
// },
// resize: function (size) {
// console.log('resize', size);
// }
// },
/** /**
* 组件的方法列表 * 组件的方法列表
*/ */
@@ -70,6 +107,16 @@ Component({
const { singleMode, shapes } = this.data; const { singleMode, shapes } = this.data;
let newShapes = []; let newShapes = [];
const selectedShape = shapes[index];
if (selectedShape.checkedDisabled) {
wx.showToast({
title: '当前形状已选中,请勿重复选中',
icon: 'none',
duration: 1500
});
return;
}
if (singleMode) { if (singleMode) {
// 单选模式:currentShapeKey 不为空时,只能选中一个形状 // 单选模式:currentShapeKey 不为空时,只能选中一个形状
newShapes = shapes.map((item, i) => ({ newShapes = shapes.map((item, i) => ({
@@ -78,7 +125,7 @@ Component({
})); }));
} else { } else {
// 多选模式:currentShapeKey 为空时,支持多选,最多可选6个 // 多选模式:currentShapeKey 为空时,支持多选,最多可选6个
newShapes = this.data.shapes.map(item => ({ ...item })); newShapes = shapes.map(item => ({ ...item }));
const targetIndex = index; const targetIndex = index;
// 统计当前已选中的数量 // 统计当前已选中的数量
@@ -7,11 +7,11 @@
选择形状{{ singleMode ? '(单选)' : '(多选)' }} 选择形状{{ singleMode ? '(单选)' : '(多选)' }}
</text> </text>
<view class="shape-options-wrapper"> <view class="shape-options-wrapper">
<view class="shape-options"> <view class="shape-options {{ singleMode ? 'single-mode' : '' }}">
<view <view
wx:for="{{shapes}}" wx:for="{{shapes}}"
wx:key="id" wx:key="id"
class="shape-option {{item.checked ? 'selected' : '' }}" class="shape-option {{item.checked ? 'selected' : '' }} {{ item.checkedDisabled ? 'checked-disabled' : '' }}"
bindtap="onSelectShape" bindtap="onSelectShape"
data-index="{{index}}" data-index="{{index}}"
data-shape="{{item.id}}"> data-shape="{{item.id}}">
@@ -25,6 +25,11 @@
class="img-shape-selected" class="img-shape-selected"
src="/assets/imgs/checked.png" src="/assets/imgs/checked.png"
mode="aspectFit" /> mode="aspectFit" />
<image
wx:if="{{item.checkedDisabled}}"
class="img-shape-checked-disabled"
src="/assets/imgs/checked-disabled.png"
mode="aspectFit" />
</view> </view>
</view> </view>
</view> </view>
File diff suppressed because one or more lines are too long
+1
View File
@@ -15,6 +15,7 @@ interface ShapeCard {
fillColor: string; fillColor: string;
drawFunction?: string; drawFunction?: string;
checked?: boolean; checked?: boolean;
checkedDisabled?: boolean;
} }
const SHAPES: ShapeCard[] = [ const SHAPES: ShapeCard[] = [
File diff suppressed because it is too large Load Diff
+59680
View File
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title>田字格字帖生成器</title>
<style>
body,
div,
p,
ul,
li {
padding: 0;
margin: 0;
list-style: none;
}
div {
width: 938px;
margin: 0 auto;
padding-left: 2px;
}
li {
float: left;
width: 80px;
height: 80px;
font-family: "楷体", "楷体_gb2312";
font-size: 58px;
text-align: center;
line-height: 85px;
background: url(images/bg12.jpg);
margin: 4px 0px 5px -2px;
color: #b8b8b8;
}
li.f {
color: #000;
/**margin-left: -0px*/
}
li.svg {
line-height: 72px;
}
li svg {
margin: 8px;
vertical-align: middle;
}
.afterpage {
page-break-after: always;
}
@media print {
li {
background: url(images/bg12.jpg);
}
.afterpage {
page-break-after: always;
}
.noprint {
display: none;
}
}
</style>
</head>
<body>
<div>
<div class="noprint">
<textarea id="to_print" style='margin: 0px; width: 930px; height: 175px'></textarea>
<div style='align-items:center; margin: 0px; width: 930px; height: 20px'>
<input type="submit" onclick='onclick_gen();' value="生成" />
<input type="submit" onclick='window.print();' value="打印" />
</div>
</div>
<ul id="u_printf">
</ul>
</div>
<script id="myword" charset="UTF-8" src="mwords.json"></script>
<script>
console.log(words["舨"]);
let template_li = '<li class="svg"><svg width="54" height="54" version="1.1" xmlns="http://www.w3.org/2000/svg">##paths##</svg></li>';
let empty_li1 = '<li class="f">&nbsp;</li>';
let empty_li2 = '';
for (let i = 0; i < 11; i++) { empty_li2 += "<li>&nbsp;</li>" }
let first_paths = '<path d="##path_d##" style="fill:rgb(0,0,0);stroke:rgb(0,0,0);" stroke-width="1.5"></path>';
let second_paths = '<path d="##path_d##" style="fill:rgb(184,184,184);stroke:rgb(184,184,184);" stroke-width="1.6"></path>';
second_paths = '<path d="##path_d##" style="fill:rgb(184,184,184);stroke:#999;" stroke-width="1.6"></path>';
let d_key = '##path_d##';
let p_key = '##paths##';
u_printf.innerHTML = "";
let preview = (c) => {
let pre_li = "";
ds = words[c];
if (!ds) {
return;
}
len = ds.length;
f_path = "";
for (let j = 0; j < len; j++) {
f_path += first_paths.replace(d_key, ds[j]);
}
pre_li = template_li.replace(p_key, f_path);;
return pre_li;
};
let practice = (c) => {
ds = words[c];
if (!ds) {
return;
}
len = ds.length;
let bihua_li = "";
let s_path = "";
for (let bihua = 0; bihua < len; bihua++) {
s_path = "";
for (let i = 0; i <= bihua; i++) {
s_path += second_paths.replace(d_key, ds[i]);
}
bihua_li += template_li.replace(p_key, s_path);
}
for (let i = 0; i < 23 - len; i++) {
bihua_li += template_li.replace(p_key, s_path);
}
bihua_li += empty_li1;
bihua_li += empty_li2;
return bihua_li;
}
let print_char = (c) => {
return preview(c) + practice(c);
}
let print_sentence = (s) => {
sen_li = "";
for (let i in s) {
sen_li += print_char(s[i]);
}
return sen_li;
}
onclick_gen = () => {
u_printf.innerHTML = "";
lis = print_sentence(to_print.value.replace(/ /g,"").trim());
u_printf.innerHTML = lis;
}
</script>
</body>
</html>
-7
View File
@@ -1,7 +0,0 @@
{
"navigationBarTitleText": "首页",
"navigationBarBackgroundColor": "#d2e7d8",
"homeButton": true,
"backgroundColor": "#e8eddb",
"enablePullDownRefresh": false
}
+184 -18
View File
@@ -3,31 +3,197 @@ page {
height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: #f5f5f5;
}
.container {
padding: 20rpx;
height: 100vh;
overflow-y: auto;
}
/* 输入区域样式 */
.input-section {
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
}
.input-row {
display: flex;
align-items: center;
gap: 20rpx;
}
.word-input {
flex: 1;
height: 80rpx;
border: 2rpx solid #e0e0e0;
border-radius: 40rpx;
padding: 0 30rpx;
font-size: 32rpx;
background-color: #fafafa;
}
.generate-btn {
width: 160rpx;
height: 80rpx;
background-color: #07c160;
color: #fff;
border-radius: 40rpx;
font-size: 28rpx;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
/* 通用区域样式 */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.clear-btn {
background-color: #ff4757;
color: #fff;
border-radius: 20rpx;
font-size: 24rpx;
padding: 10rpx 20rpx;
border: none;
}
/* 已选择的汉字区域 */
.selected-section {
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
}
.word-list {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.word-item.selected {
background-color: #07c160;
color: #fff;
border-radius: 50rpx;
padding: 20rpx 30rpx;
position: relative;
min-width: 80rpx;
text-align: center;
}
.word-text {
font-size: 32rpx;
font-weight: bold;
}
.remove-btn {
position: absolute;
top: -10rpx;
right: -10rpx;
width: 40rpx;
height: 40rpx;
background-color: #ff4757;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
font-weight: bold;
cursor: pointer;
}
/* 可选汉字区域 */
.available-section {
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
}
.word-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 20rpx;
}
.word-item.available {
background-color: #f0f0f0;
color: #333;
border-radius: 50rpx;
padding: 20rpx;
text-align: center;
border: 2rpx solid #e0e0e0;
transition: all 0.3s ease;
}
.word-item.available:active {
background-color: #e0e0e0;
transform: scale(0.95);
}
.word-item.available.disabled {
background-color: #ccc;
color: #999;
cursor: not-allowed;
}
.word-item.available.disabled:active {
background-color: #ccc;
transform: none;
}
/* 功能模块区域 */
.cards-section {
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
} }
.scroll-view { .scroll-view {
flex: 1; height: 400rpx;
overflow-y: hidden;
} }
.card { .card {
margin: 10px; margin: 10rpx;
padding: 10px; padding: 20rpx;
background-color: #fff; background-color: #f8f9fa;
border-radius: 10px; border-radius: 20rpx;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
width: calc(33.33% - 20px); width: calc(33.33% - 20rpx);
/* Adjust width for three cards per row */
box-sizing: border-box; box-sizing: border-box;
/* Ensure padding and margin are included in the width */
display: inline-block; display: inline-block;
/* Allow cards to sit next to each other */
vertical-align: top; vertical-align: top;
/* Align cards to the top */ transition: all 0.3s ease;
}
.card-title {
font-size: 18px; .card:active {
font-weight: bold; transform: scale(0.95);
color: #333; box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.2);
} }
.card-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
text-align: center;
} }
+110
View File
@@ -1,5 +1,8 @@
Page({ Page({
data: { data: {
inputWord: '', // 输入框中的汉字
wordList: [] as string[], // 选中的汉字列表
availableWords: [] as string[], // 从grade3.json中提取的前30个汉字
cards: [ cards: [
{ {
key: 0, key: 0,
@@ -24,6 +27,113 @@ Page({
], ],
}, },
onLoad() {
this.loadGrade3Words();
},
// 加载grade3.json中的前30个汉字
loadGrade3Words() {
const fs = wx.getFileSystemManager();
try {
const fileContent = fs.readFileSync('grade3.json', 'utf8') as string;
const grade3Data = JSON.parse(fileContent);
const words = Object.keys(grade3Data).slice(0, 30);
this.setData({
availableWords: words
});
} catch (error) {
console.error('加载grade3.json失败:', error);
// 如果文件读取失败,使用一些示例汉字
const fallbackWords = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '人', '口', '手', '足', '目', '耳', '鼻', '舌', '心', '肝', '脾', '肺', '肾', '胃', '肠', '胆', '膀', '胱', '皮', '毛'];
this.setData({
availableWords: fallbackWords
});
}
},
// 输入框输入事件
onInputChange(e: WechatMiniprogram.Input) {
this.setData({
inputWord: e.detail.value
});
},
// 生成按钮点击事件
onGenerateClick() {
const { inputWord, wordList } = this.data;
if (inputWord && inputWord.trim()) {
// 检查是否已经存在
if (!wordList.includes(inputWord.trim())) {
const newWordList = [...wordList, inputWord.trim()];
this.setData({
wordList: newWordList,
inputWord: '' // 清空输入框
});
wx.showToast({
title: '已添加到列表',
icon: 'success'
});
} else {
wx.showToast({
title: '该汉字已存在',
icon: 'none'
});
}
} else {
wx.showToast({
title: '请输入汉字',
icon: 'none'
});
}
},
// 选择汉字点击事件
onWordSelect(e: WechatMiniprogram.TouchEvent) {
const { word } = e.currentTarget.dataset;
const { wordList } = this.data;
if (!wordList.includes(word)) {
const newWordList = [...wordList, word];
this.setData({
wordList: newWordList
});
wx.showToast({
title: '已选择',
icon: 'success'
});
} else {
wx.showToast({
title: '已选择过',
icon: 'none'
});
}
},
// 从列表中移除汉字
onRemoveWord(e: WechatMiniprogram.TouchEvent) {
const { index } = e.currentTarget.dataset;
const { wordList } = this.data;
const newWordList = wordList.filter((_, i) => i !== index);
this.setData({
wordList: newWordList
});
wx.showToast({
title: '已移除',
icon: 'success'
});
},
// 清空所有选中的汉字
onClearAll() {
this.setData({
wordList: []
});
wx.showToast({
title: '已清空',
icon: 'success'
});
},
tapCard(e: WechatMiniprogram.TouchEvent) { tapCard(e: WechatMiniprogram.TouchEvent) {
const { url } = e.currentTarget.dataset; const { url } = e.currentTarget.dataset;
wx.navigateTo({ url }); wx.navigateTo({ url });
+79 -10
View File
@@ -1,12 +1,81 @@
<!--index.wxml--> <!--index.wxml-->
<scroll-view class="scroll-view" scroll-y type="list"> <view class="container">
<view <!-- 输入区域 -->
class="card" <view class="input-section">
wx:for="{{cards}}" <view class="input-row">
wx:for-item="card" <input
wx:key="*this" class="word-input"
bindtap="tapCard" placeholder="请输入汉字"
data-url="{{card.url}}"> value="{{inputWord}}"
<view class="card-title">{{card.title}}</view> bindinput="onInputChange"
maxlength="10" />
<button class="generate-btn" bindtap="onGenerateClick">生成</button>
</view>
</view> </view>
</scroll-view>
<!-- 已选择的汉字列表 -->
<view class="selected-section" wx:if="{{wordList.length > 0}}">
<view class="section-header">
<text class="section-title"
>已选择的汉字 ({{wordList.length}})</text
>
<button class="clear-btn" bindtap="onClearAll" size="mini">
清空
</button>
</view>
<view class="word-list">
<view
class="word-item selected"
wx:for="{{wordList}}"
wx:key="*this"
wx:for-item="word"
wx:for-index="index">
<text class="word-text">{{word}}</text>
<text
class="remove-btn"
bindtap="onRemoveWord"
data-index="{{index}}"
>×</text
>
</view>
</view>
</view>
<!-- 可选汉字区域 -->
<view class="available-section">
<view class="section-header">
<text class="section-title"
>可选汉字 ({{availableWords.length}})</text
>
</view>
<view class="word-grid">
<view
class="word-item available {{wordList.includes(word) ? 'disabled' : ''}}"
wx:for="{{availableWords}}"
wx:key="*this"
wx:for-item="word"
bindtap="onWordSelect"
data-word="{{word}}">
<text class="word-text">{{word}}</text>
</view>
</view>
</view>
<!-- 原有的卡片区域 -->
<view class="cards-section">
<view class="section-header">
<text class="section-title">功能模块</text>
</view>
<scroll-view class="scroll-view" scroll-y type="list">
<view
class="card"
wx:for="{{cards}}"
wx:for-item="card"
wx:key="*this"
bindtap="tapCard"
data-url="{{card.url}}">
<view class="card-title">{{card.title}}</view>
</view>
</scroll-view>
</view>
</view>
+6 -6
View File
@@ -26,12 +26,12 @@ Page({
wx.getStorageSync('hasShowIntroduction') || false; wx.getStorageSync('hasShowIntroduction') || false;
// 判断是否在PC端开发工具上运行 // 判断是否在PC端开发工具上运行
// const systemInfo = wx.getDeviceInfo(); const systemInfo = wx.getDeviceInfo();
// if (systemInfo.platform === 'devtools') { if (systemInfo.platform === 'devtools') {
// this.setData({ this.setData({
// isPCDevtool: true, isPCDevtool: true,
// }); });
// } }
if (!hasShowIntroduction) { if (!hasShowIntroduction) {
this.setData({ this.setData({
+20 -35
View File
@@ -10,6 +10,7 @@ Page({
boxHeight: 0, boxHeight: 0,
boxWidth: 0, boxWidth: 0,
shapeDrawService: null as ShapeDrawService | null, shapeDrawService: null as ShapeDrawService | null,
timer: null as any,
data: { data: {
shapeList: [] as ShapeCard[], shapeList: [] as ShapeCard[],
@@ -93,6 +94,13 @@ Page({
} }
}, },
handleSelectShape() {
this.setData({
showSelectShapePopup: true,
selectedShapeIndex: -1
});
},
openSelectShapePopup(e: any) { openSelectShapePopup(e: any) {
const { index } = e.detail; const { index } = e.detail;
this.setData({ this.setData({
@@ -104,7 +112,6 @@ Page({
closeSelectShapePopup() { closeSelectShapePopup() {
this.setData({ this.setData({
showSelectShapePopup: false, showSelectShapePopup: false,
selectedShapeIndex: -1
}); });
}, },
@@ -174,12 +181,11 @@ Page({
}); });
} }
// 先关闭弹窗
this.setData({ this.setData({
showSelectShapePopup: false, showSelectShapePopup: false,
selectedShapeIndex: -1, shapeList: newShapeList,
shapeList: newShapeList
}, () => { }, () => {
// 绘制canvas
this.drawCanvas(); this.drawCanvas();
}); });
}, },
@@ -233,56 +239,29 @@ Page({
this.setData({ this.setData({
showColorPopup: true, showColorPopup: true,
currentColor: fillColor, currentColor: fillColor,
currentIndex: index selectedShapeIndex: index
}); });
}, },
onCloseColorPopup() { onCloseColorPopup() {
this.setData({ this.setData({
showColorPopup: false, showColorPopup: false,
currentColor: '', currentColor: '',
currentIndex: 0 selectedShapeIndex: 0
}); });
}, },
onChangeColor(e: any) { onChangeColor(e: any) {
const { color } = e.detail; const { color } = e.detail;
const { currentIndex } = this.data; const { selectedShapeIndex } = this.data;
this.setData({ this.setData({
showColorPopup: false, showColorPopup: false,
[`shapeList[${currentIndex}].fillColor`]: color [`shapeList[${selectedShapeIndex}].fillColor`]: color
}, () => { }, () => {
// 绘制canvas // 绘制canvas
this.drawCanvas(); this.drawCanvas();
}) })
}, },
// 测试ShapeDrawService
// testShapeDrawService() {
// if (!this.shapeDrawService) {
// wx.showToast({
// title: 'Canvas未初始化',
// icon: 'none'
// });
// return;
// }
// // 使用当前选中的图形进行测试
// const testShapes = this.data.shapeList.slice(0, 3); // 只测试前3个图形
// if (testShapes.length === 0) {
// wx.showToast({
// title: '请先选择图形',
// icon: 'none'
// });
// return;
// }
// this.shapeDrawService.draw(testShapes);
// wx.showToast({
// title: '绘制完成',
// icon: 'success'
// });
// },
onShareAppMessage() { onShareAppMessage() {
return { return {
title: '涂鸦丫-涂色|识字|画画|打印', title: '涂鸦丫-涂色|识字|画画|打印',
@@ -303,4 +282,10 @@ Page({
return; return;
} }
}, },
onUnload() {
if (this.timer) {
clearTimeout(this.timer);
}
},
}); });
+2 -1
View File
@@ -4,7 +4,7 @@
<view class="btn-area"> <view class="btn-area">
<toy-button <toy-button
type="green" type="green"
bind:click="openSelectShapePopup" bind:click="handleSelectShape"
width="290rpx"> width="290rpx">
选择图形 选择图形
</toy-button> </toy-button>
@@ -33,6 +33,7 @@
</view> </view>
</view> </view>
<shape-picker <shape-picker
selectedShapeList="{{shapeList}}"
show="{{showSelectShapePopup}}" show="{{showSelectShapePopup}}"
selectedShapeIndex="{{selectedShapeIndex}}" selectedShapeIndex="{{selectedShapeIndex}}"
bind:onClose="closeSelectShapePopup" bind:onClose="closeSelectShapePopup"
+89
View File
@@ -0,0 +1,89 @@
# 汉字选择页面 (wordDemo)
## 功能描述
这是一个汉字输入和选择的页面,主要包含以下功能:
### 上部区域 - 汉字输入
- 输入框:可以输入任意汉字(最多10个字符)
- 生成按钮:点击后将输入的汉字添加到已选择列表中
- **汉字校验**:自动校验输入内容是否为汉字,非汉字会提示错误
- **智能分割**:支持输入多个汉字,自动分割为单个汉字存储
### 中部区域 - 已选择的汉字
- 显示所有已选择的汉字
- 每个汉字都有删除按钮(×)
- 清空按钮:一键清空所有已选择的汉字
- 显示已选择汉字的数量
### 下部区域 - 可选汉字
-`demo/grade3.json` 文件中提取前30个汉字
- 以6列网格形式展示
- 已选择的汉字会显示为禁用状态
- 点击未选择的汉字可以添加到列表中
## 核心特性
### 🔍 **汉字校验**
- 使用Unicode范围 `\u4e00-\u9fff` 校验汉字
- 非汉字输入会显示Toast提示"请输入汉字"
- 确保 `wordList` 中只存储纯汉字
### ✂️ **智能分割**
- 支持输入多个汉字(如:"你好世界")
- 自动分割为单个汉字:["你", "好", "世", "界"]
- 过滤重复汉字,避免重复添加
- 显示实际添加的汉字数量
### 💾 **数据管理**
- `wordList` 中每个元素都是单个汉字
- 自动去重,避免重复存储
- 支持批量添加和单个删除
## 数据结构
- `inputWord`: 输入框中的汉字
- `wordList`: 已选择的汉字列表(string[]类型,每个元素为单个汉字)
- `availableWords`: 从grade3.json中提取的前30个汉字
## 使用方法
1. 在输入框中输入汉字(支持多个汉字)
2. 点击"生成"按钮,系统自动校验和分割
3. 点击下方网格中的汉字进行选择
4. 已选择的汉字会显示在上方,可以单独删除或一键清空
5. 所有选择的汉字都存储在 `this.data.wordList`
## 技术实现
### 汉字校验
```typescript
isChineseText(text: string): boolean {
const chineseRegex = /^[\u4e00-\u9fff]+$/;
return chineseRegex.test(text);
}
```
### 文本分割
```typescript
splitToSingleChars(text: string): string[] {
const chineseRegex = /[\u4e00-\u9fff]/g;
const matches = text.match(chineseRegex);
return matches || [];
}
```
## 文件结构
- `index.ts`: 页面逻辑文件
- `index.wxml`: 页面模板文件
- `index.less`: 页面样式文件
- `index.json`: 页面配置文件
+6
View File
@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "汉字选择",
"navigationBarBackgroundColor": "#d2e7d8",
"backgroundColor": "#f5f5f5",
"enablePullDownRefresh": false
}
+159
View File
@@ -0,0 +1,159 @@
/**index.less**/
page {
height: 100vh;
background-color: #f5f5f5;
}
.container {
padding: 20rpx;
height: 100vh;
overflow-y: auto;
}
/* 输入区域样式 */
.input-section {
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
}
.input-row {
display: flex;
align-items: center;
gap: 20rpx;
}
.word-input {
flex: 1;
height: 80rpx;
border: 2rpx solid #e0e0e0;
border-radius: 40rpx;
padding: 0 30rpx;
font-size: 32rpx;
background-color: #fafafa;
}
.generate-btn {
width: 160rpx;
height: 80rpx;
background-color: #07c160;
color: #fff;
border-radius: 40rpx;
font-size: 28rpx;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
/* 通用区域样式 */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.clear-btn {
background-color: #ff4757;
color: #fff;
border-radius: 20rpx;
font-size: 24rpx;
padding: 10rpx 20rpx;
border: none;
}
/* 已选择的汉字区域 */
.selected-section {
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
}
.word-list {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.word-item.selected {
background-color: #07c160;
color: #fff;
border-radius: 50rpx;
padding: 20rpx 30rpx;
position: relative;
min-width: 80rpx;
text-align: center;
}
.word-text {
font-size: 32rpx;
font-weight: bold;
}
.remove-btn {
position: absolute;
top: -10rpx;
right: -10rpx;
width: 40rpx;
height: 40rpx;
background-color: #ff4757;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
font-weight: bold;
cursor: pointer;
}
/* 可选汉字区域 */
.available-section {
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
}
.word-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 20rpx;
}
.word-item.available {
background-color: #f0f0f0;
color: #333;
border-radius: 50rpx;
padding: 20rpx;
text-align: center;
border: 2rpx solid #e0e0e0;
transition: all 0.3s ease;
}
.word-item.available:active {
background-color: #e0e0e0;
transform: scale(0.95);
}
.word-item.available.disabled {
background-color: #ccc;
color: #999;
cursor: not-allowed;
}
.word-item.available.disabled:active {
background-color: #ccc;
transform: none;
}
+148
View File
@@ -0,0 +1,148 @@
Page({
data: {
inputWord: '', // 输入框中的汉字
wordList: [] as string[], // 选中的汉字列表
availableWords: [] as string[], // 从grade3.json中提取的前30个汉字
},
onLoad() {
this.loadGrade3Words();
},
// 加载grade3.json中的前30个汉字
loadGrade3Words() {
const fs = wx.getFileSystemManager();
try {
const fileContent = fs.readFileSync('demo/grade3.json', 'utf8') as string;
const grade3Data = JSON.parse(fileContent);
const words = Object.keys(grade3Data).slice(0, 30);
this.setData({
availableWords: words
});
} catch (error) {
console.error('加载grade3.json失败:', error);
// 如果文件读取失败,使用一些示例汉字
const fallbackWords = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '人', '口', '手', '足', '目', '耳', '鼻', '舌', '心', '肝', '脾', '肺', '肾', '胃', '肠', '胆', '膀', '胱', '皮', '毛'];
this.setData({
availableWords: fallbackWords
});
}
},
// 输入框输入事件
onInputChange(e: WechatMiniprogram.Input) {
this.setData({
inputWord: e.detail.value
});
},
// 生成按钮点击事件
onGenerateClick() {
const { inputWord, wordList } = this.data;
if (inputWord && inputWord.trim()) {
const inputText = inputWord.trim();
// 校验输入是否为汉字
if (!this.isChineseText(inputText)) {
wx.showToast({
title: '请输入汉字',
icon: 'none'
});
return;
}
// 将输入文本分割为单个汉字
const singleChars = this.splitToSingleChars(inputText);
// 过滤掉已存在的汉字
const newChars = singleChars.filter(char => !wordList.includes(char));
if (newChars.length === 0) {
wx.showToast({
title: '所有汉字都已存在',
icon: 'none'
});
return;
}
// 添加到列表中
const newWordList = [...wordList, ...newChars];
this.setData({
wordList: newWordList,
inputWord: '' // 清空输入框
});
wx.showToast({
title: `已添加${newChars.length}个汉字`,
icon: 'success'
});
} else {
wx.showToast({
title: '请输入汉字',
icon: 'none'
});
}
},
// 校验文本是否为汉字
isChineseText(text: string): boolean {
// 汉字Unicode范围:\u4e00-\u9fff
const chineseRegex = /^[\u4e00-\u9fff]+$/;
return chineseRegex.test(text);
},
// 将文本分割为单个汉字
splitToSingleChars(text: string): string[] {
// 使用正则表达式匹配每个汉字
const chineseRegex = /[\u4e00-\u9fff]/g;
const matches = text.match(chineseRegex);
return matches || [];
},
// 选择汉字点击事件
onWordSelect(e: WechatMiniprogram.TouchEvent) {
const { word } = e.currentTarget.dataset;
const { wordList } = this.data;
if (!wordList.includes(word)) {
const newWordList = [...wordList, word];
this.setData({
wordList: newWordList
});
wx.showToast({
title: '已选择',
icon: 'success'
});
} else {
wx.showToast({
title: '已选择过',
icon: 'none'
});
}
},
// 从列表中移除汉字
onRemoveWord(e: WechatMiniprogram.TouchEvent) {
const { index } = e.currentTarget.dataset;
const { wordList } = this.data;
const newWordList = wordList.filter((_, i) => i !== index);
this.setData({
wordList: newWordList
});
wx.showToast({
title: '已移除',
icon: 'success'
});
},
// 清空所有选中的汉字
onClearAll() {
this.setData({
wordList: []
});
wx.showToast({
title: '已清空',
icon: 'success'
});
},
});
+63
View File
@@ -0,0 +1,63 @@
<!--index.wxml-->
<view class="container">
<!-- 上部:输入区域 -->
<view class="input-section">
<view class="input-row">
<input
class="word-input"
placeholder="请输入汉字(支持多个汉字)"
value="{{inputWord}}"
bindinput="onInputChange"
maxlength="20" />
<button class="generate-btn" bindtap="onGenerateClick">生成</button>
</view>
</view>
<!-- 已选择的汉字列表 -->
<view class="selected-section" wx:if="{{wordList.length > 0}}">
<view class="section-header">
<text class="section-title"
>已选择的汉字 ({{wordList.length}})</text
>
<button class="clear-btn" bindtap="onClearAll" size="mini">
清空
</button>
</view>
<view class="word-list">
<view
class="word-item selected"
wx:for="{{wordList}}"
wx:key="*this"
wx:for-item="word"
wx:for-index="index">
<text class="word-text">{{word}}</text>
<text
class="remove-btn"
bindtap="onRemoveWord"
data-index="{{index}}"
>×</text
>
</view>
</view>
</view>
<!-- 下部:可选汉字区域 -->
<view class="available-section">
<view class="section-header">
<text class="section-title"
>可选汉字 ({{availableWords.length}})</text
>
</view>
<view class="word-grid">
<view
class="word-item available {{wordList.includes(word) ? 'disabled' : ''}}"
wx:for="{{availableWords}}"
wx:key="*this"
wx:for-item="word"
bindtap="onWordSelect"
data-word="{{word}}">
<text class="word-text">{{word}}</text>
</view>
</view>
</view>
</view>
+359
View File
@@ -0,0 +1,359 @@
import { PAPER_SIZE } from '../constants/colors';
/**
* 仅支持 M/L/Z 的简单 SVG 路径解析与绘制
*/
function drawSvgPathCommands(
ctx: RenderingContext,
pathD: string,
offsetX: number,
offsetY: number,
scale: number,
) {
const tokens = pathD
.replace(/,/g, ' ')
.trim()
.split(/\s+/);
let i = 0;
let currentX = 0;
let currentY = 0;
while (i < tokens.length) {
const cmd = tokens[i++];
switch (cmd) {
case 'M': {
const x = parseFloat(tokens[i++]);
const y = parseFloat(tokens[i++]);
currentX = offsetX + x * scale;
currentY = offsetY + y * scale;
ctx.moveTo(currentX, currentY);
break;
}
case 'L': {
const x = parseFloat(tokens[i++]);
const y = parseFloat(tokens[i++]);
currentX = offsetX + x * scale;
currentY = offsetY + y * scale;
ctx.lineTo(currentX, currentY);
break;
}
case 'Z': {
ctx.closePath();
break;
}
default: {
// 遇到数字(可能因为路径省略了连续的 L),回退一步并按 L 处理
if (!isNaN(parseFloat(cmd))) {
i--;
const x = parseFloat(tokens[i++]);
const y = parseFloat(tokens[i++]);
currentX = offsetX + x * scale;
currentY = offsetY + y * scale;
ctx.lineTo(currentX, currentY);
break;
}
// 其他命令不支持,直接跳过
break;
}
}
}
}
function drawStrokes(
ctx: RenderingContext,
strokes: string[],
offsetX: number,
offsetY: number,
size: number,
uptoInclusive: number,
fillStyle: string,
strokeStyle: string,
lineWidth: number,
) {
// 模板中使用 54x54 的视窗尺寸
const viewBoxSize = 54;
const scale = size / viewBoxSize;
for (let s = 0; s <= uptoInclusive && s < strokes.length; s++) {
ctx.beginPath();
drawSvgPathCommands(ctx, strokes[s], offsetX, offsetY, scale);
ctx.fillStyle = fillStyle;
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = lineWidth;
ctx.fill();
ctx.stroke();
ctx.closePath();
}
}
function drawTianZiGrid(
ctx: RenderingContext,
x: number,
y: number,
size: number,
lineColor: string = '#e0e0e0',
boldColor: string = '#cccccc',
) {
// 外框
ctx.strokeStyle = boldColor;
ctx.lineWidth = 2;
ctx.strokeRect(x - size / 2, y - size / 2, size, size);
// 中线
ctx.strokeStyle = lineColor;
ctx.lineWidth = 1;
ctx.beginPath();
// 竖线
ctx.moveTo(x, y - size / 2);
ctx.lineTo(x, y + size / 2);
// 横线
ctx.moveTo(x - size / 2, y);
ctx.lineTo(x + size / 2, y);
ctx.stroke();
ctx.closePath();
// 对角线(淡)
ctx.strokeStyle = '#eeeeee';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x - size / 2, y - size / 2);
ctx.lineTo(x + size / 2, y + size / 2);
ctx.moveTo(x + size / 2, y - size / 2);
ctx.lineTo(x - size / 2, y + size / 2);
ctx.stroke();
ctx.closePath();
}
class WordDrawService {
headerType: PrintHeader = 'wechat';
canvas: WechatMiniprogram.Canvas;
ctx: RenderingContext;
options: Record<string, any>;
paperSize: PaperSize;
currentX: number;
currentY: number;
constructor(
canvas: Canvas,
ctx: RenderingContext,
options?: Record<string, any>,
) {
options = options || {};
this.canvas = canvas;
this.ctx = ctx;
this.paperSize = 'A4';
this.options = {
appName: '涂鸦丫小程序',
appHint: '练字|识字|打印',
title: '田字格 练 字 贴',
subTitle: '按笔画临摹练习',
...options,
};
this.currentX = 0;
this.currentY = 0;
this.setPrintConfig();
}
setPrintConfig() {
const printConfig = getApp().getPrintConfig();
this.headerType = printConfig.header;
this.options.appName = printConfig.appName;
}
/**
* 生成练字帖
* @param wordsMap 形如 { "其": ["M.. L.. Z", ...], ... },不超过 10 个汉字
*/
draw(wordsMap: Record<string, string[]>) {
this.setPrintConfig();
this.clear();
this.setPaper();
if (this.headerType !== 'minimal') {
this.drawHeader();
} else {
this.drawMiniHeader();
}
this.drawContent(wordsMap);
}
async drawHeader() {
const { canvas, ctx } = this;
const { appName, appHint, title, subTitle } = this.options;
this.currentX = 80;
this.currentY = 80;
let titleX = this.currentX + 200 + 48;
const titleY = 80;
const logoX = 80;
const logoY = 60;
const logoWidth = 200;
const logoHeight = 200;
// 这里沿用小程序码/Logo策略,保持与其它服务一致
try {
const { getMiniCodeImage } = await import('../utils/index');
const image = await getMiniCodeImage(canvas);
ctx.drawImage(image, logoX, logoY, logoWidth, logoHeight);
} catch (_) {
// 忽略加载失败
}
ctx.font = 'bold 64px "Microsoft Yahei"';
ctx.fillStyle = '#000';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(appName, titleX, titleY);
ctx.font = '48px "Microsoft Yahei"';
ctx.fillStyle = '#666';
ctx.fillText(appHint, titleX, 186);
ctx.font = 'bold 64px "Microsoft Yahei"';
ctx.fillStyle = '#000';
ctx.fillText(title, 1015, titleY);
ctx.font = '48px "Microsoft Yahei"';
ctx.fillStyle = '#666';
ctx.fillText(subTitle, 1015, 186);
this.currentY = 304;
this.drawLine(this.currentY);
}
drawMiniHeader() {
const { canvas, ctx } = this;
const { appName, title } = this.options;
const titleY = 120;
ctx.font = 'bold 64px "Microsoft Yahei"';
ctx.fillStyle = '#000';
ctx.textAlign = 'center';
ctx.fillText(appName + ' ' + title, canvas.width / 2, titleY);
this.currentY = 200;
this.drawLine(this.currentY);
}
/**
* 绘制正文内容:为每个汉字生成“预览 + 逐笔画临摹 + 空白格”的练习单元
*/
drawContent(wordsMap: Record<string, string[]>) {
const { canvas, ctx } = this;
const characters = Object.keys(wordsMap).slice(0, 10);
if (characters.length === 0) return;
// 布局参数
const topGap = 50; // 与页眉分割线的距离
const leftMargin = 120;
const rightMargin = 120;
const bottomMargin = 120;
const contentTop = this.currentY + topGap;
const contentWidth = canvas.width - leftMargin - rightMargin;
const cellSize = 140;
const minGap = 24;
const maxPerRow = Math.max(1, Math.floor((contentWidth + minGap) / (cellSize + minGap)));
const rowGap = 36;
// 为每个汉字生成一串单元:1个预览 + N个逐笔画 + 2个空白 (便于描摹)
const unitsPerCharBuilder = (strokeCount: number) => {
const preview = 1;
const practice = Math.min(strokeCount, 8); // 最多展示 8 步逐笔画,避免过长
const blanks = 2;
return preview + practice + blanks;
};
let cursorX = leftMargin;
let cursorY = contentTop + cellSize / 2;
let usedInRow = 0;
characters.forEach((char) => {
const strokes = wordsMap[char] || [];
const totalUnits = unitsPerCharBuilder(strokes.length);
let unitIndex = 0;
while (unitIndex < totalUnits) {
// 如果本行放不下,换行
if (usedInRow >= maxPerRow) {
cursorX = leftMargin;
cursorY += cellSize + rowGap;
usedInRow = 0;
// 边界检查
if (cursorY + cellSize / 2 > canvas.height - bottomMargin) return;
}
// 绘制田字格
drawTianZiGrid(ctx, cursorX + cellSize / 2, cursorY, cellSize);
// 单元类型:0 预览;1..practice 逐笔画;最后 blanks 空白
const practiceMax = Math.min(strokes.length, 8);
if (unitIndex === 0) {
// 预览:全部笔画使用较深颜色
drawStrokes(
ctx,
strokes,
cursorX + (cellSize - cellSize) / 2,
cursorY - cellSize / 2,
cellSize,
strokes.length - 1,
'rgb(0,0,0)',
'#000',
1.6,
);
} else if (unitIndex <= practiceMax) {
// 逐笔画:累进显示到第 k 画,使用灰色/描边
drawStrokes(
ctx,
strokes,
cursorX + (cellSize - cellSize) / 2,
cursorY - cellSize / 2,
cellSize,
unitIndex - 1,
'rgb(184,184,184)',
'#999',
1.6,
);
} else {
// 空白格:不画笔画
}
// 前进到下一个单元
cursorX += cellSize + minGap;
usedInRow += 1;
unitIndex += 1;
}
});
}
drawLine(linY: number) {
const { canvas, ctx } = this;
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(80, linY);
ctx.lineTo(canvas.width - 80, linY);
ctx.stroke();
}
setPaper() {
const { ctx, canvas } = this;
const { pixelRatio: dpr } = wx.getWindowInfo();
let { width, height } = PAPER_SIZE[this.paperSize];
width = width * dpr;
height = height * dpr;
canvas.width = width;
canvas.height = height;
this.clear();
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, width, height);
}
clear() {
const canvas = this.canvas;
this.ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
export default WordDrawService;
+9 -2
View File
@@ -24,12 +24,19 @@
"miniprogram": { "miniprogram": {
"list": [ "list": [
{ {
"name": "pages/shape/index", "name": "pages/wordDemo/index",
"pathName": "pages/shape/index", "pathName": "pages/wordDemo/index",
"query": "", "query": "",
"scene": null, "scene": null,
"launchMode": "default" "launchMode": "default"
}, },
{
"name": "pages/shape/index",
"pathName": "pages/shape/index",
"query": "",
"launchMode": "default",
"scene": null
},
{ {
"name": "测试图形绘制", "name": "测试图形绘制",
"pathName": "demoPages/shapePrint/index", "pathName": "demoPages/shapePrint/index",