添加登录和后端校验

完成后端设计(未在本仓库体现),通过安全的手段完成了登录鉴权
This commit is contained in:
2026-08-06 16:27:36 +08:00
commit b19a56003f
286 changed files with 51650 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: 'DIY工作台'
})
+3
View File
@@ -0,0 +1,3 @@
{
"usingComponents": {}
}
+317
View File
@@ -0,0 +1,317 @@
.diy-page {
padding: 0 24px 24px;
min-height: 100vh;
}
/* 画布区域 */
.canvas-wrapper {
padding: 40px;
display: flex;
justify-content: center;
}
.canvas-area {
position: relative;
background: linear-gradient(135deg, rgba(255, 154, 158, 0.08) 0%, rgba(160, 196, 255, 0.08) 100%);
overflow: hidden;
border-radius: 16px;
min-width: 200px;
min-height: 200px;
max-width: 100%;
box-sizing: border-box;
}
.mask-border {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 3px dashed var(--accent-blue);
pointer-events: none;
z-index: 2;
}
.canvas-image {
position: absolute;
max-width: 100%;
max-height: 100%;
z-index: 5;
border: 2px solid transparent;
}
.canvas-image.active {
border-color: var(--accent-pink);
}
.canvas-image.overlap {
border-color: #ff4757;
box-shadow: 0 0 12px rgba(255, 71, 87, 0.5);
}
/* 贴纸面板 */
.sticker-panel {
padding: 28px;
}
.sticker-list {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.sticker-thumb {
position: relative;
width: 120px;
height: 120px;
border: 2px dashed var(--line-star);
border-radius: 12px;
overflow: hidden;
background: var(--bg-input);
}
.sticker-thumb.active {
border-color: var(--accent-pink);
border-width: 3px;
}
.sticker-thumb.overlap {
border-color: #ff4757;
}
.sticker-thumb-img {
width: 100%;
height: 100%;
}
.sticker-del {
position: absolute;
top: 4px;
right: 4px;
width: 36px;
height: 36px;
background: rgba(255, 71, 87, 0.85);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.del-icon {
color: #fff;
font-size: 24px;
font-weight: 700;
}
.sticker-add-btn {
width: 120px;
height: 120px;
border: 2px dashed var(--line-star);
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg-input);
}
.add-icon {
font-size: 48px;
color: var(--accent-pink);
line-height: 1;
}
.add-label {
font-size: 22px;
color: var(--text-secondary);
margin-top: 8px;
}
.overlap-hint {
display: block;
margin-top: 16px;
font-size: 26px;
color: #ff4757;
font-weight: 600;
text-align: center;
}
/* 工具栏 */
.toolbar {
padding: 28px 32px;
}
.tool-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.tool-row:last-child {
margin-bottom: 0;
}
.tool-label {
font-size: 28px;
font-weight: 600;
color: var(--text-primary);
}
.tool-btn {
width: 56px;
height: 56px;
background: linear-gradient(135deg, rgba(255, 154, 158, 0.08) 0%, rgba(160, 196, 255, 0.08) 100%);
border: 2px dashed var(--accent-blue);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: var(--text-primary);
font-weight: 700;
}
.tool-value {
font-size: 28px;
font-weight: 600;
color: var(--accent-pink);
min-width: 80px;
text-align: center;
}
.tool-hint {
font-size: 24px;
color: var(--text-secondary);
}
/* 预览弹窗 */
.preview-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 40px;
}
.preview-card {
background: var(--bg-card);
width: 100%;
max-height: 80vh;
overflow-y: auto;
padding: 40px;
}
.preview-title {
font-size: 36px;
font-weight: 700;
color: var(--text-primary);
display: block;
margin-bottom: 24px;
text-align: center;
}
.preview-canvas {
background: linear-gradient(135deg, rgba(255, 154, 158, 0.08) 0%, rgba(160, 196, 255, 0.08) 100%);
border-radius: 16px;
overflow: hidden;
margin: 0 auto 24px;
border: 3px dashed var(--accent-blue);
position: relative;
min-width: 200px;
min-height: 200px;
max-width: 100%;
box-sizing: border-box;
}
.preview-image {
position: absolute;
max-width: 100%;
max-height: 100%;
z-index: 5;
}
.preview-image.overlap {
border: 2px solid #ff4757;
}
.product-info {
text-align: center;
margin-bottom: 32px;
}
.product-name {
font-size: 30px;
font-weight: 700;
color: var(--text-primary);
display: block;
margin-bottom: 8px;
}
.product-size {
font-size: 24px;
color: var(--text-secondary);
}
.preview-actions {
display: flex;
gap: 20px;
}
.preview-actions .btn-outline,
.preview-actions .btn-gradient {
flex: 1;
text-align: center;
}
.preview-actions .btn-gradient.disabled {
opacity: 0.5;
pointer-events: none;
}
/* 常驻编辑按钮 */
.edit-btn-bar {
display: flex;
justify-content: flex-end;
}
.edit-btn {
padding: 16px 48px;
border-radius: 50px;
font-size: 28px;
font-weight: 600;
text-align: center;
background: var(--bg-input);
color: var(--text-secondary);
border: 2px dashed var(--text-muted);
transition: all 0.2s ease;
}
.edit-btn.active {
background: linear-gradient(135deg, rgba(255, 154, 158, 0.12) 0%, rgba(160, 196, 255, 0.12) 100%);
color: var(--accent-pink);
border-color: var(--accent-pink);
}
.edit-btn.disabled {
opacity: 0.5;
pointer-events: none;
}
/* 按钮 */
.action-btns {
display: flex;
flex-direction: column;
gap: 20px;
}
.mt-20 {
margin-top: 20px;
}
+411
View File
@@ -0,0 +1,411 @@
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react'
import './index.scss'
import { getProductById } from '../../utils/productConfig'
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../utils/store'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
export default function DIYPage() {
const { theme, resolvedTheme } = useThemeContext()
const safe = useSafeArea()
useStatusBar(resolvedTheme)
const [category, setCategory] = useState<any>(null)
const [step, setStep] = useState(1)
const [designId, setDesignId] = useState('')
const [quantity, setQuantity] = useState(1)
const [stickers, setStickers] = useState<StickerItem[]>([])
const [activeStickerId, setActiveStickerId] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [startPos, setStartPos] = useState({ x: 0, y: 0 })
const [previewMode, setPreviewMode] = useState(false)
const [hasOverlap, setHasOverlap] = useState(false)
useEffect(() => {
const params = Taro.getCurrentInstance().router?.params
const source = params?.source
const productId = params?.productId || params?.category
if (source === 'product' && productId) {
const product = getProductById(productId)
if (product) {
setCategory(product)
setQuantity(Number(params?.quantity) || 1)
const newDesign: DesignItem = {
id: 'DSG' + Date.now(),
productId: product.id,
productName: product.name,
productIcon: product.icon,
unitPrice: product.price,
count: Number(params?.quantity) || 1,
status: 'designing',
createdAt: new Date().toISOString().slice(0, 10)
}
const list = getDesignList()
setDesignList([...list, newDesign])
setDesignId(newDesign.id)
}
return
}
if (source === 'designList' && params?.designId) {
const dId = params.designId
const list = getDesignList()
const design = list.find(d => d.id === dId)
if (design) {
setDesignId(dId)
setQuantity(design.count)
const product = getProductById(design.productId)
if (product) setCategory(product)
if (design.designData?.stickers) {
setStickers(design.designData.stickers)
} else if (design.designData?.imageSrc) {
// 兼容旧版数据
const s: StickerItem = {
id: 'legacy_' + Date.now(),
src: design.designData.imageSrc,
x: design.designData.imagePos?.x || 0,
y: design.designData.imagePos?.y || 0,
scale: design.designData.imagePos?.scale || 1,
width: 200,
height: 200,
isOverlapping: false
}
setStickers([s])
}
}
return
}
// 默认情况(从首页 old category 参数兼容)
if (productId) {
const found = getProductById(productId)
if (found) {
setCategory(found)
const newDesign: DesignItem = {
id: 'DSG' + Date.now(),
productId: found.id,
productName: found.name,
productIcon: found.icon,
unitPrice: found.price,
count: 1,
status: 'designing',
createdAt: new Date().toISOString().slice(0, 10)
}
const list = getDesignList()
setDesignList([...list, newDesign])
setDesignId(newDesign.id)
}
}
}, [])
// 矩形碰撞检测
const checkOverlap = (list: StickerItem[]) => {
const newList = list.map(s => ({ ...s, isOverlapping: false }))
for (let i = 0; i < newList.length; i++) {
for (let j = i + 1; j < newList.length; j++) {
const a = newList[i]
const b = newList[j]
const aw = a.width * a.scale
const ah = a.height * a.scale
const bw = b.width * b.scale
const bh = b.height * b.scale
if (
a.x < b.x + bw &&
a.x + aw > b.x &&
a.y < b.y + bh &&
a.y + ah > b.y
) {
newList[i].isOverlapping = true
newList[j].isOverlapping = true
}
}
}
const overlapAny = newList.some(s => s.isOverlapping)
setHasOverlap(overlapAny)
return newList
}
const addSticker = () => {
Taro.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const src = res.tempFilePaths[0]
// 获取图片尺寸用于碰撞检测
Taro.getImageInfo({
src,
success: (info) => {
const newSticker: StickerItem = {
id: 'stk_' + Date.now(),
src,
x: 0,
y: 0,
scale: 1,
width: info.width,
height: info.height,
isOverlapping: false
}
const next = [...stickers, newSticker]
const checked = checkOverlap(next)
setStickers(checked)
setActiveStickerId(newSticker.id)
},
fail: () => {
// fallback 尺寸
const newSticker: StickerItem = {
id: 'stk_' + Date.now(),
src,
x: 0,
y: 0,
scale: 1,
width: 200,
height: 200,
isOverlapping: false
}
const next = [...stickers, newSticker]
const checked = checkOverlap(next)
setStickers(checked)
setActiveStickerId(newSticker.id)
}
})
}
})
}
const deleteSticker = (id: string) => {
const next = stickers.filter(s => s.id !== id)
const checked = checkOverlap(next)
setStickers(checked)
if (activeStickerId === id) setActiveStickerId(null)
}
const handleStickerTouchStart = (e: any, id: string) => {
const touch = e.touches[0]
const s = stickers.find(x => x.id === id)
if (!s) return
setActiveStickerId(id)
setIsDragging(true)
setStartPos({ x: touch.clientX - s.x, y: touch.clientY - s.y })
}
const handleStickerTouchMove = (e: any) => {
if (!isDragging || !activeStickerId) return
const touch = e.touches[0]
const next = stickers.map(s => {
if (s.id !== activeStickerId) return s
return { ...s, x: touch.clientX - startPos.x, y: touch.clientY - startPos.y }
})
const checked = checkOverlap(next)
setStickers(checked)
}
const handleTouchEnd = () => setIsDragging(false)
const handleEditSticker = () => {
if (!activeStickerId || !designId) return
Taro.navigateTo({
url: `/pages/diy/stickerEdit/index?designId=${designId}&stickerId=${activeStickerId}`
})
}
const handleScale = (id: string, delta: number) => {
const next = stickers.map(s => {
if (s.id !== id) return s
return { ...s, scale: Math.max(0.3, Math.min(3, s.scale + delta)) }
})
const checked = checkOverlap(next)
setStickers(checked)
}
const goBack = () => {
Taro.navigateBack()
}
const getMaskStyle = () => {
if (!category) return { width: 300, height: 420 }
const base: any = { width: category.mask.width, height: category.mask.height }
if (category.mask.shape === 'rect') {
base.borderRadius = category.mask.borderRadius || 0
}
if (category.mask.shape === 'circle') {
base.borderRadius = '50%'
}
return base
}
const handleComplete = () => {
if (hasOverlap) {
Taro.showToast({ title: '贴纸不能重叠', icon: 'none' })
return
}
if (designId) {
updateDesign(designId, {
designData: {
stickers,
category
}
})
}
setPreviewMode(false)
Taro.navigateTo({ url: `/pages/checkout/index?designId=${designId}` })
}
if (!category) {
return (
<View className={`theme-${resolvedTheme}`}>
<View className='diy-page'>
<Text className='page-title'>...</Text>
</View>
</View>
)
}
return (
<View className={`theme-${resolvedTheme}`}>
<View className='diy-page'>
<View className='page-header dashed-card mt-20' style={{ marginTop: `${safe.headerPaddingTop}px` }}>
<View className='star-badge' />
<View className='flex-between' style={{ width: '100%' }}>
<Text className='back-btn' onTap={goBack}></Text>
<Text className='page-title'></Text>
<View style={{ width: '60px' }} />
</View>
</View>
{/* 画布区域 — catchtouchmove 阻止事件冒泡导致页面滚动 */}
<View className='canvas-wrapper dashed-card mt-20' catchMove>
<View className='star-badge' />
<View className='canvas-area' style={getMaskStyle()}>
{stickers.map(s => (
<Image
key={s.id}
className={`canvas-image ${s.isOverlapping ? 'overlap' : ''} ${activeStickerId === s.id ? 'active' : ''}`}
src={s.src}
style={{
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
opacity: isDragging && activeStickerId === s.id ? 0.8 : 1,
zIndex: activeStickerId === s.id ? 10 : 5,
width: s.width || 200,
height: s.height || 200
}}
onTouchStart={(e) => handleStickerTouchStart(e, s.id)}
onTouchMove={handleStickerTouchMove}
onTouchEnd={handleTouchEnd}
onTap={() => setActiveStickerId(s.id)}
mode='aspectFit'
/>
))}
<View className='mask-border' style={getMaskStyle()} />
</View>
</View>
{/* 贴纸列表与控制 */}
<View className='sticker-panel dashed-card mt-20'>
<View className='star-badge' />
<Text className='section-title'></Text>
<View className='sticker-list'>
{stickers.map(s => (
<View key={s.id} className={`sticker-thumb ${s.isOverlapping ? 'overlap' : ''} ${activeStickerId === s.id ? 'active' : ''}`}>
<Image src={s.src} className='sticker-thumb-img' mode='aspectFit' onTap={() => setActiveStickerId(s.id)} />
<View className='sticker-del' onTap={() => deleteSticker(s.id)}>
<Text className='del-icon'>×</Text>
</View>
</View>
))}
<View className='sticker-add-btn' onTap={addSticker}>
<Text className='add-icon'>+</Text>
<Text className='add-label'></Text>
</View>
</View>
{hasOverlap && <Text className='overlap-hint'> </Text>}
</View>
{/* 控制工具栏 */}
{activeStickerId && (
<View className='toolbar dashed-card mt-20'>
<View className='star-badge' />
<View className='tool-row'>
<Text className='tool-label'></Text>
<View className='flex-center' style={{ gap: '20px' }}>
<View className='tool-btn' onTap={() => handleScale(activeStickerId, -0.1)}></View>
<Text className='tool-value'>
{Math.round((stickers.find(s => s.id === activeStickerId)?.scale || 1) * 100)}%
</Text>
<View className='tool-btn' onTap={() => handleScale(activeStickerId, 0.1)}></View>
</View>
</View>
<View className='tool-row'>
<Text className='tool-label'></Text>
<Text className='tool-hint'></Text>
</View>
</View>
)}
{/* 常驻编辑按钮 */}
<View className='edit-btn-bar mt-20'>
<View
className={`edit-btn ${activeStickerId ? 'active' : 'disabled'}`}
onTap={handleEditSticker}
>
<Text></Text>
</View>
</View>
{/* 底部操作按钮 */}
<View className='action-btns mt-20'>
<View className='btn-gradient' onTap={() => setPreviewMode(true)}>
<Text></Text>
</View>
<View className='btn-outline' onTap={addSticker}>
<Text></Text>
</View>
</View>
{/* 预览弹层 */}
{previewMode && (
<View className='preview-overlay'>
<View className='preview-card dashed-card'>
<View className='star-badge' />
<Text className='preview-title'></Text>
<View className='preview-canvas' style={getMaskStyle()}>
{stickers.map(s => (
<Image
key={s.id}
className={`preview-image ${s.isOverlapping ? 'overlap' : ''}`}
src={s.src}
style={{
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
width: s.width || 200,
height: s.height || 200
}}
mode='aspectFit'
/>
))}
</View>
<View className='product-info'>
<Text className='product-name'>{category.name}</Text>
<Text className='product-size'>: {category.mask.width} × {category.mask.height} px</Text>
{hasOverlap && <Text className='overlap-hint'></Text>}
</View>
<View className='preview-actions'>
<View className='btn-outline' onTap={() => setPreviewMode(false)}>
<Text></Text>
</View>
<View className={`btn-gradient ${hasOverlap ? 'disabled' : ''}`} onTap={handleComplete}>
<Text></Text>
</View>
</View>
</View>
</View>
)}
<View style={{ height: '40px' }} />
</View>
</View>
)
}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '编辑贴纸'
})
+184
View File
@@ -0,0 +1,184 @@
.sticker-edit-page {
padding: 0 24px 24px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.edit-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(env(safe-area-inset-top, 0px) + 24px) 0 24px;
}
.edit-back {
font-size: 36px;
color: var(--text-primary);
width: 60px;
}
.edit-title {
font-size: 34px;
font-weight: 700;
color: var(--text-primary);
text-align: center;
flex: 1;
}
.edit-save {
font-size: 28px;
color: var(--accent-pink);
font-weight: 600;
width: 60px;
text-align: right;
}
/* 画布区域 */
.edit-canvas-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px 0;
min-height: 400px;
}
.edit-canvas {
width: 100%;
height: 500px;
background: var(--bg-input);
border-radius: 16px;
}
/* 工具面板 */
.edit-tools {
padding: 32px;
}
.edit-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32px;
}
.edit-row:last-child {
margin-bottom: 0;
}
.edit-label {
font-size: 28px;
font-weight: 500;
color: var(--text-primary);
width: 120px;
flex-shrink: 0;
}
.slider-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 16px;
}
.slider-track {
flex: 1;
height: 8px;
border-radius: 4px;
background: var(--bg-input);
position: relative;
}
.slider-fill {
height: 100%;
border-radius: 4px;
background: linear-gradient(90deg, #ff9a9e, #fecfef);
position: absolute;
left: 0;
top: 0;
}
.slider-thumb {
width: 32px;
height: 32px;
border-radius: 50%;
background: #fff;
border: 4px solid var(--accent-pink);
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.slider-value {
font-size: 24px;
color: var(--text-secondary);
min-width: 60px;
text-align: right;
}
/* 快速操作按钮 */
.edit-quick-actions {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.quick-btn {
padding: 16px 28px;
border-radius: 40px;
font-size: 24px;
font-weight: 500;
background: var(--bg-input);
color: var(--text-primary);
border: 2px dashed var(--line-star);
transition: all 0.2s;
}
.quick-btn:active {
opacity: 0.8;
transform: scale(0.97);
}
.quick-btn.active {
background: rgba(255, 154, 158, 0.12);
border-color: var(--accent-pink);
color: var(--accent-pink);
}
/* 底部操作 */
.edit-footer {
padding: 20px 0 calc(20px + env(safe-area-inset-bottom));
display: flex;
gap: 20px;
}
.edit-footer .btn-gradient,
.edit-footer .btn-outline {
flex: 1;
text-align: center;
padding: 24px 0;
font-size: 28px;
}
/* 加载遮罩 */
.edit-loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.4);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.edit-loading-text {
font-size: 28px;
color: #fff;
margin-top: 20px;
}
+420
View File
@@ -0,0 +1,420 @@
import { View, Text, Canvas, Image } from '@tarojs/components'
import Taro, { createCanvasContext, canvasToTempFilePath } from '@tarojs/taro'
import { useState, useEffect, useRef, useCallback } from 'react'
import './index.scss'
import { getProductById } from '../../../utils/productConfig'
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../../utils/store'
import { useThemeContext } from '../../../context/ThemeContext'
import { useSafeArea } from '../../../hooks/useSafeArea'
import { useStatusBar } from '../../../hooks/useStatusBar'
/* ============================================================
使用 Canvas 实现贴纸编辑(亮度/色相/线稿)
============================================================ */
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
/** 拖动手势滑块组件 */
interface SliderProps {
value: number
min: number
max: number
step?: number
onChange: (val: number) => void
showValue?: boolean
format?: (v: number) => string
}
function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProps) {
const trackRef = useRef<any>(null)
const [dragging, setDragging] = useState(false)
const computedFromClientX = (clientX: number) => {
if (!trackRef.current) return value
const query = Taro.createSelectorQuery().in(trackRef.current)
// 注意:这里需要取 track 元素的位置信息
const rect = trackRef.current.getBoundingClientRect ? trackRef.current.getBoundingClientRect() : { left: 0, width: 300 }
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
let raw = min + (max - min) * ratio
if (step > 0) {
raw = Math.round(raw / step) * step
}
return clamp(raw, min, max)
}
const handleTouchStart = (e: any) => {
setDragging(true)
const x = e.touches?.[0]?.clientX ?? 0
onChange(computedFromClientX(x))
}
const handleTouchMove = (e: any) => {
if (!dragging) return
const x = e.touches?.[0]?.clientX ?? 0
onChange(computedFromClientX(x))
}
const handleTouchEnd = () => {
setDragging(false)
}
const pct = `${((value - min) / (max - min)) * 100}%`
return (
<View className='slider-wrap'>
<View
className='slider-track'
ref={trackRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<View className='slider-fill' style={{ width: pct }} />
<View className={`slider-thumb ${dragging ? 'dragging' : ''}`} style={{ left: pct }} />
</View>
<Text className='slider-value'>{format ? format(value) : value}</Text>
</View>
)
}
export default function StickerEditPage() {
const { resolvedTheme } = useThemeContext()
const safe = useSafeArea()
useStatusBar(resolvedTheme)
const [designId, setDesignId] = useState('')
const [stickerId, setStickerId] = useState('')
const [sticker, setSticker] = useState<StickerItem | null>(null)
// 编辑参数
const [brightness, setBrightness] = useState(0)
const [hue, setHue] = useState(0)
const [contrast, setContrast] = useState(0)
const [loading, setLoading] = useState(false)
const [canvasReady, setCanvasReady] = useState(false)
const canvasRef = useRef<any>(null)
const ctxRef = useRef<any>(null)
const canvasNodeRef = useRef<any>(null)
const canvasSizeRef = useRef<{width:number, height:number}>({width:0,height:0})
// 使用 nextTick + 重试初始化 Canvas(小程序 2D)
const initCanvas = useCallback((st: StickerItem) => {
if (canvasReady) return
Taro.nextTick(() => {
const query = Taro.createSelectorQuery()
query.select('#editCanvas')
.fields({ node: true, size: true })
.exec((res: any) => {
const canvas = res?.[0]?.node
if (!canvas) {
setTimeout(() => initCanvas(st), 300)
return
}
const ctx = canvas.getContext('2d')
let cw = res[0]?.width || 300
let ch = res[0]?.height || 400
if (cw <= 0) cw = 300
if (ch <= 0) ch = 400
const dpr = Taro.getSystemInfoSync().pixelRatio || 1
canvas.width = Math.round(cw * dpr)
canvas.height = Math.round(ch * dpr)
ctx.scale(dpr, dpr)
canvasNodeRef.current = canvas
ctxRef.current = ctx
canvasSizeRef.current = { width: cw, height: ch }
setCanvasReady(true)
drawSticker(ctx, cw, ch, st)
})
})
}, [canvasReady])
useEffect(() => {
const params = Taro.getCurrentInstance().router?.params
const dId = params?.designId as string
const sId = params?.stickerId as string
setDesignId(dId)
setStickerId(sId)
const list = getDesignList()
const design = list.find(d => d.id === dId)
const st = design?.designData?.stickers?.find(s => s.id === sId)
if (st) {
setSticker(st)
setBrightness(st.edits?.brightness || 0)
setHue(st.edits?.hue || 0)
setContrast(st.edits?.contrast || 0)
initCanvas(st)
}
}, [initCanvas])
/** canvas 上绘制带滤镜的贴纸 */
const drawSticker = (ctx: any, cw: number, ch: number, st: StickerItem, opts?: { brightness: number; hue: number; contrast: number }) => {
if (!ctx || !canvasNodeRef.current) return
const canvas = canvasNodeRef.current
// 防护:cw/ch 为 0 时容易绘制异常
if (!cw || !ch) return
const imgW = st.width || 200
const imgH = st.height || 200
const img = canvas.createImage()
img.onload = () => {
ctx.clearRect(0, 0, cw, ch)
ctx.save()
// 居中缩放显示(限制最大为画板 90%)
const scale = Math.min(cw / imgW, ch / imgH, 4) * 0.9
const w = imgW * scale
const h = imgH * scale
const x = (cw - w) / 2
const y = (ch - h) / 2
// 应用滤镜(小程序 Canvas 2D 支持 filter 属性 2.16.0+
const b = opts?.brightness ?? brightness
const hVal = opts?.hue ?? hue
const c = opts?.contrast ?? contrast
if (b !== 0 || hVal !== 0 || c !== 0) {
ctx.filter = `brightness(${100 + b}%) hue-rotate(${hVal}deg) contrast(${100 + c}%)`
} else {
ctx.filter = 'none'
}
ctx.drawImage(img, x, y, w, h)
ctx.restore()
}
img.onerror = (err: any) => {
console.error('Canvas load image error:', err, st.src)
Taro.showToast({ title: '图片加载失败', icon: 'none' })
}
if (st.src) {
img.src = st.src
}
}
/** 重新绘制(参数变化时) */
const redraw = () => {
if (!sticker || !ctxRef.current || !canvasNodeRef.current) return
const { width, height } = canvasSizeRef.current
drawSticker(ctxRef.current, width, height, sticker, { brightness, hue, contrast })
}
useEffect(() => {
redraw()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [brightness, hue, contrast])
const handleSave = () => {
if (!sticker || !designId) return
setLoading(true)
// 使用 Canvas 2D 导出
// @ts-ignore
Taro.canvasToTempFilePath({
canvas: canvasNodeRef.current,
quality: 0.92,
success: (res: any) => {
const newSrc = res.tempFilePath
const dList = getDesignList()
const dIdx = dList.findIndex(d => d.id === designId)
if (dIdx === -1) { setLoading(false); return }
const stickers = dList[dIdx].designData?.stickers || []
const sIdx = stickers.findIndex(s => s.id === stickerId)
if (sIdx === -1) { setLoading(false); return }
stickers[sIdx] = {
...stickers[sIdx],
src: newSrc,
edits: { brightness, hue, contrast }
}
updateDesign(designId, { designData: { ...dList[dIdx].designData, stickers } })
Taro.showToast({ title: '保存成功', icon: 'success' })
Taro.navigateBack()
},
fail: () => {
setLoading(false)
Taro.showToast({ title: '保存失败', icon: 'none' })
}
})
}
/** 裁剪:调用微信cropImage */
const handleCrop = () => {
if (!sticker) return
// @ts-ignore
if (!Taro.cropImage) {
Taro.showToast({ title: '当前微信版本不支持裁剪', icon: 'none' })
return
}
// @ts-ignore
Taro.cropImage({
src: sticker.src,
cropScale: '1:1',
success: (res) => {
const newSticker = { ...sticker, src: res.tempFilePath }
setSticker(newSticker)
// 重绘
setTimeout(() => redraw(), 200)
}
})
}
/** 线稿:预留后端AI接口 */
const API_BASE = 'https://your-api-domain.com' // ← 填入你的服务器地址
const handleSketch = () => {
if (!sticker) return
setLoading(true)
Taro.uploadFile({
url: `${API_BASE}/api/sketch`,
filePath: sticker.src,
name: 'image',
success: (res) => {
try {
const data = JSON.parse(res.data)
if (data.url) {
const newSticker = { ...sticker, src: data.url, edits: { ...sticker.edits, sketchSrc: data.url } }
setSticker(newSticker)
setTimeout(() => redraw(), 200)
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
} else {
throw new Error('no url')
}
} catch {
// 如果接口不可用,降级为前端灰度+边缘检测
applyFrontendSketch()
}
},
fail: () => {
applyFrontendSketch()
}
})
}
/** 前端线稿降级方案:灰度+反相高对比 */
const applyFrontendSketch = () => {
if (!sticker || !ctxRef.current) { setLoading(false); return }
Taro.showToast({ title: '使用本地线稿', icon: 'none' })
setBrightness(20)
setContrast(80)
setHue(0)
setLoading(false)
}
const bPct = `${((brightness + 100) / 200) * 100}%`
const hPct = `${((hue + 180) / 360) * 100}%`
const cPct = `${((contrast + 100) / 200) * 100}%`
return (
<View className={`theme-${resolvedTheme}`}>
<View className='sticker-edit-page'>
{/* Header */}
<View className='edit-header' style={{ paddingTop: `${safe.headerPaddingTop}px` }}>
<Text className='edit-back' onTap={() => Taro.navigateBack()}></Text>
<Text className='edit-title'></Text>
<Text className='edit-save' onTap={handleSave}></Text>
</View>
{/* Canvas 预览 */}
<View className='edit-canvas-wrap'>
<Canvas
id='editCanvas'
type='2d'
className='edit-canvas'
style={{ width: '100%', height: '400px' }}
/>
</View>
{/* 快速操作 */}
<View className='edit-tools dashed-card mt-20'>
<View className='edit-row'>
<Text className='edit-label'></Text>
<View className='edit-quick-actions'>
<View className='quick-btn' onTap={handleCrop}>
<Text></Text>
</View>
<View className='quick-btn' onTap={handleSketch}>
<Text>线稿</Text>
</View>
</View>
</View>
{/* 亮度 */}
<View className='edit-row slider-row'
onTouchMove={(e) => {
const { clientX } = e.changedTouches[0]
// 取得滑轨位置
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
query.select('.slider-track').boundingClientRect((rect: any) => {
if (!rect) return
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
const v = Math.round((-100 + 200 * ratio) / 1) * 1
setBrightness(clamp(v, -100, 100))
}).exec()
}}
>
<Text className='edit-label'></Text>
<View className='slider-wrap'>
<View className='slider-track'>
<View className='slider-fill' style={{ width: bPct }} />
<View className='slider-thumb' style={{ left: bPct }} />
</View>
<Text className='slider-value'>{brightness > 0 ? `+${brightness}` : brightness}</Text>
</View>
</View>
{/* 色相 */}
<View className='edit-row slider-row'
onTouchMove={(e) => {
const { clientX } = e.changedTouches[0]
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
query.select('.slider-track').boundingClientRect((rect: any) => {
if (!rect) return
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
const v = Math.round((-180 + 360 * ratio) / 1) * 1
setHue(clamp(v, -180, 180))
}).exec()
}}
>
<Text className='edit-label'></Text>
<View className='slider-wrap'>
<View className='slider-track'>
<View className='slider-fill' style={{ width: hPct }} />
<View className='slider-thumb' style={{ left: hPct }} />
</View>
<Text className='slider-value'>{hue > 0 ? `+${hue}` : hue}</Text>
</View>
</View>
{/* 对比度 */}
<View className='edit-row slider-row'
onTouchMove={(e) => {
const { clientX } = e.changedTouches[0]
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
query.select('.slider-track').boundingClientRect((rect: any) => {
if (!rect) return
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
const v = Math.round((-100 + 200 * ratio) / 1) * 1
setContrast(clamp(v, -100, 100))
}).exec()
}}
>
<Text className='edit-label'></Text>
<View className='slider-wrap'>
<View className='slider-track'>
<View className='slider-fill' style={{ width: cPct }} />
<View className='slider-thumb' style={{ left: cPct }} />
</View>
<Text className='slider-value'>{contrast > 0 ? `+${contrast}` : contrast}</Text>
</View>
</View>
</View>
<View style={{ flex: 1 }} />
{loading && (
<View className='edit-loading'>
<Text className='edit-loading-text'></Text>
</View>
)}
</View>
</View>
)
}