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>
This commit is contained in:
Vendored
-8
@@ -133,14 +133,6 @@
|
|||||||
</image>
|
</image>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template name="tmpl_0_15">
|
|
||||||
<canvas canvas-id="{{i.p0}}" disable-scroll="{{xs.b(i.p1,!1)}}" binderror="eh" bindtouchstart="eh" bindtouchmove="eh" bindtouchend="eh" bindtouchcancel="eh" bindlongtap="eh" type="{{i.p2}}" style="{{i.st}}" class="{{i.cl}}" bindtap="eh" id="{{i.uid||i.sid}}" data-sid="{{i.sid}}">
|
|
||||||
<block wx:for="{{i.cn}}" wx:key="sid">
|
|
||||||
<template is="{{xs.a(c, item.nn, l)}}" data="{{i:item,c:c+1,l:xs.f(l,item.nn)}}" />
|
|
||||||
</block>
|
|
||||||
</canvas>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template name="tmpl_0_8">
|
<template name="tmpl_0_8">
|
||||||
<block>{{i.v}}</block>
|
<block>{{i.v}}</block>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -325,6 +325,18 @@ export default function DIYPage() {
|
|||||||
setStickers(checked)
|
setStickers(checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 问题5 修正:渲染端消费贴纸 edits(亮度/色相/对比度 → CSS filter),
|
||||||
|
// 与贴纸编辑页同一呈现逻辑;checkout 预览消费 edits 记入 R3 待办
|
||||||
|
const stickerFilter = (s: StickerItem) => {
|
||||||
|
const ed = s.edits
|
||||||
|
if (!ed) return undefined
|
||||||
|
const b = ed.brightness || 0
|
||||||
|
const h = ed.hue || 0
|
||||||
|
const c = ed.contrast || 0
|
||||||
|
if (b === 0 && h === 0 && c === 0) return undefined
|
||||||
|
return `brightness(${100 + b}%) hue-rotate(${h}deg) contrast(${100 + c}%)`
|
||||||
|
}
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
Taro.navigateBack()
|
Taro.navigateBack()
|
||||||
}
|
}
|
||||||
@@ -425,6 +437,7 @@ export default function DIYPage() {
|
|||||||
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
|
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
|
||||||
opacity: isDragging && activeStickerId === s.id ? 0.8 : 1,
|
opacity: isDragging && activeStickerId === s.id ? 0.8 : 1,
|
||||||
zIndex: activeStickerId === s.id ? 10 : 5,
|
zIndex: activeStickerId === s.id ? 10 : 5,
|
||||||
|
filter: stickerFilter(s),
|
||||||
width: s.width || 200,
|
width: s.width || 200,
|
||||||
height: s.height || 200
|
height: s.height || 200
|
||||||
}}
|
}}
|
||||||
@@ -510,6 +523,7 @@ export default function DIYPage() {
|
|||||||
src={s.src}
|
src={s.src}
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
|
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
|
||||||
|
filter: stickerFilter(s),
|
||||||
width: s.width || 200,
|
width: s.width || 200,
|
||||||
height: s.height || 200
|
height: s.height || 200
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -81,6 +81,16 @@
|
|||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 预览图(Image + CSS filter,替代 Canvas 预览) */
|
||||||
|
.edit-preview-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 480rpx;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
border: 1rpx solid var(--border-strong);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
/* 工具面板 */
|
/* 工具面板 */
|
||||||
.edit-tools {
|
.edit-tools {
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
|
|||||||
+115
-255
@@ -1,9 +1,8 @@
|
|||||||
import { View, Text, Canvas, Image } from '@tarojs/components'
|
import { View, Text, Image } from '@tarojs/components'
|
||||||
import Taro, { createCanvasContext, canvasToTempFilePath } from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
import { getProductById } from '../../../utils/productConfig'
|
import { getDesignList, updateDesign, type StickerItem } from '../../../utils/store'
|
||||||
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../../utils/store'
|
|
||||||
import { sketchImage, updateDesign as updateDesignApi } from '../../../utils/api'
|
import { sketchImage, updateDesign as updateDesignApi } from '../../../utils/api'
|
||||||
import { useThemeContext } from '../../../context/ThemeContext'
|
import { useThemeContext } from '../../../context/ThemeContext'
|
||||||
import { useSafeArea } from '../../../hooks/useSafeArea'
|
import { useSafeArea } from '../../../hooks/useSafeArea'
|
||||||
@@ -13,55 +12,59 @@ import ScrollTopMask from '../../../components/ScrollTopMask'
|
|||||||
import BottomActionBar from '../../../components/BottomActionBar'
|
import BottomActionBar from '../../../components/BottomActionBar'
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
使用 Canvas 实现贴纸编辑(亮度/色相/线稿)
|
贴纸编辑(亮度/色相/对比度/裁剪/线稿)
|
||||||
|
问题5 修正:edits 参数化保存(src 不变),预览用 Image + CSS filter
|
||||||
|
所见即所得,不再依赖 Canvas 2D ctx.filter(开发者工具不支持);
|
||||||
|
DIY 渲染端按同一 edits 套 CSS filter 呈现(契约 §4:edits 不进 WCD
|
||||||
|
document.json,仅 manifest.meta,src 保持原样对 R4 无影响)。
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
|
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
|
||||||
|
|
||||||
/** 拖动手势滑块组件 */
|
/** 拖动手势滑块组件(问题5 修正:选择器查询按 id 定位,touchstart 时缓存 rect) */
|
||||||
interface SliderProps {
|
interface SliderProps {
|
||||||
|
id: string
|
||||||
value: number
|
value: number
|
||||||
min: number
|
min: number
|
||||||
max: number
|
max: number
|
||||||
step?: number
|
|
||||||
onChange: (val: number) => void
|
onChange: (val: number) => void
|
||||||
showValue?: boolean
|
|
||||||
format?: (v: number) => string
|
format?: (v: number) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProps) {
|
function TouchSlider({ id, value, min, max, onChange, format }: SliderProps) {
|
||||||
const trackRef = useRef<any>(null)
|
const rectRef = useRef<{ left: number; width: number } | null>(null)
|
||||||
const [dragging, setDragging] = useState(false)
|
|
||||||
|
|
||||||
const computedFromClientX = (clientX: number) => {
|
const measure = (cb?: () => void) => {
|
||||||
if (!trackRef.current) return value
|
Taro.createSelectorQuery()
|
||||||
const query = Taro.createSelectorQuery().in(trackRef.current)
|
.select(`#${id}`)
|
||||||
// 注意:这里需要取 track 元素的位置信息
|
.boundingClientRect((rect: any) => {
|
||||||
const rect = trackRef.current.getBoundingClientRect ? trackRef.current.getBoundingClientRect() : { left: 0, width: 300 }
|
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)
|
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
|
||||||
let raw = min + (max - min) * ratio
|
return clamp(min + (max - min) * ratio, min, max)
|
||||||
if (step > 0) {
|
|
||||||
raw = Math.round(raw / step) * step
|
|
||||||
}
|
|
||||||
return clamp(raw, min, max)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTouchStart = (e: any) => {
|
const handleTouchStart = (e: any) => {
|
||||||
setDragging(true)
|
|
||||||
const touch = e.touches && e.touches[0]
|
const touch = e.touches && e.touches[0]
|
||||||
const x = touch ? touch.clientX : 0
|
if (!touch) return
|
||||||
onChange(computedFromClientX(x))
|
measure(() => onChange(valueFromX(touch.clientX)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTouchMove = (e: any) => {
|
const handleTouchMove = (e: any) => {
|
||||||
if (!dragging) return
|
|
||||||
const touch = e.touches && e.touches[0]
|
const touch = e.touches && e.touches[0]
|
||||||
const x = touch ? touch.clientX : 0
|
if (!touch) return
|
||||||
onChange(computedFromClientX(x))
|
if (!rectRef.current) {
|
||||||
}
|
measure(() => onChange(valueFromX(touch.clientX)))
|
||||||
|
return
|
||||||
const handleTouchEnd = () => {
|
}
|
||||||
setDragging(false)
|
onChange(valueFromX(touch.clientX))
|
||||||
}
|
}
|
||||||
|
|
||||||
const pct = `${((value - min) / (max - min)) * 100}%`
|
const pct = `${((value - min) / (max - min)) * 100}%`
|
||||||
@@ -69,14 +72,14 @@ function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProp
|
|||||||
return (
|
return (
|
||||||
<View className='slider-wrap'>
|
<View className='slider-wrap'>
|
||||||
<View
|
<View
|
||||||
|
id={id}
|
||||||
className='slider-track'
|
className='slider-track'
|
||||||
ref={trackRef}
|
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={() => { /* rect 已缓存,无需处理 */ }}
|
||||||
>
|
>
|
||||||
<View className='slider-fill' style={{ width: pct }} />
|
<View className='slider-fill' style={{ width: pct }} />
|
||||||
<View className={`slider-thumb ${dragging ? 'dragging' : ''}`} style={{ left: pct }} />
|
<View className='slider-thumb' style={{ left: pct }} />
|
||||||
</View>
|
</View>
|
||||||
<Text className='slider-value'>{format ? format(value) : value}</Text>
|
<Text className='slider-value'>{format ? format(value) : value}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -91,50 +94,14 @@ export default function StickerEditPage() {
|
|||||||
const [stickerId, setStickerId] = useState('')
|
const [stickerId, setStickerId] = useState('')
|
||||||
const [sticker, setSticker] = useState<StickerItem | null>(null)
|
const [sticker, setSticker] = useState<StickerItem | null>(null)
|
||||||
const [notFound, setNotFound] = useState(false)
|
const [notFound, setNotFound] = useState(false)
|
||||||
|
// 裁剪/线稿产生的 src 替换(保存时一并落库;取消则丢弃)
|
||||||
|
const [pendingSrc, setPendingSrc] = useState<string | null>(null)
|
||||||
|
|
||||||
// 编辑参数
|
// 编辑参数
|
||||||
const [brightness, setBrightness] = useState(0)
|
const [brightness, setBrightness] = useState(0)
|
||||||
const [hue, setHue] = useState(0)
|
const [hue, setHue] = useState(0)
|
||||||
const [contrast, setContrast] = useState(0)
|
const [contrast, setContrast] = useState(0)
|
||||||
const [loading, setLoading] = useState(false)
|
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 resFirst = res && res[0]
|
|
||||||
const canvas = resFirst ? resFirst.node : undefined
|
|
||||||
if (!canvas) {
|
|
||||||
setTimeout(() => initCanvas(st), 300)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
let cw = (res[0] && res[0].width) || 300
|
|
||||||
let ch = (res[0] && 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(() => {
|
useEffect(() => {
|
||||||
const router = Taro.getCurrentInstance().router
|
const router = Taro.getCurrentInstance().router
|
||||||
@@ -152,112 +119,44 @@ export default function StickerEditPage() {
|
|||||||
setBrightness((st.edits && st.edits.brightness) || 0)
|
setBrightness((st.edits && st.edits.brightness) || 0)
|
||||||
setHue((st.edits && st.edits.hue) || 0)
|
setHue((st.edits && st.edits.hue) || 0)
|
||||||
setContrast((st.edits && st.edits.contrast) || 0)
|
setContrast((st.edits && st.edits.contrast) || 0)
|
||||||
initCanvas(st)
|
|
||||||
} else {
|
} else {
|
||||||
// 问题4 留意点:找不到贴纸时给出空态,而不是停在空白 Canvas
|
// 问题4 留意点:找不到贴纸时给出空态,而不是停在空白 Canvas
|
||||||
setNotFound(true)
|
setNotFound(true)
|
||||||
}
|
}
|
||||||
}, [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 ? opts.brightness : brightness
|
|
||||||
const hVal = opts ? opts.hue : hue
|
|
||||||
const c = opts ? 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])
|
|
||||||
|
|
||||||
|
/** 保存:edits 参数化持久化(src 不变),裁剪/线稿的 src 替换一并落库 */
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (!sticker || !designId) return
|
if (!sticker || !designId) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
// 使用 Canvas 2D 导出
|
const dList = getDesignList()
|
||||||
// @ts-ignore
|
const dIdx = dList.findIndex(d => d.id === designId)
|
||||||
Taro.canvasToTempFilePath({
|
if (dIdx === -1) { setLoading(false); setNotFound(true); return }
|
||||||
canvas: canvasNodeRef.current,
|
const d = dList[dIdx]
|
||||||
quality: 0.92,
|
const stickers = (d.designData && d.designData.stickers) || []
|
||||||
success: (res: any) => {
|
const sIdx = stickers.findIndex(s => s.id === stickerId)
|
||||||
const newSrc = res.tempFilePath
|
if (sIdx === -1) { setLoading(false); setNotFound(true); return }
|
||||||
const dList = getDesignList()
|
stickers[sIdx] = {
|
||||||
const dIdx = dList.findIndex(d => d.id === designId)
|
...stickers[sIdx],
|
||||||
if (dIdx === -1) { setLoading(false); return }
|
...(pendingSrc ? { src: pendingSrc } : {}),
|
||||||
const stickers = dList[dIdx].designData ? dList[dIdx].designData.stickers || [] : []
|
edits: { brightness, hue, contrast }
|
||||||
const sIdx = stickers.findIndex(s => s.id === stickerId)
|
}
|
||||||
if (sIdx === -1) { setLoading(false); return }
|
// WCD 红线:基于服务端已有 designData 合并(保留 wordcloud 分组),只覆盖 stickers
|
||||||
stickers[sIdx] = {
|
const designData = { ...d.designData, stickers }
|
||||||
...stickers[sIdx],
|
updateDesign(designId, { designData }) // 本地缓存同步
|
||||||
src: newSrc,
|
// 服务端保存(items 全量替换,必须带全 4 个基本字段);失败暂存本地
|
||||||
edits: { brightness, hue, contrast }
|
updateDesignApi(designId, {
|
||||||
}
|
item: {
|
||||||
const d = dList[dIdx]
|
productId: d.productId,
|
||||||
// WCD 红线:基于服务端已有 designData 合并(保留 wordcloud 分组),只覆盖 stickers
|
productName: d.productName,
|
||||||
const designData = { ...d.designData, stickers }
|
unitPrice: d.unitPrice,
|
||||||
updateDesign(designId, { designData }) // 本地缓存同步
|
count: d.count,
|
||||||
// 服务端保存(items 全量替换,必须带全 4 个基本字段);失败暂存本地
|
designData
|
||||||
updateDesignApi(designId, {
|
|
||||||
item: {
|
|
||||||
productId: d.productId,
|
|
||||||
productName: d.productName,
|
|
||||||
unitPrice: d.unitPrice,
|
|
||||||
count: d.count,
|
|
||||||
designData
|
|
||||||
}
|
|
||||||
}).catch(() => Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' }))
|
|
||||||
Taro.showToast({ title: '保存成功', icon: 'success' })
|
|
||||||
Taro.navigateBack()
|
|
||||||
},
|
|
||||||
fail: () => {
|
|
||||||
setLoading(false)
|
|
||||||
Taro.showToast({ title: '保存失败', icon: 'none' })
|
|
||||||
}
|
}
|
||||||
})
|
}).catch(() => Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' }))
|
||||||
|
setLoading(false)
|
||||||
|
Taro.showToast({ title: '保存成功', icon: 'success' })
|
||||||
|
Taro.navigateBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 裁剪:调用微信cropImage */
|
/** 裁剪:调用微信cropImage */
|
||||||
@@ -275,46 +174,38 @@ export default function StickerEditPage() {
|
|||||||
success: (res) => {
|
success: (res) => {
|
||||||
const newSticker = { ...sticker, src: res.tempFilePath }
|
const newSticker = { ...sticker, src: res.tempFilePath }
|
||||||
setSticker(newSticker)
|
setSticker(newSticker)
|
||||||
// 重绘
|
setPendingSrc(res.tempFilePath)
|
||||||
setTimeout(() => redraw(), 200)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度 */
|
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度参数 */
|
||||||
const handleSketch = async () => {
|
const handleSketch = async () => {
|
||||||
if (!sticker) return
|
if (!sticker) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await sketchImage(sticker.src)
|
const res = await sketchImage(sticker.src)
|
||||||
if (res?.imageUrl) {
|
if (res?.imageUrl) {
|
||||||
const newSticker = { ...sticker, src: res.imageUrl, edits: { ...sticker.edits, sketchSrc: res.imageUrl } }
|
setSticker({ ...sticker, src: res.imageUrl })
|
||||||
setSticker(newSticker)
|
setPendingSrc(res.imageUrl)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setTimeout(() => redraw(), 200)
|
|
||||||
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
|
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
|
||||||
} else {
|
} else {
|
||||||
throw new Error('no imageUrl')
|
throw new Error('no imageUrl')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 如果接口不可用/失败,降级为前端灰度+边缘检测
|
// 如果接口不可用/失败,降级为前端灰度滤镜参数(保存时随 edits 持久化)
|
||||||
applyFrontendSketch()
|
Taro.showToast({ title: '使用本地线稿', icon: 'none' })
|
||||||
|
setBrightness(20)
|
||||||
|
setContrast(80)
|
||||||
|
setHue(0)
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 前端线稿降级方案:灰度+反相高对比 */
|
const previewFilter = brightness === 0 && hue === 0 && contrast === 0
|
||||||
const applyFrontendSketch = () => {
|
? 'none'
|
||||||
if (!sticker || !ctxRef.current) { setLoading(false); return }
|
: `brightness(${100 + brightness}%) hue-rotate(${hue}deg) contrast(${100 + contrast}%)`
|
||||||
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}%`
|
|
||||||
|
|
||||||
if (notFound) {
|
if (notFound) {
|
||||||
return (
|
return (
|
||||||
@@ -351,13 +242,13 @@ export default function StickerEditPage() {
|
|||||||
<Text className='edit-save' onTap={handleSave}>保存</Text>
|
<Text className='edit-save' onTap={handleSave}>保存</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Canvas 预览 */}
|
{/* 预览:Image + CSS filter 所见即所得(不依赖 Canvas ctx.filter) */}
|
||||||
<View className='edit-canvas-wrap'>
|
<View className='edit-canvas-wrap'>
|
||||||
<Canvas
|
<Image
|
||||||
id='editCanvas'
|
className='edit-preview-img'
|
||||||
type='2d'
|
src={sticker ? sticker.src : ''}
|
||||||
className='edit-canvas'
|
mode='aspectFit'
|
||||||
style={{ width: '100%', height: '480rpx' }}
|
style={{ filter: previewFilter }}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -376,73 +267,42 @@ export default function StickerEditPage() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 亮度 */}
|
{/* 亮度 */}
|
||||||
<View className='edit-row slider-row'
|
<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>
|
<Text className='edit-label'>亮度</Text>
|
||||||
<View className='slider-wrap'>
|
<TouchSlider
|
||||||
<View className='slider-track'>
|
id='slider-brightness'
|
||||||
<View className='slider-fill' style={{ width: bPct }} />
|
value={brightness}
|
||||||
<View className='slider-thumb' style={{ left: bPct }} />
|
min={-100}
|
||||||
</View>
|
max={100}
|
||||||
<Text className='slider-value'>{brightness > 0 ? `+${brightness}` : brightness}</Text>
|
onChange={setBrightness}
|
||||||
</View>
|
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 色相 */}
|
{/* 色相 */}
|
||||||
<View className='edit-row slider-row'
|
<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>
|
<Text className='edit-label'>色相</Text>
|
||||||
<View className='slider-wrap'>
|
<TouchSlider
|
||||||
<View className='slider-track'>
|
id='slider-hue'
|
||||||
<View className='slider-fill' style={{ width: hPct }} />
|
value={hue}
|
||||||
<View className='slider-thumb' style={{ left: hPct }} />
|
min={-180}
|
||||||
</View>
|
max={180}
|
||||||
<Text className='slider-value'>{hue > 0 ? `+${hue}` : hue}</Text>
|
onChange={setHue}
|
||||||
</View>
|
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 对比度 */}
|
{/* 对比度 */}
|
||||||
<View className='edit-row slider-row'
|
<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>
|
<Text className='edit-label'>对比度</Text>
|
||||||
<View className='slider-wrap'>
|
<TouchSlider
|
||||||
<View className='slider-track'>
|
id='slider-contrast'
|
||||||
<View className='slider-fill' style={{ width: cPct }} />
|
value={contrast}
|
||||||
<View className='slider-thumb' style={{ left: cPct }} />
|
min={-100}
|
||||||
</View>
|
max={100}
|
||||||
<Text className='slider-value'>{contrast > 0 ? `+${contrast}` : contrast}</Text>
|
onChange={setContrast}
|
||||||
</View>
|
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user