Files
wechat_wc/src/pages/diy/stickerEdit/index.tsx
T
lhmin0604andClaude 3de6128cae restore(r2): 恢复被 R3 整树提交覆盖的 R2 功能层(契约实现为准)
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>
2026-09-15 05:28:50 +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>
)
}