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:
2026-09-12 19:29:03 +08:00
co-authored by Claude
parent 98da907df8
commit 2e3676a470
8 changed files with 143 additions and 267 deletions
-8
View File
@@ -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>
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+14
View File
@@ -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
}} }}
+10
View File
@@ -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
View File
@@ -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 呈现(契约 §4edits 不进 WCD
document.json,仅 manifest.metasrc 保持原样对 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>