feat: 打印预览组件化

This commit is contained in:
R524809
2026-04-22 15:02:48 +08:00
parent 4ad387e99d
commit de74666c9f
24 changed files with 552 additions and 273 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+9 -9
View File
@@ -5,6 +5,13 @@
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
{
"icon_id": "47422029",
"name": "refresh",
"font_class": "refresh",
"unicode": "e62c",
"unicode_decimal": 58924
},
{
"icon_id": "47400915",
"name": "keyboard-arrow-up",
@@ -285,13 +292,6 @@
"unicode": "e6ea",
"unicode_decimal": 59114
},
{
"icon_id": "12578929",
"name": "refresh2",
"font_class": "refresh2",
"unicode": "e6e3",
"unicode_decimal": 59107
},
{
"icon_id": "18441414",
"name": "sun",
@@ -301,8 +301,8 @@
},
{
"icon_id": "21937756",
"name": "refresh",
"font_class": "refresh",
"name": "refresh-two",
"font_class": "refresh-two",
"unicode": "e6c2",
"unicode_decimal": 59074
},
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+41
View File
@@ -168,6 +168,47 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
.exec();
},
/**
* 从 preview-card 组件的 canvas-ready 事件初始化 Canvas
* 用于已将 canvas 节点下沉到 preview-card 组件的页面
*/
initCanvasFromComponent(
this: PageCanvasInstance,
detail: {
canvas: Canvas;
ctx: RenderingContext;
width: number;
height: number;
},
options: InitCanvasOptions,
) {
this.canvas = detail.canvas;
this.ctx = detail.ctx;
this.boxWidth = detail.width;
this.boxHeight = detail.height;
this.setData({
boxWidth: detail.width,
boxHeight: detail.height,
});
const drawServiceOptions = {
title: this.data.pageTitle,
subTitle: this.data.subTitle || '',
...options.drawServiceOptions,
};
this.drawService = options.createDrawService(
detail.canvas,
detail.ctx,
drawServiceOptions,
);
if (options.onCanvasReady) {
options.onCanvasReady.call(this);
}
},
/**
* 导出打印
*/
@@ -0,0 +1,8 @@
{
"component": true,
"usingComponents": {
"toy-icon": "../../toy/icon/icon"
},
"styleIsolation": "apply-shared",
"addGlobalClass": true
}
@@ -0,0 +1,112 @@
@import '../../style/theme.less';
.pc-wrap {
position: relative;
}
.pc-card {
position: relative;
background: @bg-white;
border-radius: @radius-xl;
box-shadow: @shadow;
padding: 40rpx;
border: @border;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: stretch;
}
.pc-card--with-fab {
padding-bottom: 56rpx;
}
.pc-kicker {
display: block;
text-align: center;
font-size: @fs-section-head-title;
font-weight: bold;
color: @text-secondary;
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 30rpx;
}
.pc-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;
}
.pc-canvas {
max-width: 100%;
border-radius: @radius-sm;
box-shadow: @shadow;
}
/* 底部角上的圆形操作按钮 */
@pc-fab-size: 88rpx;
@keyframes pc-fab-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.pc-fab {
position: absolute;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: @pc-fab-size;
height: @pc-fab-size;
border-radius: 50%;
box-shadow: 0 6rpx 18rpx rgba(50, 46, 37, 0.12);
box-sizing: border-box;
}
.pc-fab--hover {
opacity: 0.88;
transform: scale(0.96);
}
.pc-fab--refresh {
left: 12rpx;
bottom: 12rpx;
background: #9add4c;
}
.pc-fab--favorite {
right: 12rpx;
bottom: 12rpx;
background: #f2eee4;
}
.pc-fab__toy {
display: flex;
align-items: center;
justify-content: center;
}
.pc-fab__icon-wrap--a .pc-fab__toy,
.pc-fab__icon-wrap--b .pc-fab__toy {
animation: pc-fab-spin 0.55s linear 1;
}
.pc-fab__icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
@@ -0,0 +1,145 @@
import { PAPER_SIZE, A4_EXPORT_SIZE_150DPI } from '../../constants/colors';
Component({
options: {
styleIsolation: 'apply-shared',
addGlobalClass: true,
},
properties: {
kicker: {
type: String,
value: '打印预览',
},
showRefresh: {
type: Boolean,
value: false,
},
showFavorite: {
type: Boolean,
value: false,
},
favorited: {
type: Boolean,
value: false,
},
favoritedIconColor: {
type: String,
value: '#E53935',
},
favoriteIdleIconColor: {
type: String,
value: '#605b50',
},
},
data: {
boxWidth: 0,
boxHeight: 0,
refreshIconAnimClass: '',
withFab: false,
},
observers: {
'showRefresh, showFavorite'(
showRefresh: boolean,
showFavorite: boolean,
) {
this.setData({ withFab: !!showRefresh || !!showFavorite });
},
},
lifetimes: {
ready() {
this.initCanvas();
},
},
methods: {
initCanvas() {
const query = this.createSelectorQuery();
query
.select('#canvasWrapper')
.boundingClientRect((rect) => {
if (!rect) return;
const { width, height } = PAPER_SIZE['A4'];
const boxWidth = rect.width;
const boxHeight = boxWidth / (width / height);
this.setData({ boxWidth, boxHeight });
this.createSelectorQuery()
.select('#canvasContent')
.fields({ node: true, size: true })
.exec((res) => {
if (!res[0]) return;
const canvas = res[0].node;
const ctx = canvas.getContext('2d');
const dpr = wx.getSystemInfoSync().pixelRatio;
canvas.width = boxWidth * dpr;
canvas.height = boxHeight * dpr;
ctx.scale(dpr, dpr);
this._canvas = canvas;
this.triggerEvent('canvas-ready', {
canvas,
ctx,
width: boxWidth,
height: boxHeight,
});
});
})
.exec();
},
onRefreshTap() {
this.triggerEvent('refresh', {});
if ((this as any)._refreshSpinTimer) {
clearTimeout((this as any)._refreshSpinTimer);
}
this.setData({ refreshIconAnimClass: '' });
const n = ((this as any)._refreshTapCount || 0) + 1;
(this as any)._refreshTapCount = n;
const sub = n % 2 === 1 ? 'a' : 'b';
(this as any)._refreshSpinTimer = setTimeout(() => {
(this as any)._refreshSpinTimer = null;
this.setData({
refreshIconAnimClass: `pc-fab__icon-wrap--${sub}`,
});
}, 20);
},
onFavoriteTap() {
this.triggerEvent('favorite', {});
},
getCanvas(): WechatMiniprogram.Canvas | null {
return this._canvas || null;
},
exportToTempFile(
options?: { quality?: number; fileType?: 'png' | 'jpg' },
): Promise<string> {
const canvas = this._canvas;
if (!canvas) {
return Promise.reject(new Error('Canvas not initialized'));
}
return new Promise((resolve, reject) => {
wx.canvasToTempFilePath({
canvas,
fileType: options?.fileType || 'png',
quality: options?.quality ?? 1,
destWidth: A4_EXPORT_SIZE_150DPI.width,
destHeight: A4_EXPORT_SIZE_150DPI.height,
success: (res) => resolve(res.tempFilePath),
fail: (err) => reject(err),
});
});
},
},
});
@@ -0,0 +1,43 @@
<view class="pc-wrap">
<view class="pc-card {{withFab ? 'pc-card--with-fab' : ''}}">
<text class="pc-kicker">{{kicker}}</text>
<view id="canvasWrapper" class="pc-canvas-wrap">
<canvas
type="2d"
id="canvasContent"
class="pc-canvas"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
</view>
<view
wx:if="{{showRefresh}}"
class="pc-fab pc-fab--refresh"
hover-class="pc-fab--hover"
hover-start-time="0"
hover-stay-time="70"
catchtap="onRefreshTap">
<view
class="pc-fab__icon-wrap {{refreshIconAnimClass}}">
<toy-icon
name="refresh"
size="40rpx"
color="#2d5016"
custom-class="pc-fab__toy" />
</view>
</view>
<view
wx:if="{{showFavorite}}"
class="pc-fab pc-fab--favorite"
hover-class="pc-fab--hover"
hover-start-time="0"
hover-stay-time="70"
catchtap="onFavoriteTap">
<toy-icon
name="{{favorited ? 'heart' : 'heart-o'}}"
size="40rpx"
color="{{favorited ? favoritedIconColor : favoriteIdleIconColor}}"
custom-class="pc-fab__toy" />
</view>
</view>
</view>
@@ -10,6 +10,7 @@
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
"draw-ad": "../../components/draw-ad/draw-ad",
"preview-footer-actions": "../../components3.0/preview-footer-actions/preview-footer-actions",
"toy-icon": "../../toy/icon/icon"
"toy-icon": "../../toy/icon/icon",
"preview-card": "../../components3.0/preview-card/preview-card"
}
}
@@ -19,53 +19,6 @@ page {
gap: 64rpx;
}
/* ===== 预览卡 (rounded-xl shadow-sm p-8 border) ===== */
.lt-preview-wrap {
position: relative;
}
.lt-preview-card {
background: @bg-white;
border-radius: @radius-xl;
box-shadow: @shadow;
padding: 40rpx;
border: @border;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: stretch;
}
.lt-preview-kicker {
display: block;
text-align: center;
/* text-xs = 12px → 24rpx */
font-size: @fs-section-head-title;
font-weight: bold;
color: @text-secondary;
// 设置字母间距,使文本字符之间有更宽的间隔,提升可读性和美观性
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 30rpx;
}
.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;
}
/* ===== 独立 Section 区块(参考 UI 稿 space-y-4 ===== */
.lt-section {
display: flex;
@@ -92,6 +92,7 @@ type PageData = CanvasDataState & {
selectedTripleGroupIdx: number;
letterGrid: string[];
modeOptions: ReadonlyArray<{ id: string; label: string; desc: string }>;
isPreviewFavorite: boolean;
};
createPage(
@@ -121,6 +122,7 @@ createPage(
selectedTripleGroupIdx: 0,
letterGrid: LETTERS_PAIRS,
modeOptions: [...MODES],
isPreviewFavorite: false,
} as unknown as PageData,
/** 页面加载:从路由参数获取 worksheetId、letter、case、font 并应用,同时预加载字体 */
@@ -170,9 +172,9 @@ createPage(
});
},
/** 页面就绪:初始化 Canvas → 首次绘制(标题行前缀使用系统 emoji,无需 loadFontFace */
onReady() {
this.initCanvas({
/** Canvas 组件就绪回调:创建 DrawService → 首次绘制 */
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
this.initCanvasFromComponent(e.detail, {
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
@@ -274,6 +276,15 @@ createPage(
this.drawCanvas();
},
onPreviewFavorite() {
const next = !this.data.isPreviewFavorite;
this.setData({ isPreviewFavorite: next });
wx.showToast({
title: next ? '收藏成功' : '已取消收藏',
icon: 'none',
});
},
/** 用户在三字母分组选择器中选择了一组 */
onSelectTripleGroup(e: WechatMiniprogram.TouchEvent) {
const idx = Number(e.currentTarget.dataset.idx);
@@ -2,18 +2,13 @@
<view class="lt-page">
<view class="lt-main">
<view class="lt-preview-wrap">
<view class="lt-preview-card">
<text class="lt-preview-kicker">打印预览</text>
<view id="canvasWrapper" class="lt-canvas-wrap">
<canvas
type="2d"
id="canvasContent"
class="lt-canvas"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
</view>
</view>
</view>
<preview-card
id="previewCard"
showRefresh="{{false}}"
showFavorite="{{true}}"
favorited="{{isPreviewFavorite}}"
bind:canvas-ready="onCanvasReady"
bind:favorite="onPreviewFavorite" />
<!-- 练习类型(2列卡片网格) -->
<view class="lt-section">
@@ -10,6 +10,7 @@
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
"draw-ad": "../../components/draw-ad/draw-ad",
"preview-footer-actions": "../../components3.0/preview-footer-actions/preview-footer-actions",
"toy-icon": "../../toy/icon/icon"
"toy-icon": "../../toy/icon/icon",
"preview-card": "../../components3.0/preview-card/preview-card"
}
}
@@ -18,51 +18,6 @@ page {
gap: 64rpx;
}
/* ===== 预览卡 ===== */
.fd-preview-wrap {
position: relative;
}
.fd-preview-card {
background: @bg-white;
border-radius: @radius-xl;
box-shadow: @shadow;
padding: 40rpx;
border: @border;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: stretch;
}
.fd-preview-kicker {
display: block;
text-align: center;
font-size: @fs-section-head-title;
font-weight: bold;
color: @text-secondary;
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 30rpx;
}
.fd-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;
}
.fd-canvas {
max-width: 100%;
border-radius: @radius-sm;
box-shadow: @shadow;
}
/* ===== 区块 ===== */
.fd-section {
display: flex;
+17 -2
View File
@@ -35,6 +35,7 @@ createFocusPage({
actionsTitle: '选择模式',
currentActions: [] as FocusTypeAction[],
currentMode: '',
isPreviewFavorite: false,
},
onLoad(options: { id?: string; mode?: string }) {
@@ -65,13 +66,13 @@ createFocusPage({
this.initPageInfo(routeId, title);
},
onReady() {
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
if (!this.currentTypeConfig) return;
const typeConfig = this.currentTypeConfig;
const mode = this.data.currentMode;
this.initCanvas({
this.initCanvasFromComponent(e.detail, {
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
@@ -181,4 +182,18 @@ createFocusPage({
);
this.drawCanvas();
},
/** 打印预览角标:换一批,与「随机生成」相同逻辑 */
onPreviewRefresh() {
this.onRandom();
},
onPreviewFavorite() {
const next = !this.data.isPreviewFavorite;
this.setData({ isPreviewFavorite: next });
wx.showToast({
title: next ? '收藏成功' : '已取消收藏',
icon: 'none',
});
},
});
+25 -29
View File
@@ -3,15 +3,29 @@
<view class="fd-page">
<view class="fd-main">
<!-- 打印预览卡 -->
<view class="fd-preview-wrap">
<view class="fd-preview-card">
<text class="fd-preview-kicker">打印预览</text>
<view id="canvasWrapper" class="fd-canvas-wrap">
<canvas
type="2d"
id="canvasContent"
class="fd-canvas"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
<preview-card
id="previewCard"
showRefresh="{{true}}"
showFavorite="{{true}}"
favorited="{{isPreviewFavorite}}"
bind:canvas-ready="onCanvasReady"
bind:refresh="onPreviewRefresh"
bind:favorite="onPreviewFavorite" />
<!-- 子选项(当前类型有模式切换时显示) -->
<view wx:if="{{showActions}}" class="fd-section">
<text class="fd-section-title">{{actionsTitle}}</text>
<view class="fd-chip-row">
<view
wx:for="{{currentActions}}"
wx:key="value"
class="fd-chip {{currentMode === item.value ? 'fd-chip--active' : ''}}"
data-value="{{item.value}}"
hover-class="fd-chip--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectMode">
{{item.label}}
</view>
</view>
</view>
@@ -35,25 +49,7 @@
</view>
</view>
<!-- 子选项(当前类型有模式切换时显示) -->
<view wx:if="{{showActions}}" class="fd-section">
<text class="fd-section-title">{{actionsTitle}}</text>
<view class="fd-chip-row">
<view
wx:for="{{currentActions}}"
wx:key="value"
class="fd-chip {{currentMode === item.value ? 'fd-chip--active' : ''}}"
data-value="{{item.value}}"
hover-class="fd-chip--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectMode">
{{item.label}}
</view>
</view>
</view>
<!-- 随机生成按钮 -->
<!-- 随机生成按钮
<view
class="fd-shuffle-btn"
hover-class="fd-shuffle-btn--hover"
@@ -66,7 +62,7 @@
color="#605b50"
custom-class="fd-shuffle-btn__icon" />
<text class="fd-shuffle-btn__text">随机生成</text>
</view>
</view> -->
<!-- 广告位 -->
<draw-ad type="focusDraw"></draw-ad>
+2 -1
View File
@@ -10,6 +10,7 @@
"share-guide-popup": "../../components/share-guide-popup/share-guide-popup",
"draw-ad": "../../components/draw-ad/draw-ad",
"preview-footer-actions": "../../components3.0/preview-footer-actions/preview-footer-actions",
"toy-icon": "../../toy/icon/icon"
"toy-icon": "../../toy/icon/icon",
"preview-card": "../../components3.0/preview-card/preview-card"
}
}
@@ -18,51 +18,6 @@ page {
gap: 64rpx;
}
/* ===== 预览卡 ===== */
.md-preview-wrap {
position: relative;
}
.md-preview-card {
background: @bg-white;
border-radius: @radius-xl;
box-shadow: @shadow;
padding: 40rpx;
border: @border;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: stretch;
}
.md-preview-kicker {
display: block;
text-align: center;
font-size: @fs-section-head-title;
font-weight: bold;
color: @text-secondary;
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 30rpx;
}
.md-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;
}
.md-canvas {
max-width: 100%;
border-radius: @radius-sm;
box-shadow: @shadow;
}
/* ===== 区块 ===== */
.md-section {
display: flex;
+16 -2
View File
@@ -39,6 +39,7 @@ createMathPage({
showNumberGrid: false,
numberList: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
selectedNumber: 0,
isPreviewFavorite: false,
},
onLoad(options: { id?: string; mode?: string }) {
@@ -74,13 +75,13 @@ createMathPage({
this.initPageInfo(routeId, title);
},
onReady() {
onCanvasReady(e: WechatMiniprogram.CustomEvent) {
if (!this.currentTypeConfig) return;
const typeConfig = this.currentTypeConfig;
const mode = this.data.currentMode;
this.initCanvas({
this.initCanvasFromComponent(e.detail, {
createDrawService: (
canvas: Canvas,
ctx: RenderingContext,
@@ -246,4 +247,17 @@ createMathPage({
);
this.drawCanvas();
},
onPreviewRefresh() {
this.onRandom();
},
onPreviewFavorite() {
const next = !this.data.isPreviewFavorite;
this.setData({ isPreviewFavorite: next });
wx.showToast({
title: next ? '收藏成功' : '已取消收藏',
icon: 'none',
});
},
});
+31 -34
View File
@@ -3,42 +3,20 @@
<view class="md-page">
<view class="md-main">
<!-- 打印预览卡 -->
<view class="md-preview-wrap">
<view class="md-preview-card">
<text class="md-preview-kicker">打印预览</text>
<view id="canvasWrapper" class="md-canvas-wrap">
<canvas
type="2d"
id="canvasContent"
class="md-canvas"
style="width:{{boxWidth}}px;height:{{boxHeight}}px" />
</view>
</view>
</view>
<!-- 练习类型选择(2列卡片网格) -->
<view class="md-section">
<text class="md-section-title">练习类型</text>
<view class="md-type-grid">
<view
wx:for="{{typeList}}"
wx:key="id"
class="md-type-card {{selectedTypeId === item.id ? 'md-type-card--active' : ''}}"
data-id="{{item.id}}"
hover-class="md-type-card--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectType">
<text class="md-type-card__icon">{{item.icon}}</text>
<text class="md-type-card__label">{{item.title}}</text>
</view>
</view>
</view>
<preview-card
id="previewCard"
showRefresh="{{true}}"
showFavorite="{{true}}"
favorited="{{isPreviewFavorite}}"
bind:canvas-ready="onCanvasReady"
bind:refresh="onPreviewRefresh"
bind:favorite="onPreviewFavorite" />
<!-- 子选项(当前类型有模式切换时显示) -->
<view wx:if="{{showActions}}" class="md-section">
<text class="md-section-title">{{actionsTitle}}</text>
<view class="md-chip-row {{currentActions.length > 3 ? 'md-chip-row--wrap' : ''}}">
<view
class="md-chip-row {{currentActions.length > 3 ? 'md-chip-row--wrap' : ''}}">
<view
wx:for="{{currentActions}}"
wx:key="value"
@@ -71,7 +49,26 @@
</view>
</view>
<!-- 随机生成按钮 -->
<!-- 练习类型选择(2列卡片网格) -->
<view class="md-section">
<text class="md-section-title">练习类型</text>
<view class="md-type-grid">
<view
wx:for="{{typeList}}"
wx:key="id"
class="md-type-card {{selectedTypeId === item.id ? 'md-type-card--active' : ''}}"
data-id="{{item.id}}"
hover-class="md-type-card--pressed"
hover-start-time="0"
hover-stay-time="70"
bindtap="onSelectType">
<text class="md-type-card__icon">{{item.icon}}</text>
<text class="md-type-card__label">{{item.title}}</text>
</view>
</view>
</view>
<!-- 随机生成按钮
<view
class="md-shuffle-btn"
hover-class="md-shuffle-btn--hover"
@@ -84,7 +81,7 @@
color="#605b50"
custom-class="md-shuffle-btn__icon" />
<text class="md-shuffle-btn__text">随机生成</text>
</view>
</view> -->
<!-- 广告位 -->
<draw-ad type="mathDraw"></draw-ad>