From 73fac0cb9cf9a666ce38c3301cea978cf31c2eac Mon Sep 17 00:00:00 2001 From: obroccolio Date: Wed, 12 Aug 2026 19:10:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(wordcloud):=20=E5=89=8D=E7=AB=AF=20R4=20?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B/api/=E8=AF=8D=E4=BA=91=E9=A1=B5=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E6=8E=A5=E5=8F=A3=20+=20stickerEdit=20=E7=BA=BF?= =?UTF-8?q?=E7=A8=BF=E7=9C=9F=E5=AE=9E=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/diy/stickerEdit/index.tsx | 44 +++++------- src/pages/wordcloud/index.tsx | 70 ++++++++++++++---- src/types/index.ts | 85 +++++++++++++++++++--- src/utils/api/upload.ts | 107 ++++++++++++++++++++++++++-- 4 files changed, 252 insertions(+), 54 deletions(-) diff --git a/src/pages/diy/stickerEdit/index.tsx b/src/pages/diy/stickerEdit/index.tsx index 4e529be..0811ccd 100644 --- a/src/pages/diy/stickerEdit/index.tsx +++ b/src/pages/diy/stickerEdit/index.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } 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 { useThemeContext } from '../../../context/ThemeContext' import { useSafeArea } from '../../../hooks/useSafeArea' import { useStatusBar } from '../../../hooks/useStatusBar' @@ -263,36 +264,25 @@ export default function StickerEditPage() { }) } - /** 线稿:预留后端AI接口 */ - const API_BASE = 'https://your-api-domain.com' // ← 填入你的服务器地址 - - const handleSketch = () => { + /** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度 */ + const handleSketch = async () => { if (!sticker) return setLoading(true) - Taro.uploadFile({ - url: `${API_BASE}/api/sketch`, - filePath: sticker.src, - name: 'image', - success: (res) => { - try { - const data = JSON.parse(res.data) - if (data.url) { - const newSticker = { ...sticker, src: data.url, edits: { ...sticker.edits, sketchSrc: data.url } } - setSticker(newSticker) - setTimeout(() => redraw(), 200) - Taro.showToast({ title: '线稿生成成功', icon: 'success' }) - } else { - throw new Error('no url') - } - } catch { - // 如果接口不可用,降级为前端灰度+边缘检测 - applyFrontendSketch() - } - }, - fail: () => { - applyFrontendSketch() + try { + const res = await sketchImage(sticker.src) + if (res?.imageUrl) { + const newSticker = { ...sticker, src: res.imageUrl, edits: { ...sticker.edits, sketchSrc: res.imageUrl } } + setSticker(newSticker) + setLoading(false) + setTimeout(() => redraw(), 200) + Taro.showToast({ title: '线稿生成成功', icon: 'success' }) + } else { + throw new Error('no imageUrl') } - }) + } catch { + // 如果接口不可用/失败,降级为前端灰度+边缘检测 + applyFrontendSketch() + } } /** 前端线稿降级方案:灰度+反相高对比 */ diff --git a/src/pages/wordcloud/index.tsx b/src/pages/wordcloud/index.tsx index ab4bb88..208a821 100644 --- a/src/pages/wordcloud/index.tsx +++ b/src/pages/wordcloud/index.tsx @@ -8,6 +8,7 @@ import { useStatusBar } from '../../hooks/useStatusBar' import ThemedPageMeta from '../../components/ThemedPageMeta' import ScrollTopMask from '../../components/ScrollTopMask' import { assetUrl } from '../../utils/asset' +import { createWordCloudJob, getWordCloudJob } from '../../utils/api' export default function WordCloudPage() { const { theme, resolvedTheme } = useThemeContext() @@ -19,6 +20,7 @@ export default function WordCloudPage() { const [generatedImage, setGeneratedImage] = useState('') const [isGenerating, setIsGenerating] = useState(false) const [progress, setProgress] = useState(0) + const [error, setError] = useState('') const steps = [ { num: 1, label: '上传底图' }, @@ -33,24 +35,53 @@ export default function WordCloudPage() { }) } - const handleGenerate = () => { + const handleGenerate = async () => { if (!namesText.trim()) { Taro.showToast({ title: '请先输入名字', icon: 'none' }) return } - setStep(3); setIsGenerating(true); setProgress(0) - const timer = setInterval(() => { - setProgress((prev) => { - if (prev >= 100) { - clearInterval(timer); setIsGenerating(false) - setGeneratedImage(baseImage) - return 100 - } - return prev + Math.random() * 15 - }) - }, 1000) + if (!baseImage) { + Taro.showToast({ title: '请先上传底图', icon: 'none' }) + return + } + 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((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) + setGeneratedImage(job.imageUrl || baseImage) + resolve() + } else if (job.status === 'failed') { + clearInterval(timer); setIsGenerating(false) + setError(job.error || '词云生成失败') + reject(new Error(job.error || '词云生成失败')) + } + } catch { + // 连续 5 次请求失败才终止,避免瞬时网络问题打断任务 + retries += 1 + if (retries > 5) { + clearInterval(timer); setIsGenerating(false) + reject(new Error('任务状态获取失败,请稍后重试')) + } + } + }, 1500) + }) + const handleExportImage = () => { if (!generatedImage) return Taro.saveImageToPhotosAlbum({ @@ -148,10 +179,21 @@ export default function WordCloudPage() { )} - {/* 步骤3: 生成中/预览 */} + {/* 步骤3: 生成中/结果/失败 */} {step === 3 && ( - {isGenerating ? ( + {error ? ( + + + 生成失败 + {error} + + { setError(''); setStep(2) }}> + 返回修改名单 + + + + ) : isGenerating ? ( 正在生成词云... diff --git a/src/types/index.ts b/src/types/index.ts index 13d5586..0cbc557 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -56,6 +56,10 @@ export interface StickerItem { width: number height: number isOverlapping: boolean + /** 旋转角(度);本期 DIY 持久化,WCD 打包会携带(见 design-data-contract-v1.md 决策#2) */ + rotation?: number + /** 图层顺序,WCD 按 zIndex 排列元素 */ + zIndex?: number /** 编辑状态持久化 */ edits?: { 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 { id: string @@ -75,14 +151,7 @@ export interface DesignItem { unitPrice: number count: number status: 'undesigned' | 'designing' | 'ordered' - designData?: { - /** 兼容旧版字段 */ - imageSrc?: string - imagePos?: { x: number; y: number; scale: number } - /** 新版贴纸列表 */ - stickers?: StickerItem[] - category?: ProductCategory - } + designData?: DesignDataV1 orderId?: string createdAt: string } diff --git a/src/utils/api/upload.ts b/src/utils/api/upload.ts index 9aae6ed..af59b59 100644 --- a/src/utils/api/upload.ts +++ b/src/utils/api/upload.ts @@ -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 上传接口归属文件。 - * 后端 COS/wordcloud 适配器就绪后,在这里补充: - * getUploadCredentials / uploadImage / generateWordCloud / getWordCloudJob / sketchImage - * 词云任务使用“创建任务 -> 轮询状态 -> 取结果链接”的异步模型。 + * R4 上传 / 词云 / 线稿接口。 + * 契约:前端与后端见 docs 里 api-contract-v1.md §8;后端与 wordcloud 见 wordcloud-contract.md。 + * multipart 上传经 Taro.uploadFile 统一带 Bearer token、解析后端 { code, message, data } 信封; + * 与 request.ts 的 JSON 封装保持一致的成功/失败语义。 */ -export {} + +interface Envelope { + code: number + message: string + data?: T +} + +/** multipart 上传封装:带 token、解析统一信封;业务失败抛 Error(message) */ +async function uploadMultipart( + path: string, + filePath: string, + name: string, + formData?: Record, +): Promise { + const res = await Taro.uploadFile({ + url: `${BASE_URL}${path}`, + filePath, + name, + formData, + header: { Authorization: `Bearer ${getToken()}` }, + }) + + let body: Envelope + 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 { + return http.get(`/api/upload/credentials?key=${encodeURIComponent(key)}`) +} + +/** + * 直传 COS:服务端下发预签名 URL 时用 PUT 提交文件字节;否则返回凭证由调用方决定。 + * 注:COS 直传的最终语义以联调阶段服务端 upload 模块实现为准(当前后端为占位)。 + */ +export async function uploadToCos(localPath: string, credentials: CosCredentials): Promise { + 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 { + return uploadMultipart('/api/sketch', localPath, 'image') +} + +/** 创建词云任务:底图 + 名单 → jobId(异步模型,随后轮询) */ +export function createWordCloudJob( + imagePath: string, + names: string, + params?: Record, +): 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 { + return http.get(`/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 }