feat(wordcloud): 前端 R4 类型/api/词云页真实接口 + stickerEdit 线稿真实地址
This commit is contained in:
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
|
|||||||
import './index.scss'
|
import './index.scss'
|
||||||
import { getProductById } from '../../../utils/productConfig'
|
import { getProductById } from '../../../utils/productConfig'
|
||||||
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../../utils/store'
|
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../../utils/store'
|
||||||
|
import { sketchImage } from '../../../utils/api'
|
||||||
import { useThemeContext } from '../../../context/ThemeContext'
|
import { useThemeContext } from '../../../context/ThemeContext'
|
||||||
import { useSafeArea } from '../../../hooks/useSafeArea'
|
import { useSafeArea } from '../../../hooks/useSafeArea'
|
||||||
import { useStatusBar } from '../../../hooks/useStatusBar'
|
import { useStatusBar } from '../../../hooks/useStatusBar'
|
||||||
@@ -263,36 +264,25 @@ export default function StickerEditPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 线稿:预留后端AI接口 */
|
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度 */
|
||||||
const API_BASE = 'https://your-api-domain.com' // ← 填入你的服务器地址
|
const handleSketch = async () => {
|
||||||
|
|
||||||
const handleSketch = () => {
|
|
||||||
if (!sticker) return
|
if (!sticker) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
Taro.uploadFile({
|
|
||||||
url: `${API_BASE}/api/sketch`,
|
|
||||||
filePath: sticker.src,
|
|
||||||
name: 'image',
|
|
||||||
success: (res) => {
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(res.data)
|
const res = await sketchImage(sticker.src)
|
||||||
if (data.url) {
|
if (res?.imageUrl) {
|
||||||
const newSticker = { ...sticker, src: data.url, edits: { ...sticker.edits, sketchSrc: data.url } }
|
const newSticker = { ...sticker, src: res.imageUrl, edits: { ...sticker.edits, sketchSrc: res.imageUrl } }
|
||||||
setSticker(newSticker)
|
setSticker(newSticker)
|
||||||
|
setLoading(false)
|
||||||
setTimeout(() => redraw(), 200)
|
setTimeout(() => redraw(), 200)
|
||||||
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
|
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
|
||||||
} else {
|
} else {
|
||||||
throw new Error('no url')
|
throw new Error('no imageUrl')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 如果接口不可用,降级为前端灰度+边缘检测
|
// 如果接口不可用/失败,降级为前端灰度+边缘检测
|
||||||
applyFrontendSketch()
|
applyFrontendSketch()
|
||||||
}
|
}
|
||||||
},
|
|
||||||
fail: () => {
|
|
||||||
applyFrontendSketch()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 前端线稿降级方案:灰度+反相高对比 */
|
/** 前端线稿降级方案:灰度+反相高对比 */
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useStatusBar } from '../../hooks/useStatusBar'
|
|||||||
import ThemedPageMeta from '../../components/ThemedPageMeta'
|
import ThemedPageMeta from '../../components/ThemedPageMeta'
|
||||||
import ScrollTopMask from '../../components/ScrollTopMask'
|
import ScrollTopMask from '../../components/ScrollTopMask'
|
||||||
import { assetUrl } from '../../utils/asset'
|
import { assetUrl } from '../../utils/asset'
|
||||||
|
import { createWordCloudJob, getWordCloudJob } from '../../utils/api'
|
||||||
|
|
||||||
export default function WordCloudPage() {
|
export default function WordCloudPage() {
|
||||||
const { theme, resolvedTheme } = useThemeContext()
|
const { theme, resolvedTheme } = useThemeContext()
|
||||||
@@ -19,6 +20,7 @@ export default function WordCloudPage() {
|
|||||||
const [generatedImage, setGeneratedImage] = useState('')
|
const [generatedImage, setGeneratedImage] = useState('')
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
const [progress, setProgress] = useState(0)
|
const [progress, setProgress] = useState(0)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{ num: 1, label: '上传底图' },
|
{ num: 1, label: '上传底图' },
|
||||||
@@ -33,23 +35,52 @@ export default function WordCloudPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGenerate = () => {
|
const handleGenerate = async () => {
|
||||||
if (!namesText.trim()) {
|
if (!namesText.trim()) {
|
||||||
Taro.showToast({ title: '请先输入名字', icon: 'none' })
|
Taro.showToast({ title: '请先输入名字', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStep(3); setIsGenerating(true); setProgress(0)
|
if (!baseImage) {
|
||||||
const timer = setInterval(() => {
|
Taro.showToast({ title: '请先上传底图', icon: 'none' })
|
||||||
setProgress((prev) => {
|
return
|
||||||
if (prev >= 100) {
|
}
|
||||||
|
setStep(3); setIsGenerating(true); setProgress(0); setError('')
|
||||||
|
try {
|
||||||
|
// 底图以 multipart 直接交给后端创建词云任务(不再本地模拟)
|
||||||
|
const { jobId } = await createWordCloudJob(baseImage, namesText.trim())
|
||||||
|
await pollWordCloudJob(jobId)
|
||||||
|
} catch (e) {
|
||||||
|
setIsGenerating(false)
|
||||||
|
setError(e instanceof Error ? e.message : '生成失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轮询词云任务:success 取结果图,failed 展示错误;单次网络抖动自动重试 */
|
||||||
|
const pollWordCloudJob = (jobId: string) => new Promise<void>((resolve, reject) => {
|
||||||
|
let retries = 0
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const job = await getWordCloudJob(jobId)
|
||||||
|
setProgress(Math.min(job.progress, 100))
|
||||||
|
if (job.status === 'success') {
|
||||||
clearInterval(timer); setIsGenerating(false)
|
clearInterval(timer); setIsGenerating(false)
|
||||||
setGeneratedImage(baseImage)
|
setGeneratedImage(job.imageUrl || baseImage)
|
||||||
return 100
|
resolve()
|
||||||
|
} else if (job.status === 'failed') {
|
||||||
|
clearInterval(timer); setIsGenerating(false)
|
||||||
|
setError(job.error || '词云生成失败')
|
||||||
|
reject(new Error(job.error || '词云生成失败'))
|
||||||
}
|
}
|
||||||
return prev + Math.random() * 15
|
} catch {
|
||||||
|
// 连续 5 次请求失败才终止,避免瞬时网络问题打断任务
|
||||||
|
retries += 1
|
||||||
|
if (retries > 5) {
|
||||||
|
clearInterval(timer); setIsGenerating(false)
|
||||||
|
reject(new Error('任务状态获取失败,请稍后重试'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1500)
|
||||||
})
|
})
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleExportImage = () => {
|
const handleExportImage = () => {
|
||||||
if (!generatedImage) return
|
if (!generatedImage) return
|
||||||
@@ -148,10 +179,21 @@ export default function WordCloudPage() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 步骤3: 生成中/预览 */}
|
{/* 步骤3: 生成中/结果/失败 */}
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<View className='step-content'>
|
<View className='step-content'>
|
||||||
{isGenerating ? (
|
{error ? (
|
||||||
|
<View className='generating-panel'>
|
||||||
|
<Image className='generating-icon' src={assetUrl('/icon/词云生成.png')} mode='aspectFit' />
|
||||||
|
<Text className='generating-title'>生成失败</Text>
|
||||||
|
<Text className='generating-subtitle'>{error}</Text>
|
||||||
|
<View className='action-btns'>
|
||||||
|
<View className='btn-primary' onTap={() => { setError(''); setStep(2) }}>
|
||||||
|
<Text>返回修改名单</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : isGenerating ? (
|
||||||
<View className='generating-panel'>
|
<View className='generating-panel'>
|
||||||
<Image className='generating-icon' src={assetUrl('/icon/词云生成.png')} mode='aspectFit' />
|
<Image className='generating-icon' src={assetUrl('/icon/词云生成.png')} mode='aspectFit' />
|
||||||
<Text className='generating-title'>正在生成词云...</Text>
|
<Text className='generating-title'>正在生成词云...</Text>
|
||||||
|
|||||||
+77
-8
@@ -56,6 +56,10 @@ export interface StickerItem {
|
|||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
isOverlapping: boolean
|
isOverlapping: boolean
|
||||||
|
/** 旋转角(度);本期 DIY 持久化,WCD 打包会携带(见 design-data-contract-v1.md 决策#2) */
|
||||||
|
rotation?: number
|
||||||
|
/** 图层顺序,WCD 按 zIndex 排列元素 */
|
||||||
|
zIndex?: number
|
||||||
/** 编辑状态持久化 */
|
/** 编辑状态持久化 */
|
||||||
edits?: {
|
edits?: {
|
||||||
brightness?: number // -100 ~ 100
|
brightness?: number // -100 ~ 100
|
||||||
@@ -66,6 +70,78 @@ export interface StickerItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设计数据 v1:支撑 R4 下单后 WCD 生产任务打包。
|
||||||
|
* 契约见 docs/design-data-contract-v1.md;贴纸 src 持久化由 R4 负责。
|
||||||
|
*/
|
||||||
|
export interface DesignDataV1 {
|
||||||
|
/** 结构版本;旧数据缺省视为 v1 */
|
||||||
|
version?: 1
|
||||||
|
/** 商品品类:mask 是画布尺寸来源,说明见 docs/mask-config-guide.md */
|
||||||
|
category?: ProductCategory
|
||||||
|
/** 底图(持久 URL),WCD 打包的画布底 */
|
||||||
|
background?: {
|
||||||
|
src: string
|
||||||
|
color?: string
|
||||||
|
pos?: { x: number; y: number; scale: number }
|
||||||
|
}
|
||||||
|
/** 词云信息(R4 词云生成后写入,R2 保存/更新清单时原样保留) */
|
||||||
|
wordcloud?: {
|
||||||
|
jobId?: string
|
||||||
|
imageUrl: string
|
||||||
|
names: string[]
|
||||||
|
}
|
||||||
|
/** 贴纸(src 需为 COS 持久 URL,见 design-data-contract-v1.md 约束#1) */
|
||||||
|
stickers?: StickerItem[]
|
||||||
|
/** 兼容旧版字段 */
|
||||||
|
imageSrc?: string
|
||||||
|
imagePos?: { x: number; y: number; scale: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 词云任务状态(与 wordcloud 契约、后端透传一致) */
|
||||||
|
export type WordCloudStatus = 'queued' | 'running' | 'success' | 'failed'
|
||||||
|
|
||||||
|
/** 词云任务(前端轮询模型:route-r4 §2 / api-contract §8) */
|
||||||
|
export interface WordCloudJob {
|
||||||
|
id: string
|
||||||
|
status: WordCloudStatus
|
||||||
|
progress: number
|
||||||
|
imageUrl?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** COS 直传凭证(STS 临时凭证或预签名 URL;小程序不得持有永久密钥) */
|
||||||
|
export interface CosCredentials {
|
||||||
|
key: string
|
||||||
|
bucket?: string
|
||||||
|
region?: string
|
||||||
|
/** STS 临时密钥(服务端下发,非主账户密钥) */
|
||||||
|
credentials?: { secretId?: string; secretKey?: string; token?: string } | null
|
||||||
|
/** 预签名直传 URL(与 credentials 二选一) */
|
||||||
|
presignedUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 线稿处理结果(POST /api/sketch) */
|
||||||
|
export interface SketchResult {
|
||||||
|
imageUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下单后 WCD 派单状态:not_configured = WORDCLOUD_API_URL 未配置(见 api-contract §8) */
|
||||||
|
export type WordCloudDispatchStatus =
|
||||||
|
| 'queued'
|
||||||
|
| 'running'
|
||||||
|
| 'success'
|
||||||
|
| 'failed'
|
||||||
|
| 'not_configured'
|
||||||
|
|
||||||
|
/** 下单后 WCD 派单结果(POST /api/orders/:id/dispatch) */
|
||||||
|
export interface WordCloudDispatchResult {
|
||||||
|
orderId: string
|
||||||
|
status: WordCloudDispatchStatus
|
||||||
|
wordcloudJobId?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
/** 设计清单条目 */
|
/** 设计清单条目 */
|
||||||
export interface DesignItem {
|
export interface DesignItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -75,14 +151,7 @@ export interface DesignItem {
|
|||||||
unitPrice: number
|
unitPrice: number
|
||||||
count: number
|
count: number
|
||||||
status: 'undesigned' | 'designing' | 'ordered'
|
status: 'undesigned' | 'designing' | 'ordered'
|
||||||
designData?: {
|
designData?: DesignDataV1
|
||||||
/** 兼容旧版字段 */
|
|
||||||
imageSrc?: string
|
|
||||||
imagePos?: { x: number; y: number; scale: number }
|
|
||||||
/** 新版贴纸列表 */
|
|
||||||
stickers?: StickerItem[]
|
|
||||||
category?: ProductCategory
|
|
||||||
}
|
|
||||||
orderId?: string
|
orderId?: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-5
@@ -1,7 +1,104 @@
|
|||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import http, { BASE_URL, getToken } from '../request'
|
||||||
|
import type {
|
||||||
|
CosCredentials,
|
||||||
|
SketchResult,
|
||||||
|
WordCloudJob,
|
||||||
|
WordCloudStatus,
|
||||||
|
} from '../../types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* R4 上传接口归属文件。
|
* R4 上传 / 词云 / 线稿接口。
|
||||||
* 后端 COS/wordcloud 适配器就绪后,在这里补充:
|
* 契约:前端与后端见 docs 里 api-contract-v1.md §8;后端与 wordcloud 见 wordcloud-contract.md。
|
||||||
* getUploadCredentials / uploadImage / generateWordCloud / getWordCloudJob / sketchImage
|
* multipart 上传经 Taro.uploadFile 统一带 Bearer token、解析后端 { code, message, data } 信封;
|
||||||
* 词云任务使用“创建任务 -> 轮询状态 -> 取结果链接”的异步模型。
|
* 与 request.ts 的 JSON 封装保持一致的成功/失败语义。
|
||||||
*/
|
*/
|
||||||
export {}
|
|
||||||
|
interface Envelope<T> {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
data?: T
|
||||||
|
}
|
||||||
|
|
||||||
|
/** multipart 上传封装:带 token、解析统一信封;业务失败抛 Error(message) */
|
||||||
|
async function uploadMultipart<T>(
|
||||||
|
path: string,
|
||||||
|
filePath: string,
|
||||||
|
name: string,
|
||||||
|
formData?: Record<string, string>,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await Taro.uploadFile({
|
||||||
|
url: `${BASE_URL}${path}`,
|
||||||
|
filePath,
|
||||||
|
name,
|
||||||
|
formData,
|
||||||
|
header: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
let body: Envelope<T>
|
||||||
|
try {
|
||||||
|
body = JSON.parse(res.data)
|
||||||
|
} catch {
|
||||||
|
throw new Error('网络异常,请稍后重试')
|
||||||
|
}
|
||||||
|
if (body && typeof body.code === 'number' && body.code !== 0) {
|
||||||
|
throw new Error(body.message || '请求失败')
|
||||||
|
}
|
||||||
|
return (body && typeof body.code === 'number' ? body.data : body) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 COS 直传凭证(STS 临时凭证或预签名 URL;小程序不持有永久密钥) */
|
||||||
|
export function getUploadCredentials(key: string): Promise<CosCredentials> {
|
||||||
|
return http.get<CosCredentials>(`/api/upload/credentials?key=${encodeURIComponent(key)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直传 COS:服务端下发预签名 URL 时用 PUT 提交文件字节;否则返回凭证由调用方决定。
|
||||||
|
* 注:COS 直传的最终语义以联调阶段服务端 upload 模块实现为准(当前后端为占位)。
|
||||||
|
*/
|
||||||
|
export async function uploadToCos(localPath: string, credentials: CosCredentials): Promise<string> {
|
||||||
|
if (!credentials.presignedUrl) {
|
||||||
|
throw new Error('获取 COS 直传地址失败')
|
||||||
|
}
|
||||||
|
const fileSystem = Taro.getFileSystemManager()
|
||||||
|
const data = fileSystem.readFileSync(localPath)
|
||||||
|
await Taro.request({
|
||||||
|
url: credentials.presignedUrl,
|
||||||
|
method: 'PUT',
|
||||||
|
data,
|
||||||
|
header: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
})
|
||||||
|
// 直传成功后返回去掉签名参数的对象地址
|
||||||
|
return credentials.presignedUrl.split('?')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 线稿:图片 → 处理后图片 URL(失败由调用方降级本地灰度) */
|
||||||
|
export function sketchImage(localPath: string): Promise<SketchResult> {
|
||||||
|
return uploadMultipart<SketchResult>('/api/sketch', localPath, 'image')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建词云任务:底图 + 名单 → jobId(异步模型,随后轮询) */
|
||||||
|
export function createWordCloudJob(
|
||||||
|
imagePath: string,
|
||||||
|
names: string,
|
||||||
|
params?: Record<string, unknown>,
|
||||||
|
): Promise<{ jobId: string }> {
|
||||||
|
return uploadMultipart<{ jobId: string }>(
|
||||||
|
'/api/wordcloud/generate',
|
||||||
|
imagePath,
|
||||||
|
'image',
|
||||||
|
{ names, ...(params ? { params: JSON.stringify(params) } : {}) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轮询词云任务状态;success 后 WordCloudJob.imageUrl 为结果图 COS 链接 */
|
||||||
|
export function getWordCloudJob(jobId: string): Promise<WordCloudJob> {
|
||||||
|
return http.get<WordCloudJob>(`/api/wordcloud/jobs/${jobId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取词云任务结果(后端将 wordcloud 产物转存 COS 后返回) */
|
||||||
|
export function getWordCloudResult(jobId: string): Promise<{ imageUrl: string; svgUrl?: string }> {
|
||||||
|
return http.get<{ imageUrl: string; svgUrl?: string }>(`/api/wordcloud/jobs/${jobId}/result`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { WordCloudStatus }
|
||||||
|
|||||||
Reference in New Issue
Block a user