Files
wechat_wc/src/pages/diy/stickerEdit/index.tsx
T
lhmin0604andClaude 2e3676a470 fix(diy): 贴纸编辑页滑块组件化 + CSS filter 预览 + edits 参数化保存
修正工作流批次③(问题5):
- 三个滑块统一改用 TouchSlider 组件(id 选择器定位 + touchstart 缓存
  rect),删除原 .in(e.currentTarget) 坏实现与死代码
- 预览弃用 Canvas ctx.filter(开发者工具不支持),改 Image + CSS filter
  所见即所得;Canvas 导出链路整体移除
- 保存改为 edits 参数化持久化(src 不变;裁剪/线稿的 src 替换经
  pendingSrc 一并落库),不再依赖 canvasToTempFilePath
- DIY 工作台画布与预览弹层渲染端消费 edits(同一 CSS filter 逻辑),
  checkout 预览消费 edits 记入 R3 待办(routes/R2/README.md #6)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-12 19:29:03 +08:00

331 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect, useRef } from 'react'
import './index.scss'
import { getDesignList, updateDesign, type StickerItem } from '../../../utils/store'
import { sketchImage, updateDesign as updateDesignApi } from '../../../utils/api'
import { useThemeContext } from '../../../context/ThemeContext'
import { useSafeArea } from '../../../hooks/useSafeArea'
import { useStatusBar } from '../../../hooks/useStatusBar'
import ThemedPageMeta from '../../../components/ThemedPageMeta'
import ScrollTopMask from '../../../components/ScrollTopMask'
import BottomActionBar from '../../../components/BottomActionBar'
/* ============================================================
贴纸编辑(亮度/色相/对比度/裁剪/线稿)
问题5 修正:edits 参数化保存(src 不变),预览用 Image + CSS filter
所见即所得,不再依赖 Canvas 2D ctx.filter(开发者工具不支持);
DIY 渲染端按同一 edits 套 CSS filter 呈现(契约 §4edits 不进 WCD
document.json,仅 manifest.metasrc 保持原样对 R4 无影响)。
============================================================ */
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
/** 拖动手势滑块组件(问题5 修正:选择器查询按 id 定位,touchstart 时缓存 rect */
interface SliderProps {
id: string
value: number
min: number
max: number
onChange: (val: number) => void
format?: (v: number) => string
}
function TouchSlider({ id, value, min, max, onChange, format }: SliderProps) {
const rectRef = useRef<{ left: number; width: number } | null>(null)
const measure = (cb?: () => void) => {
Taro.createSelectorQuery()
.select(`#${id}`)
.boundingClientRect((rect: any) => {
if (rect && rect.width > 0) rectRef.current = { left: rect.left, width: rect.width }
if (cb) cb()
})
.exec()
}
const valueFromX = (clientX: number) => {
const rect = rectRef.current
if (!rect || !rect.width) return value
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
return clamp(min + (max - min) * ratio, min, max)
}
const handleTouchStart = (e: any) => {
const touch = e.touches && e.touches[0]
if (!touch) return
measure(() => onChange(valueFromX(touch.clientX)))
}
const handleTouchMove = (e: any) => {
const touch = e.touches && e.touches[0]
if (!touch) return
if (!rectRef.current) {
measure(() => onChange(valueFromX(touch.clientX)))
return
}
onChange(valueFromX(touch.clientX))
}
const pct = `${((value - min) / (max - min)) * 100}%`
return (
<View className='slider-wrap'>
<View
id={id}
className='slider-track'
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={() => { /* rect 已缓存,无需处理 */ }}
>
<View className='slider-fill' style={{ width: pct }} />
<View className='slider-thumb' 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 [notFound, setNotFound] = useState(false)
// 裁剪/线稿产生的 src 替换(保存时一并落库;取消则丢弃)
const [pendingSrc, setPendingSrc] = useState<string | null>(null)
// 编辑参数
const [brightness, setBrightness] = useState(0)
const [hue, setHue] = useState(0)
const [contrast, setContrast] = useState(0)
const [loading, setLoading] = useState(false)
useEffect(() => {
const router = Taro.getCurrentInstance().router
const params = router ? router.params : undefined
const dId = params ? params.designId : ''
const sId = params ? params.stickerId : ''
setDesignId(dId)
setStickerId(sId)
const list = getDesignList()
const design = list.find(d => d.id === dId)
const st = design && design.designData && design.designData.stickers ? design.designData.stickers.find(s => s.id === sId) : undefined
if (st) {
setSticker(st)
setBrightness((st.edits && st.edits.brightness) || 0)
setHue((st.edits && st.edits.hue) || 0)
setContrast((st.edits && st.edits.contrast) || 0)
} else {
// 问题4 留意点:找不到贴纸时给出空态,而不是停在空白 Canvas
setNotFound(true)
}
}, [])
/** 保存:edits 参数化持久化(src 不变),裁剪/线稿的 src 替换一并落库 */
const handleSave = () => {
if (!sticker || !designId) return
setLoading(true)
const dList = getDesignList()
const dIdx = dList.findIndex(d => d.id === designId)
if (dIdx === -1) { setLoading(false); setNotFound(true); return }
const d = dList[dIdx]
const stickers = (d.designData && d.designData.stickers) || []
const sIdx = stickers.findIndex(s => s.id === stickerId)
if (sIdx === -1) { setLoading(false); setNotFound(true); return }
stickers[sIdx] = {
...stickers[sIdx],
...(pendingSrc ? { src: pendingSrc } : {}),
edits: { brightness, hue, contrast }
}
// WCD 红线:基于服务端已有 designData 合并(保留 wordcloud 分组),只覆盖 stickers
const designData = { ...d.designData, stickers }
updateDesign(designId, { designData }) // 本地缓存同步
// 服务端保存(items 全量替换,必须带全 4 个基本字段);失败暂存本地
updateDesignApi(designId, {
item: {
productId: d.productId,
productName: d.productName,
unitPrice: d.unitPrice,
count: d.count,
designData
}
}).catch(() => Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' }))
setLoading(false)
Taro.showToast({ title: '保存成功', icon: 'success' })
Taro.navigateBack()
}
/** 裁剪:调用微信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)
setPendingSrc(res.tempFilePath)
}
})
}
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度参数 */
const handleSketch = async () => {
if (!sticker) return
setLoading(true)
try {
const res = await sketchImage(sticker.src)
if (res?.imageUrl) {
setSticker({ ...sticker, src: res.imageUrl })
setPendingSrc(res.imageUrl)
setLoading(false)
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
} else {
throw new Error('no imageUrl')
}
} catch {
// 如果接口不可用/失败,降级为前端灰度滤镜参数(保存时随 edits 持久化)
Taro.showToast({ title: '使用本地线稿', icon: 'none' })
setBrightness(20)
setContrast(80)
setHue(0)
setLoading(false)
}
}
const previewFilter = brightness === 0 && hue === 0 && contrast === 0
? 'none'
: `brightness(${100 + brightness}%) hue-rotate(${hue}deg) contrast(${100 + contrast}%)`
if (notFound) {
return (
<View className={`theme-${resolvedTheme}`}>
<ThemedPageMeta />
<ScrollTopMask title="编辑贴纸" targetSelector=".edit-header" showBack />
<View className='sticker-edit-page'>
<View className='edit-header' style={{ paddingTop: `${safe.statusBarHeight + 12}px` }}>
<Text className='edit-back' onTap={() => Taro.navigateBack()}></Text>
<Text className='edit-title'>编辑贴纸</Text>
<Text className='edit-save' />
</View>
<View className='edit-empty surface-card mt-20'>
<Text className='edit-empty-title'>未找到该贴纸</Text>
<Text className='edit-empty-desc'>贴纸可能已被删除,请返回工作台重新添加</Text>
<View className='btn-primary' onTap={() => Taro.navigateBack()}>
<Text>返回工作台</Text>
</View>
</View>
</View>
</View>
)
}
return (
<View className={`theme-${resolvedTheme}`}>
<ThemedPageMeta />
<ScrollTopMask title="编辑贴纸" targetSelector=".edit-header" showBack />
<View className='sticker-edit-page'>
{/* Header */}
<View className='edit-header' style={{ paddingTop: `${safe.statusBarHeight + 12}px` }}>
<Text className='edit-back' onTap={() => Taro.navigateBack()}></Text>
<Text className='edit-title'>编辑贴纸</Text>
<Text className='edit-save' onTap={handleSave}>保存</Text>
</View>
{/* 预览:Image + CSS filter 所见即所得(不依赖 Canvas ctx.filter */}
<View className='edit-canvas-wrap'>
<Image
className='edit-preview-img'
src={sticker ? sticker.src : ''}
mode='aspectFit'
style={{ filter: previewFilter }}
/>
</View>
{/* 快速操作 */}
<View className='edit-tools surface-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'>
<Text className='edit-label'>亮度</Text>
<TouchSlider
id='slider-brightness'
value={brightness}
min={-100}
max={100}
onChange={setBrightness}
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
</View>
{/* 色相 */}
<View className='edit-row slider-row'>
<Text className='edit-label'>色相</Text>
<TouchSlider
id='slider-hue'
value={hue}
min={-180}
max={180}
onChange={setHue}
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
</View>
{/* 对比度 */}
<View className='edit-row slider-row'>
<Text className='edit-label'>对比度</Text>
<TouchSlider
id='slider-contrast'
value={contrast}
min={-100}
max={100}
onChange={setContrast}
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
</View>
</View>
<View style={{ flex: 1 }} />
<BottomActionBar className='edit-footer'>
<View className='btn-secondary' onTap={() => Taro.navigateBack()}>
<Text>取消</Text>
</View>
<View className='btn-primary' onTap={handleSave}>
<Text>保存</Text>
</View>
</BottomActionBar>
<View className='safe-bottom-placeholder' />
{loading && (
<View className='edit-loading'>
<Text className='edit-loading-text'>处理中…</Text>
</View>
)}
</View>
</View>
)
}