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>
This commit is contained in:
@@ -103,6 +103,8 @@
|
||||
position: absolute;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
/* 问题1 修正:碰撞盒按左上角计算,缩放原点统一为左上角,渲染与碰撞语义自洽 */
|
||||
transform-origin: top left;
|
||||
z-index: 5;
|
||||
border: 2rpx solid transparent;
|
||||
border-radius: 8rpx;
|
||||
@@ -391,6 +393,7 @@
|
||||
position: absolute;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
transform-origin: top left;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
|
||||
+181
-60
@@ -1,11 +1,10 @@
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import './index.scss'
|
||||
import { getProductById } from '../../utils/productConfig'
|
||||
import { getDesignList, setDesignList, updateDesign as updateLocalDesign, type DesignItem, type StickerItem } from '../../utils/store'
|
||||
import { persistDesignMedia } from '../../utils/api'
|
||||
import { createDesign, updateDesign as updateRemoteDesign } from '../../utils/api/design'
|
||||
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../utils/store'
|
||||
import { createDesign, updateDesign as updateDesignApi, persistDesignMedia } from '../../utils/api'
|
||||
import { useThemeContext } from '../../context/ThemeContext'
|
||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||
import { useStatusBar } from '../../hooks/useStatusBar'
|
||||
@@ -27,7 +26,41 @@ export default function DIYPage() {
|
||||
const [startPos, setStartPos] = useState({ x: 0, y: 0 })
|
||||
const [previewMode, setPreviewMode] = useState(false)
|
||||
const [hasOverlap, setHasOverlap] = useState(false)
|
||||
const [isCreatingDesign, setIsCreatingDesign] = useState(false)
|
||||
|
||||
/** 进入 DIY 即创建清单条目,并推进到「设计中」(问题3 修正:进入工作台即 SUBMITTED),失败降级本地 */
|
||||
const createDesignEntry = (product: { id: string; name: string; price: number; icon: string }, count: number) => {
|
||||
createDesign({
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
unitPrice: product.price,
|
||||
count
|
||||
})
|
||||
.then(item => {
|
||||
setDesignList([item, ...getDesignList()]) // 回写缓存,checkout 等页面仍读缓存
|
||||
setDesignId(item.id)
|
||||
return updateDesignApi(item.id, { status: 'designing' })
|
||||
.then(updated => {
|
||||
setDesignList(getDesignList().map(d => d.id === item.id ? { ...d, status: updated.status } : d))
|
||||
})
|
||||
.catch(() => {
|
||||
setDesignList(getDesignList().map(d => d.id === item.id ? { ...d, status: 'designing' as const } : d))
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
const newDesign: DesignItem = {
|
||||
id: 'DSG' + Date.now(),
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
productIcon: product.icon,
|
||||
unitPrice: product.price,
|
||||
count,
|
||||
status: 'designing',
|
||||
createdAt: new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
setDesignList([...getDesignList(), newDesign])
|
||||
setDesignId(newDesign.id)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const router = Taro.getCurrentInstance().router
|
||||
@@ -35,46 +68,13 @@ export default function DIYPage() {
|
||||
const source = params ? params.source : undefined
|
||||
const productId = (params && params.productId) || (params && params.category)
|
||||
|
||||
const createDraftForProduct = async (product: any, count: number) => {
|
||||
const draft: DesignItem = {
|
||||
id: 'DSG' + Date.now(),
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
productIcon: product.icon,
|
||||
unitPrice: product.price,
|
||||
count,
|
||||
status: 'designing',
|
||||
createdAt: new Date().toISOString().slice(0, 10),
|
||||
}
|
||||
|
||||
// 先写本地草稿,保证离线时仍能编辑;在线时立即换成服务端真实 id。
|
||||
setDesignList([...getDesignList(), draft])
|
||||
setDesignId(draft.id)
|
||||
setIsCreatingDesign(true)
|
||||
try {
|
||||
const saved = await createDesign({
|
||||
productId: draft.productId,
|
||||
productName: draft.productName,
|
||||
unitPrice: draft.unitPrice,
|
||||
count: draft.count,
|
||||
designData: draft.designData,
|
||||
})
|
||||
setDesignList(getDesignList().map(item => item.id === draft.id ? saved : item))
|
||||
setDesignId(saved.id)
|
||||
} catch {
|
||||
// 保留本地草稿;结算页会在网络恢复后继续走本地兜底。
|
||||
} finally {
|
||||
setIsCreatingDesign(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (source === 'product' && productId) {
|
||||
const product = getProductById(productId)
|
||||
if (product) {
|
||||
setCategory(product)
|
||||
const count = Number(params ? params.quantity : undefined) || 1
|
||||
setQuantity(count)
|
||||
void createDraftForProduct(product, count)
|
||||
createDesignEntry(product, count)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -88,22 +88,42 @@ export default function DIYPage() {
|
||||
setQuantity(design.count)
|
||||
const product = getProductById(design.productId)
|
||||
if (product) setCategory(product)
|
||||
if (design.designData ? design.designData.stickers : undefined) {
|
||||
setStickers(design.designData.stickers)
|
||||
} else if (design.designData ? design.designData.imageSrc : undefined) {
|
||||
const dd = design.designData
|
||||
const rawStickers = dd ? dd.stickers : undefined
|
||||
if (rawStickers) {
|
||||
// 问题1 留意点:旧数据 width/height 为图片原始像素语义,读取端按 mask 等比归一化
|
||||
//(契约实现注记,不做存量迁移);新语义数据(≤mask 短边 60%)原样通过
|
||||
const mask = (dd && dd.category && dd.category.mask) || (product ? product.mask : undefined)
|
||||
const normalized: StickerItem[] = mask
|
||||
? rawStickers.map(s => {
|
||||
if (s.width > mask.width || s.height > mask.height) {
|
||||
const k = (Math.min(mask.width, mask.height) * 0.6) / Math.max(s.width, s.height)
|
||||
return { ...s, width: Math.round(s.width * k), height: Math.round(s.height * k) }
|
||||
}
|
||||
return s
|
||||
})
|
||||
: rawStickers
|
||||
setStickers(normalized)
|
||||
} else if (dd ? dd.imageSrc : undefined) {
|
||||
// 兼容旧版数据
|
||||
const s: StickerItem = {
|
||||
id: 'legacy_' + Date.now(),
|
||||
src: design.designData.imageSrc,
|
||||
x: design.designData.imagePos ? design.designData.imagePos.x || 0 : 0,
|
||||
y: design.designData.imagePos ? design.designData.imagePos.y || 0 : 0,
|
||||
scale: design.designData.imagePos ? design.designData.imagePos.scale || 1 : 1,
|
||||
src: dd.imageSrc,
|
||||
x: dd.imagePos ? dd.imagePos.x || 0 : 0,
|
||||
y: dd.imagePos ? dd.imagePos.y || 0 : 0,
|
||||
scale: dd.imagePos ? dd.imagePos.scale || 1 : 1,
|
||||
width: 200,
|
||||
height: 200,
|
||||
isOverlapping: false
|
||||
}
|
||||
setStickers([s])
|
||||
}
|
||||
|
||||
// 问题3 修正:继续设计同样进入「设计中」状态(单向状态机,undesigned → designing)
|
||||
if (design.status === 'undesigned') {
|
||||
updateDesign(dId, { status: 'designing' })
|
||||
updateDesignApi(dId, { status: 'designing' }).catch(() => {})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -113,11 +133,71 @@ export default function DIYPage() {
|
||||
const found = getProductById(productId)
|
||||
if (found) {
|
||||
setCategory(found)
|
||||
void createDraftForProduct(found, 1)
|
||||
createDesignEntry(found, 1)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ---------- 草稿持久化(修正工作流 问题2)----------
|
||||
// 贴纸变化防抖 800ms:本地缓存即时同步 + 服务端静默同步;不推状态(SUBMITTED 只由显式动作触发)
|
||||
const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const latestRef = useRef({ designId: '', quantity: 1, category: null as any, stickers: [] as StickerItem[] })
|
||||
latestRef.current = { designId, quantity, category, stickers }
|
||||
|
||||
const saveDraft = () => {
|
||||
const { designId: dId, quantity: qty, category: cat, stickers: st } = latestRef.current
|
||||
if (!dId || !cat) return
|
||||
// WCD 红线:基于缓存中服务端最新 designData 合并(保留 wordcloud 分组),只覆盖本页编辑字段
|
||||
const cached = getDesignList().find(d => d.id === dId)
|
||||
const designData = {
|
||||
...(cached && cached.designData ? cached.designData : {}),
|
||||
version: 1 as const,
|
||||
category: { id: cat.id, mask: cat.mask, ...(cat.tone ? { tone: cat.tone } : {}) },
|
||||
stickers: st
|
||||
}
|
||||
updateDesign(dId, { designData }) // 本地缓存即时同步(问题4:贴纸编辑页从这里读取)
|
||||
updateDesignApi(dId, {
|
||||
item: { productId: cat.id, productName: cat.name, unitPrice: cat.price, count: qty, designData }
|
||||
}).catch(() => {}) // 草稿静默降级:失败仅落缓存
|
||||
}
|
||||
|
||||
const flushDraft = () => {
|
||||
if (draftTimerRef.current) {
|
||||
clearTimeout(draftTimerRef.current)
|
||||
draftTimerRef.current = null
|
||||
}
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
// 隐藏页面(切后台/跳转)时强制落盘,防止防抖窗口内的改动丢失
|
||||
Taro.useDidHide(flushDraft)
|
||||
// 卸载时同样落盘
|
||||
useEffect(() => flushDraft, [])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
const skipDraftRef = useRef(true)
|
||||
useEffect(() => {
|
||||
if (skipDraftRef.current) { skipDraftRef.current = false; return }
|
||||
if (draftTimerRef.current) clearTimeout(draftTimerRef.current)
|
||||
draftTimerRef.current = setTimeout(saveDraft, 800)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stickers])
|
||||
|
||||
// 问题5 留意点:页面栈返回(贴纸编辑页保存后 navigateBack)不触发重新挂载,
|
||||
// 需在 onShow 从缓存同步最新贴纸(src/edits),否则工作台仍渲染旧 state。
|
||||
// 首次 onShow 跳过——mount 读取端已做过旧数据归一化,避免被未归一化的缓存覆盖。
|
||||
const skipShowRef = useRef(true)
|
||||
Taro.useDidShow(() => {
|
||||
if (skipShowRef.current) { skipShowRef.current = false; return }
|
||||
const dId = latestRef.current.designId
|
||||
if (!dId) return
|
||||
const cached = getDesignList().find(d => d.id === dId)
|
||||
const list = cached && cached.designData ? cached.designData.stickers : undefined
|
||||
if (list && list.length) {
|
||||
setStickers(checkOverlap(list))
|
||||
}
|
||||
})
|
||||
|
||||
// 矩形碰撞检测
|
||||
const checkOverlap = (list: StickerItem[]) => {
|
||||
const newList = list.map(s => ({ ...s, isOverlapping: false }))
|
||||
@@ -156,14 +236,17 @@ export default function DIYPage() {
|
||||
Taro.getImageInfo({
|
||||
src,
|
||||
success: (info) => {
|
||||
// 问题1 修正:归一化到画布坐标空间(width/height = 画布显示像素,契约实现注记)
|
||||
const maskMin = Math.min(category.mask.width, category.mask.height)
|
||||
const k = (maskMin * 0.6) / Math.max(info.width, info.height)
|
||||
const newSticker: StickerItem = {
|
||||
id: 'stk_' + Date.now(),
|
||||
src,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
width: Math.round(info.width * k),
|
||||
height: Math.round(info.height * k),
|
||||
isOverlapping: false
|
||||
}
|
||||
const next = [...stickers, newSticker]
|
||||
@@ -172,15 +255,17 @@ export default function DIYPage() {
|
||||
setActiveStickerId(newSticker.id)
|
||||
},
|
||||
fail: () => {
|
||||
// fallback 尺寸
|
||||
// fallback 尺寸(同样按画布空间归一化)
|
||||
const maskMin = Math.min(category.mask.width, category.mask.height)
|
||||
const k = (maskMin * 0.6) / 200
|
||||
const newSticker: StickerItem = {
|
||||
id: 'stk_' + Date.now(),
|
||||
src,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
width: 200,
|
||||
height: 200,
|
||||
width: Math.round(200 * k),
|
||||
height: Math.round(200 * k),
|
||||
isOverlapping: false
|
||||
}
|
||||
const next = [...stickers, newSticker]
|
||||
@@ -212,6 +297,7 @@ export default function DIYPage() {
|
||||
const handleStickerTouchMove = (e: any) => {
|
||||
if (!isDragging || !activeStickerId) return
|
||||
const touch = e.touches[0]
|
||||
// 需求决定(2026-09-12):贴纸允许拖出画布外自由摆放,不做坐标钳制
|
||||
const next = stickers.map(s => {
|
||||
if (s.id !== activeStickerId) return s
|
||||
return { ...s, x: touch.clientX - startPos.x, y: touch.clientY - startPos.y }
|
||||
@@ -224,6 +310,7 @@ export default function DIYPage() {
|
||||
|
||||
const handleEditSticker = () => {
|
||||
if (!activeStickerId || !designId) return
|
||||
flushDraft() // 问题4:跳编辑页前清掉防抖窗口,缓存里保证有最新贴纸
|
||||
Taro.navigateTo({
|
||||
url: `/pages/diy/stickerEdit/index?designId=${designId}&stickerId=${activeStickerId}`
|
||||
})
|
||||
@@ -238,6 +325,18 @@ export default function DIYPage() {
|
||||
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 = () => {
|
||||
Taro.navigateBack()
|
||||
}
|
||||
@@ -255,22 +354,42 @@ export default function DIYPage() {
|
||||
}
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (isCreatingDesign) {
|
||||
Taro.showToast({ title: '正在创建设计,请稍候', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (hasOverlap) {
|
||||
Taro.showToast({ title: '贴纸不能重叠', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (designId) {
|
||||
if (designId && category) {
|
||||
// 贴纸/底图持久化(决策#4,R4 负责):本地图 → COS 持久 URL,保证后端 WCD 打包可下载素材
|
||||
const data = await persistDesignMedia({ stickers, category, version: 1 })
|
||||
updateLocalDesign(designId, { designData: data })
|
||||
// category 裁剪为 {id, mask, tone}(阶段0 决策#2;契约约束#3:mask 必须保留)
|
||||
const trimmedCategory = {
|
||||
id: category.id,
|
||||
mask: category.mask,
|
||||
...(category.tone ? { tone: category.tone } : {})
|
||||
}
|
||||
// WCD 红线:基于服务端已有 designData 合并(保留 R4 写入的 wordcloud 分组等),只覆盖本页编辑的字段
|
||||
const cached = getDesignList().find(d => d.id === designId)
|
||||
const designData = {
|
||||
...(cached && cached.designData ? cached.designData : {}),
|
||||
version: 1 as const,
|
||||
category: trimmedCategory,
|
||||
stickers: data.stickers || stickers
|
||||
}
|
||||
// 本地缓存同步(checkout 仍读缓存)
|
||||
updateDesign(designId, { status: 'designing', designData })
|
||||
try {
|
||||
await updateRemoteDesign(designId, { designData: data, status: 'designing' })
|
||||
await updateDesignApi(designId, {
|
||||
status: 'designing',
|
||||
item: {
|
||||
productId: category.id,
|
||||
productName: category.name,
|
||||
unitPrice: category.price,
|
||||
count: quantity,
|
||||
designData
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// 离线草稿继续由本地存储承接;线上草稿会在下一次操作时重试同步。
|
||||
Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' })
|
||||
}
|
||||
}
|
||||
setPreviewMode(false)
|
||||
@@ -318,6 +437,7 @@ export default function DIYPage() {
|
||||
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
|
||||
opacity: isDragging && activeStickerId === s.id ? 0.8 : 1,
|
||||
zIndex: activeStickerId === s.id ? 10 : 5,
|
||||
filter: stickerFilter(s),
|
||||
width: s.width || 200,
|
||||
height: s.height || 200
|
||||
}}
|
||||
@@ -403,6 +523,7 @@ export default function DIYPage() {
|
||||
src={s.src}
|
||||
style={{
|
||||
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
|
||||
filter: stickerFilter(s),
|
||||
width: s.width || 200,
|
||||
height: s.height || 200
|
||||
}}
|
||||
|
||||
@@ -81,6 +81,16 @@
|
||||
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 {
|
||||
padding: 24rpx;
|
||||
@@ -226,3 +236,30 @@
|
||||
.mt-20 {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
/* 空态(贴纸未找到) */
|
||||
.edit-empty {
|
||||
margin: 24rpx 24rpx 0;
|
||||
padding: 60rpx 40rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.edit-empty-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-empty-desc {
|
||||
font-size: 26rpx;
|
||||
opacity: 0.65;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.edit-empty .btn-primary {
|
||||
padding: 16rpx 60rpx;
|
||||
border-radius: 44rpx;
|
||||
}
|
||||
|
||||
+142
-242
@@ -1,10 +1,9 @@
|
||||
import { View, Text, Canvas, Image } from '@tarojs/components'
|
||||
import Taro, { createCanvasContext, canvasToTempFilePath } from '@tarojs/taro'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import './index.scss'
|
||||
import { getProductById } from '../../../utils/productConfig'
|
||||
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../../utils/store'
|
||||
import { sketchImage } from '../../../utils/api'
|
||||
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'
|
||||
@@ -13,55 +12,59 @@ import ScrollTopMask from '../../../components/ScrollTopMask'
|
||||
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)
|
||||
|
||||
/** 拖动手势滑块组件 */
|
||||
/** 拖动手势滑块组件(问题5 修正:选择器查询按 id 定位,touchstart 时缓存 rect) */
|
||||
interface SliderProps {
|
||||
id: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step?: number
|
||||
onChange: (val: number) => void
|
||||
showValue?: boolean
|
||||
format?: (v: number) => string
|
||||
}
|
||||
|
||||
function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProps) {
|
||||
const trackRef = useRef<any>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
function TouchSlider({ id, value, min, max, onChange, format }: SliderProps) {
|
||||
const rectRef = useRef<{ left: number; width: number } | null>(null)
|
||||
|
||||
const computedFromClientX = (clientX: number) => {
|
||||
if (!trackRef.current) return value
|
||||
const query = Taro.createSelectorQuery().in(trackRef.current)
|
||||
// 注意:这里需要取 track 元素的位置信息
|
||||
const rect = trackRef.current.getBoundingClientRect ? trackRef.current.getBoundingClientRect() : { left: 0, width: 300 }
|
||||
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)
|
||||
let raw = min + (max - min) * ratio
|
||||
if (step > 0) {
|
||||
raw = Math.round(raw / step) * step
|
||||
}
|
||||
return clamp(raw, min, max)
|
||||
return clamp(min + (max - min) * ratio, min, max)
|
||||
}
|
||||
|
||||
const handleTouchStart = (e: any) => {
|
||||
setDragging(true)
|
||||
const touch = e.touches && e.touches[0]
|
||||
const x = touch ? touch.clientX : 0
|
||||
onChange(computedFromClientX(x))
|
||||
if (!touch) return
|
||||
measure(() => onChange(valueFromX(touch.clientX)))
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: any) => {
|
||||
if (!dragging) return
|
||||
const touch = e.touches && e.touches[0]
|
||||
const x = touch ? touch.clientX : 0
|
||||
onChange(computedFromClientX(x))
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setDragging(false)
|
||||
if (!touch) return
|
||||
if (!rectRef.current) {
|
||||
measure(() => onChange(valueFromX(touch.clientX)))
|
||||
return
|
||||
}
|
||||
onChange(valueFromX(touch.clientX))
|
||||
}
|
||||
|
||||
const pct = `${((value - min) / (max - min)) * 100}%`
|
||||
@@ -69,14 +72,14 @@ function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProp
|
||||
return (
|
||||
<View className='slider-wrap'>
|
||||
<View
|
||||
id={id}
|
||||
className='slider-track'
|
||||
ref={trackRef}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchEnd={() => { /* rect 已缓存,无需处理 */ }}
|
||||
>
|
||||
<View className='slider-fill' style={{ width: pct }} />
|
||||
<View className={`slider-thumb ${dragging ? 'dragging' : ''}`} style={{ left: pct }} />
|
||||
<View className='slider-thumb' style={{ left: pct }} />
|
||||
</View>
|
||||
<Text className='slider-value'>{format ? format(value) : value}</Text>
|
||||
</View>
|
||||
@@ -90,50 +93,15 @@ export default function StickerEditPage() {
|
||||
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)
|
||||
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(() => {
|
||||
const router = Taro.getCurrentInstance().router
|
||||
@@ -151,96 +119,44 @@ export default function StickerEditPage() {
|
||||
setBrightness((st.edits && st.edits.brightness) || 0)
|
||||
setHue((st.edits && st.edits.hue) || 0)
|
||||
setContrast((st.edits && st.edits.contrast) || 0)
|
||||
initCanvas(st)
|
||||
} else {
|
||||
// 问题4 留意点:找不到贴纸时给出空态,而不是停在空白 Canvas
|
||||
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 = () => {
|
||||
if (!sticker || !designId) return
|
||||
setLoading(true)
|
||||
// 使用 Canvas 2D 导出
|
||||
// @ts-ignore
|
||||
Taro.canvasToTempFilePath({
|
||||
canvas: canvasNodeRef.current,
|
||||
quality: 0.92,
|
||||
success: (res: any) => {
|
||||
const newSrc = res.tempFilePath
|
||||
const dList = getDesignList()
|
||||
const dIdx = dList.findIndex(d => d.id === designId)
|
||||
if (dIdx === -1) { setLoading(false); return }
|
||||
const stickers = dList[dIdx].designData ? dList[dIdx].designData.stickers || [] : []
|
||||
const sIdx = stickers.findIndex(s => s.id === stickerId)
|
||||
if (sIdx === -1) { setLoading(false); return }
|
||||
stickers[sIdx] = {
|
||||
...stickers[sIdx],
|
||||
src: newSrc,
|
||||
edits: { brightness, hue, contrast }
|
||||
}
|
||||
updateDesign(designId, { designData: { ...dList[dIdx].designData, stickers } })
|
||||
Taro.showToast({ title: '保存成功', icon: 'success' })
|
||||
Taro.navigateBack()
|
||||
},
|
||||
fail: () => {
|
||||
setLoading(false)
|
||||
Taro.showToast({ title: '保存失败', icon: 'none' })
|
||||
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 */
|
||||
@@ -258,46 +174,61 @@ export default function StickerEditPage() {
|
||||
success: (res) => {
|
||||
const newSticker = { ...sticker, src: res.tempFilePath }
|
||||
setSticker(newSticker)
|
||||
// 重绘
|
||||
setTimeout(() => redraw(), 200)
|
||||
setPendingSrc(res.tempFilePath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度 */
|
||||
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度参数 */
|
||||
const handleSketch = async () => {
|
||||
if (!sticker) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await sketchImage(sticker.src)
|
||||
if (res?.imageUrl) {
|
||||
const newSticker = { ...sticker, src: res.imageUrl, edits: { ...sticker.edits, sketchSrc: res.imageUrl } }
|
||||
setSticker(newSticker)
|
||||
setSticker({ ...sticker, src: res.imageUrl })
|
||||
setPendingSrc(res.imageUrl)
|
||||
setLoading(false)
|
||||
setTimeout(() => redraw(), 200)
|
||||
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
|
||||
} else {
|
||||
throw new Error('no imageUrl')
|
||||
}
|
||||
} catch {
|
||||
// 如果接口不可用/失败,降级为前端灰度+边缘检测
|
||||
applyFrontendSketch()
|
||||
// 如果接口不可用/失败,降级为前端灰度滤镜参数(保存时随 edits 持久化)
|
||||
Taro.showToast({ title: '使用本地线稿', icon: 'none' })
|
||||
setBrightness(20)
|
||||
setContrast(80)
|
||||
setHue(0)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 前端线稿降级方案:灰度+反相高对比 */
|
||||
const applyFrontendSketch = () => {
|
||||
if (!sticker || !ctxRef.current) { setLoading(false); return }
|
||||
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}%)`
|
||||
|
||||
const bPct = `${((brightness + 100) / 200) * 100}%`
|
||||
const hPct = `${((hue + 180) / 360) * 100}%`
|
||||
const cPct = `${((contrast + 100) / 200) * 100}%`
|
||||
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}`}>
|
||||
@@ -311,13 +242,13 @@ export default function StickerEditPage() {
|
||||
<Text className='edit-save' onTap={handleSave}>保存</Text>
|
||||
</View>
|
||||
|
||||
{/* Canvas 预览 */}
|
||||
{/* 预览:Image + CSS filter 所见即所得(不依赖 Canvas ctx.filter) */}
|
||||
<View className='edit-canvas-wrap'>
|
||||
<Canvas
|
||||
id='editCanvas'
|
||||
type='2d'
|
||||
className='edit-canvas'
|
||||
style={{ width: '100%', height: '480rpx' }}
|
||||
<Image
|
||||
className='edit-preview-img'
|
||||
src={sticker ? sticker.src : ''}
|
||||
mode='aspectFit'
|
||||
style={{ filter: previewFilter }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -336,73 +267,42 @@ export default function StickerEditPage() {
|
||||
</View>
|
||||
|
||||
{/* 亮度 */}
|
||||
<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()
|
||||
}}
|
||||
>
|
||||
<View className='edit-row slider-row'>
|
||||
<Text className='edit-label'>亮度</Text>
|
||||
<View className='slider-wrap'>
|
||||
<View className='slider-track'>
|
||||
<View className='slider-fill' style={{ width: bPct }} />
|
||||
<View className='slider-thumb' style={{ left: bPct }} />
|
||||
</View>
|
||||
<Text className='slider-value'>{brightness > 0 ? `+${brightness}` : brightness}</Text>
|
||||
</View>
|
||||
<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'
|
||||
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()
|
||||
}}
|
||||
>
|
||||
<View className='edit-row slider-row'>
|
||||
<Text className='edit-label'>色相</Text>
|
||||
<View className='slider-wrap'>
|
||||
<View className='slider-track'>
|
||||
<View className='slider-fill' style={{ width: hPct }} />
|
||||
<View className='slider-thumb' style={{ left: hPct }} />
|
||||
</View>
|
||||
<Text className='slider-value'>{hue > 0 ? `+${hue}` : hue}</Text>
|
||||
</View>
|
||||
<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'
|
||||
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()
|
||||
}}
|
||||
>
|
||||
<View className='edit-row slider-row'>
|
||||
<Text className='edit-label'>对比度</Text>
|
||||
<View className='slider-wrap'>
|
||||
<View className='slider-track'>
|
||||
<View className='slider-fill' style={{ width: cPct }} />
|
||||
<View className='slider-thumb' style={{ left: cPct }} />
|
||||
</View>
|
||||
<Text className='slider-value'>{contrast > 0 ? `+${contrast}` : contrast}</Text>
|
||||
</View>
|
||||
<TouchSlider
|
||||
id='slider-contrast'
|
||||
value={contrast}
|
||||
min={-100}
|
||||
max={100}
|
||||
onChange={setContrast}
|
||||
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user