8fdf0b1(R3) 基于 a13e380 却用旧工作区副本覆盖了 R2 域文件,导致终版
(feat/r1-catalog) 上 R2 功能丢失。本提交在不改动 R1/R3 域文件的前提下恢复:
- 整文件恢复(纯 R2 域):diy/index.tsx+scss(批次①②③全部修正)、
stickerEdit(批次③)、api/address.ts(region 转换收口)、
store/design.ts+address.ts(缓存层语义文档)、address 页(真机验收版)
- 手工合并:api/design.ts(R2 契约实现为基底 + R3 需要的 fetchDesign)、
types/index.ts(DesignDataV1.category 收窄 {id,mask,tone}、productIcon
optional、AddressItem.createdAt 恢复;保留 R1 ProductCategory 扩展与
R3 OrderItem 扩展)、designList 页(缓存优先 load + processing 徽标)
- 保留不动(其他链路域):checkout/orders/orderDetail/shop/index/profile、
api/order.ts、api/product.ts、productAdapter、request.ts(R3 构建期注入)、
keys.ts(R3 mock 处理)、product 页(R1 已实现等价服务端写入)
验证:build:weapp 通过;tsc 错误集合与恢复前对比,R1/R3 文件零变化。
Co-Authored-By: Claude <noreply@anthropic.com>
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
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 呈现(契约 §4:edits 不进 WCD
|
||
document.json,仅 manifest.meta,src 保持原样对 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>
|
||
)
|
||
}
|