feat:新增光栅卡与镭射卡与新版UI的对接

This commit is contained in:
liulong 2026-05-15 16:53:37 +08:00
parent 15834c6719
commit 39209849cc
11 changed files with 1951 additions and 362 deletions

View File

@ -70,25 +70,11 @@ const pageProxy = getCurrentInstance()?.proxy
const MOTION_HIDE_PX = 0.45 const MOTION_HIDE_PX = 0.45
/** 仅在画面因倾斜产生可见位移后隐藏提示(非陀螺仪连接即隐藏) */
function shouldHideTiltHint() { function shouldHideTiltHint() {
if (
props.gyroSource === 'accelerometer' ||
props.gyroSource === 'gyroscope' ||
props.gyroSource === 'orientation'
) {
return true
}
return Object.values(props.transforms || {}).some((t) => Math.abs(t.x) > MOTION_HIDE_PX) return Object.values(props.transforms || {}).some((t) => Math.abs(t.x) > MOTION_HIDE_PX)
} }
watch(
() => props.gyroSource,
() => {
if (shouldHideTiltHint()) showHint.value = false
},
{ immediate: true }
)
watch( watch(
() => props.transforms, () => props.transforms,
() => { () => {

View File

@ -0,0 +1,163 @@
/**
* 铸爱流程中的光栅预览 lenticular-studio 一致的物理参数离散档位与陀螺仪启动节奏
*/
import { ref, nextTick } from 'vue'
import { useLenticularPreview } from '@/composables/useLenticularPreview.js'
import { useLenticularStudioTilt } from '@/composables/useLenticularStudioTilt.js'
const FLOW_PHYSICS = {
angleStability: 52,
transitionSmoothness: 40,
tiltSensitivity: 96,
sensorDeadzoneStrength: 0,
parallaxDepth: 18,
lenticularAnchorFloor: 0.1,
lenticularNonDominantResidualMin: 0.092,
lenticularPrevLayerGhostMin: 0.098,
lenticularBlendBaseScale: 1.1,
}
const DISCRETE_STEP_DEG = 13
const DISCRETE_STEP_HYST_DEG = 5
const TILT_MAG_TAU_MS = 150
const TILT_MAG_MAX_RISE_DPS = 82
const TILT_MAG_MAX_FALL_DPS = 168
const TILT_AWAY_FROM_LEVEL_DPS = 72
const TILT_RISE_TIGHTEN = 0.42
export const LENTICULAR_TILT_START_DELAY_MS = 560
/**
* @param {import('vue').Ref<Array>|import('vue').Ref<unknown>} layersRef
*/
export function useLenticularCraftTiltPreview(layersRef) {
const gyroSourceLabel = ref('simulation')
const { physics, layerTransforms, simulate, relax, snapSimulatedTilt } = useLenticularPreview(layersRef)
Object.assign(physics, FLOW_PHYSICS)
const discreteStableStep = ref(0)
let tiltMagEma = 0
let lastTiltMagSampleAt = 0
let lastSignedDegHint = null
function resetTiltMagFilter() {
tiltMagEma = 0
lastTiltMagSampleAt = 0
lastSignedDegHint = null
}
function getLayersArray() {
const v = layersRef.value !== undefined ? layersRef.value : layersRef
return Array.isArray(v) ? v : []
}
function simulateFromSignedDegrees(tiltMagDeg, signedDegHint) {
const ls = getLayersArray()
const n = Math.max(1, ls.length)
const raw = Math.abs(Number(tiltMagDeg) || 0)
const now = Date.now()
let dt = 33
if (lastTiltMagSampleAt > 0) {
dt = now - lastTiltMagSampleAt
}
lastTiltMagSampleAt = now
dt = Math.max(16, Math.min(100, dt))
const alpha = 1 - Math.exp(-dt / TILT_MAG_TAU_MS)
let nextEma = tiltMagEma + (raw - tiltMagEma) * alpha
const sec = dt * 0.001
let maxRise = TILT_MAG_MAX_RISE_DPS * sec
const maxFall = TILT_MAG_MAX_FALL_DPS * sec
if (Number.isFinite(signedDegHint)) {
if (lastSignedDegHint != null && sec > 1e-6) {
const dAbsSignedDt =
(Math.abs(signedDegHint) - Math.abs(lastSignedDegHint)) / sec
if (dAbsSignedDt > TILT_AWAY_FROM_LEVEL_DPS) {
maxRise *= TILT_RISE_TIGHTEN
}
}
lastSignedDegHint = signedDegHint
} else {
lastSignedDegHint = null
}
let dMag = nextEma - tiltMagEma
if (dMag > 0) {
dMag = Math.min(dMag, maxRise)
} else {
dMag = Math.max(dMag, -maxFall)
}
tiltMagEma += dMag
const absDeg = tiltMagEma
const STEP = DISCRETE_STEP_DEG
const MARGIN = DISCRETE_STEP_HYST_DEG
const stepBefore = discreteStableStep.value
let s = stepBefore
while (absDeg >= (s + 1) * STEP + MARGIN) s++
while (s > 0 && absDeg <= s * STEP - MARGIN) s--
discreteStableStep.value = s
const idx = s % n
const sens = physics.tiltSensitivity / 100
const mul = 0.44 + sens * 0.52
const u = (idx + 0.5) / n
const gPick = Math.max(-1, Math.min(1, 2 * u - 1))
const gamma = Math.max(-1, Math.min(1, gPick / mul))
simulate(gamma, 0)
}
function lockPreviewStill() {
discreteStableStep.value = 0
resetTiltMagFilter()
simulateFromSignedDegrees(0)
snapSimulatedTilt({ resetLayerSmoothing: true })
}
const { start: startTilt, stop: stopTilt } = useLenticularStudioTilt({
simulate,
simulateFromSignedDegrees,
gyroSourceLabel,
useStudioAccelDirect: true,
onTiltDriverFallback: () => {
discreteStableStep.value = 0
resetTiltMagFilter()
},
})
let entryTiltTimer = null
function cancelScheduledTiltStart() {
if (entryTiltTimer != null) {
clearTimeout(entryTiltTimer)
entryTiltTimer = null
}
}
function scheduleTiltStart() {
cancelScheduledTiltStart()
nextTick(() => {
relax(0.86)
lockPreviewStill()
entryTiltTimer = setTimeout(() => {
entryTiltTimer = null
startTilt()
}, LENTICULAR_TILT_START_DELAY_MS)
})
}
function stopTiltPreview() {
cancelScheduledTiltStart()
stopTilt()
}
return {
physics,
layerTransforms,
simulate,
relax,
gyroSourceLabel,
scheduleTiltStart,
stopTiltPreview,
lockPreviewStill,
}
}

View File

@ -10,8 +10,104 @@
</view> </view>
</view> </view>
<!-- 铸爱选图后确认铸造光栅 / 镭射 -->
<scroll-view v-if="craftConfirmMode" scroll-y class="content-scroll craft-confirm-scroll">
<view class="content-wrapper craft-confirm-body">
<view class="card-section craft-card-section">
<view class="card-wrapper craft-card-wrapper">
<image
class="card-frame"
src="/static/square/gerenzhongxincangpinkuang.png"
mode="aspectFit"
/>
<view v-if="isCraftLenticular" class="craft-lenticular-slot">
<LenticularCard
class="craft-lenticular-card"
:layers="lenticularLayers"
:transforms="layerTransforms"
:gyro-source="gyroSourceLabel"
:skip-built-in-touch="true"
tilt-hint-text="晃动查看"
:shimmer-mid-opacity="0.16"
@simulate="simulate"
/>
</view>
<image
v-else
class="card-image craft-card-image"
:src="craftCoverUrl"
mode="aspectFill"
/>
</view>
<view class="card-meta-row">
<view class="earnings-area">
<image class="crystal-icon" src="/static/icon/crystal.png" mode="aspectFit" />
<text class="earnings-text">{{ craftEarningsHint }}</text>
</view>
</view>
</view>
<view class="info-row craft-info-row">
<view class="info-col">
<view class="info-item">
<text class="info-label">分类</text>
<text class="info-value">{{ craftCategoryLabel }}</text>
</view>
</view>
<view class="info-col">
<view class="info-item">
<text class="info-label">创作者</text>
<text class="info-value">{{ craftCreatorName }}</text>
</view>
</view>
<view class="info-col">
<view class="info-item">
<text class="info-label">铸爱时间</text>
<text class="info-value">{{ craftMintDate }}</text>
</view>
</view>
</view>
<view class="chain-section">
<image class="chain-logo" src="/static/logo/APPLOGO.png" mode="aspectFit" />
<view class="chain-left">
<view class="chain-row">
<text class="chain-label">数根名称</text>
<view class="chain-value-wrap">
<text class="chain-value">{{ craftAssetName }}</text>
</view>
</view>
<view class="chain-row">
<text class="chain-label">数根发行方</text>
<view class="chain-value-wrap">
<text class="chain-value">TOPFANS</text>
</view>
</view>
<view class="chain-row">
<text class="chain-label">区块链编号</text>
<view class="chain-value-wrap">
<text class="chain-value">{{ craftBlockPlaceholder }}</text>
</view>
</view>
<view class="chain-row">
<text class="chain-label">交易哈希</text>
<view class="chain-value-wrap">
<text class="chain-value chain-hash">{{ craftHashPlaceholder }}</text>
</view>
</view>
</view>
</view>
<view class="craft-mint-bar">
<button class="craft-mint-btn" :disabled="craftMinting" @tap="handleCraftMint">
{{ craftMinting ? '提交中…' : '确认铸造' }}
</button>
</view>
</view>
</scroll-view>
<!-- 加载中 --> <!-- 加载中 -->
<view v-if="loading" class="loading-wrapper"> <view v-else-if="loading" class="loading-wrapper">
<!-- 旋转光环 --> <!-- 旋转光环 -->
<view class="loading-ring-outer"> <view class="loading-ring-outer">
<view class="loading-ring"></view> <view class="loading-ring"></view>
@ -27,7 +123,7 @@
</view> </view>
<!-- 错误状态 --> <!-- 错误状态 -->
<view v-else-if="loadError" class="error-wrapper"> <view v-else-if="loadError && !craftConfirmMode" class="error-wrapper">
<text class="error-text">{{ loadError }}</text> <text class="error-text">{{ loadError }}</text>
<button class="retry-btn" @tap="loadData">重试</button> <button class="retry-btn" @tap="loadData">重试</button>
</view> </view>
@ -189,15 +285,62 @@
<script setup> <script setup>
import { ref, computed, onUnmounted } from 'vue'; import { ref, computed, onUnmounted } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app'; import { onLoad, onShow, onHide } from '@dcloudio/uni-app';
import { getAssetDetailApi, getMintOrderDetailApi, likeAssetApi, unlikeAssetApi } from '@/utils/api.js'; import { getAssetDetailApi, getMintOrderDetailApi, likeAssetApi, unlikeAssetApi } from '@/utils/api.js';
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js'; import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js';
import LikeUsersModal from '@/pages/components/LikeUsersModal.vue'; import LikeUsersModal from '@/pages/components/LikeUsersModal.vue';
import LenticularCard from '@/components/lenticular/LenticularCard.vue';
import { useLenticularCraftTiltPreview } from '@/composables/useLenticularCraftTiltPreview.js';
import {
buildLenticularLayersTwo,
LENTICULAR_STUDIO_STORAGE_KEY,
CRAFT_LENTICULAR_CN,
CRAFT_LASER_CARD_CN,
} from '@/utils/castloveMintForm.js';
import {
CASTLOVE_FORM_KEY,
CRAFT_SELECTED_IMAGE_KEY,
STUDIO_LENTICULAR,
STUDIO_LASER,
} from '@/utils/castloveGenerationFlow.js';
import { submitCraftMintFromPath } from '@/utils/craftMintSubmit.js';
// //
const assetIdParam = ref(''); const assetIdParam = ref('');
const orderIdParam = ref(''); const orderIdParam = ref('');
const fromParam = ref(''); const fromParam = ref('');
const lastLoadKey = ref(''); // key const studioKindParam = ref('');
const lastLoadKey = ref('');
const craftConfirmMode = ref(false);
const craftFormData = ref({});
const craftCoverUrl = ref('');
const craftMinting = ref(false);
const lenticularLayers = ref([]);
const {
layerTransforms,
simulate,
gyroSourceLabel,
scheduleTiltStart,
stopTiltPreview,
} = useLenticularCraftTiltPreview(lenticularLayers);
const isCraftLenticular = computed(() => studioKindParam.value === STUDIO_LENTICULAR);
const craftCategoryLabel = computed(() => {
if (isCraftLenticular.value) return CRAFT_LENTICULAR_CN;
if (studioKindParam.value === STUDIO_LASER) return CRAFT_LASER_CARD_CN;
return craftFormData.value?.typeName || '星卡';
});
const craftAssetName = computed(() => craftFormData.value?.name || craftCategoryLabel.value);
const craftCreatorName = computed(() => '我');
const craftEarningsHint = computed(() => '21/时');
const craftMintDate = computed(() => {
const d = new Date();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}.${mm}.${dd}`;
});
const craftBlockPlaceholder = computed(() => '铸造后生成');
const craftHashPlaceholder = computed(() => '铸造后生成');
// //
const loading = ref(true); const loading = ref(true);
@ -418,6 +561,54 @@ const handleLike = async () => {
} }
}; };
const loadCraftConfirm = () => {
loading.value = false;
loadError.value = '';
try {
const formStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
if (formStr) {
craftFormData.value = JSON.parse(formStr);
}
const selected = uni.getStorageSync(CRAFT_SELECTED_IMAGE_KEY) || '';
craftCoverUrl.value = selected;
if (isCraftLenticular.value) {
const raw = uni.getStorageSync(LENTICULAR_STUDIO_STORAGE_KEY);
if (raw) {
const p = typeof raw === 'string' ? JSON.parse(raw) : raw;
lenticularLayers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '');
scheduleTiltStart();
}
}
} catch (e) {
console.error('[asset-detail] craft confirm', e);
loadError.value = '加载确认页失败';
}
};
const handleCraftMint = async () => {
if (craftMinting.value) return;
const imagePath =
isCraftLenticular.value
? lenticularLayers.value.find((l) => l.id === 'mid')?.src || craftCoverUrl.value
: craftCoverUrl.value;
if (!imagePath) {
uni.showToast({ title: '缺少作品图', icon: 'none' });
return;
}
craftMinting.value = true;
uni.showLoading({ title: '铸造中…', mask: true });
try {
await submitCraftMintFromPath({ imagePath, formData: craftFormData.value });
uni.hideLoading();
uni.navigateTo({ url: '/pages/castlove/success' });
} catch (e) {
uni.hideLoading();
uni.showToast({ title: e.message || '铸造失败', icon: 'none' });
} finally {
craftMinting.value = false;
}
};
// //
const handleBack = () => { const handleBack = () => {
if (fromParam.value === 'castlove') { if (fromParam.value === 'castlove') {
@ -428,26 +619,45 @@ const handleBack = () => {
}; };
onLoad((options) => { onLoad((options) => {
console.log('onLoad 触发,参数:', options);
assetIdParam.value = options?.asset_id || ''; assetIdParam.value = options?.asset_id || '';
orderIdParam.value = options?.order_id || ''; orderIdParam.value = options?.order_id || '';
fromParam.value = options?.from || ''; fromParam.value = options?.from || '';
studioKindParam.value = options?.studio_kind || '';
craftConfirmMode.value = fromParam.value === 'craft_confirm';
if (craftConfirmMode.value) {
loadCraftConfirm();
}
}); });
onShow(() => { onShow(() => {
console.log('onShow 触发'); if (craftConfirmMode.value) {
if (isCraftLenticular.value && lenticularLayers.value.length) {
scheduleTiltStart();
}
return;
}
const currentKey = `${assetIdParam.value}_${orderIdParam.value}`; const currentKey = `${assetIdParam.value}_${orderIdParam.value}`;
console.log('当前key:', currentKey, '上次key:', lastLoadKey.value); const hasTarget = !!(assetIdParam.value || orderIdParam.value);
if (!hasTarget) {
// loading.value = false;
if ((assetIdParam.value || orderIdParam.value) && currentKey !== lastLoadKey.value) { loadError.value = '藏品信息不完整';
return;
}
if (currentKey !== lastLoadKey.value) {
lastLoadKey.value = currentKey; lastLoadKey.value = currentKey;
loadData(); loadData();
} }
}); });
onHide(() => {
if (craftConfirmMode.value && isCraftLenticular.value) {
stopTiltPreview();
}
});
onUnmounted(() => { onUnmounted(() => {
if (countdownTimer) clearInterval(countdownTimer); if (countdownTimer) clearInterval(countdownTimer);
stopTiltPreview();
}); });
</script> </script>
@ -1039,4 +1249,71 @@ onUnmounted(() => {
font-size:16rpx; font-size:16rpx;
} }
.craft-confirm-scroll {
flex: 1;
height: calc(100vh - 120rpx);
margin-top: 100rpx;
}
.craft-card-section {
margin-top: 24rpx;
}
.craft-card-wrapper {
position: relative;
width: 520rpx;
height: 680rpx;
margin: 0 auto;
}
.craft-lenticular-slot {
position: absolute;
left: 50%;
top: 50%;
width: 78%;
height: 82%;
transform: translate(-50%, -50%) rotate(-6deg);
z-index: 2;
}
.craft-lenticular-card {
width: 100%;
height: 100%;
}
.craft-card-image {
position: absolute;
left: 50%;
top: 50%;
width: 78%;
height: 82%;
transform: translate(-50%, -50%) rotate(-6deg);
z-index: 2;
border-radius: 16rpx;
}
.craft-info-row {
margin-top: 32rpx;
}
.craft-mint-bar {
margin: 48rpx 32rpx 80rpx;
}
.craft-mint-btn {
width: 100%;
height: 96rpx;
line-height: 96rpx;
border-radius: 48rpx;
background: linear-gradient(90deg, #ff6b9d 0%, #ffa8c5 100%);
color: #fff;
font-size: 32rpx;
font-weight: bold;
border: none;
}
.craft-mint-btn[disabled] {
opacity: 0.65;
}
</style> </style>

View File

@ -159,7 +159,7 @@
:class="isLaserCardCraft ? 'btn-confirm-laser' : 'btn-skip'" :class="isLaserCardCraft ? 'btn-confirm-laser' : 'btn-skip'"
@click="handleSingleCraftLaserEntry" @click="handleSingleCraftLaserEntry"
> >
{{ isLaserCardCraft ? '确认' : '生成' }} 生成
</button> </button>
</view> </view>
</scroll-view> </scroll-view>
@ -196,9 +196,13 @@ import {
buildCastloveFormSnapshot, buildCastloveFormSnapshot,
CRAFT_LENTICULAR_CN, CRAFT_LENTICULAR_CN,
CRAFT_LASER_CARD_CN, CRAFT_LASER_CARD_CN,
CASTLOVE_LASER_ENTRY_KEY,
LENTICULAR_STUDIO_STORAGE_KEY,
} from '@/utils/castloveMintForm.js'; } from '@/utils/castloveMintForm.js';
import {
startAiImageGenerationFlow,
startCraftGenerationFlow,
STUDIO_LENTICULAR,
STUDIO_LASER,
} from '@/utils/castloveGenerationFlow.js';
// H5 onLoad query onMounted + getCurrentPages().options undefined // H5 onLoad query onMounted + getCurrentPages().options undefined
const pageType = ref(''); const pageType = ref('');
@ -677,100 +681,57 @@ const handleBack = async () => {
} }
}; };
/** 光栅卡:双图校验后进入工作室预览 */ /** 光栅 / 镭射:上传 → Loading → 选图 → 工作室 */
const handleLenticularGenerate = () => { const buildCraftFormData = () => {
if (!uploadedBg.value || !uploadedSubject.value) { const snap = isLenticularCraft.value
uni.showToast({ title: '请上传背景图与主体图', icon: 'none' }); ? buildCastloveFormSnapshot({
return;
}
if (!uploadedBgBase64.value || !uploadedSubjectBase64.value) {
uni.showToast({ title: '图片尚未处理完成', icon: 'none' });
return;
}
if (!nftInfo.value.trim()) {
uni.showToast({ title: '请输入藏品信息', icon: 'none' });
return;
}
try {
uni.setStorageSync(
LENTICULAR_STUDIO_STORAGE_KEY,
JSON.stringify({
bgPath: uploadedBg.value,
subjectPath: uploadedSubject.value,
bgBase64: uploadedBgBase64.value,
subjectBase64: uploadedSubjectBase64.value,
nftInfo: nftInfo.value, nftInfo: nftInfo.value,
materialTypes,
materialTypeIndex: materialTypeIndex.value, materialTypeIndex: materialTypeIndex.value,
aiDescription: aiDescription.value, pageName: pageName.value,
}) uploadedImage: uploadedSubject.value,
); uploadedImageBase64: uploadedSubjectBase64.value,
} catch (e) { })
console.error('[CreatePage] lenticular studio payload', e); : buildCastloveFormSnapshot({
uni.showToast({ title: '保存失败,请重试', icon: 'none' });
return;
}
uni.navigateTo({
url: '/pages/castlove/lenticular-studio',
});
};
/** 单图工艺进入镭射工坊Laser-Card 配置页),保存后再上传并创建订单 */
const handleSingleCraftLaserEntry = () => {
if (isLenticularCraft.value) {
uni.showToast({ title: '光栅卡请使用「生成」进入工作室', icon: 'none' });
return;
}
if (!uploadedImage.value) {
uni.showToast({ title: '请上传藏品图片', icon: 'none' });
return;
}
if (!uploadedImageBase64.value) {
uni.showToast({ title: '图片尚未处理完成', icon: 'none' });
return;
}
if (!nftInfo.value.trim()) {
uni.showToast({ title: '请输入藏品信息', icon: 'none' });
return;
}
try {
uni.setStorageSync(
CASTLOVE_LASER_ENTRY_KEY,
JSON.stringify({
nftInfo: nftInfo.value, nftInfo: nftInfo.value,
materialTypes: [...materialTypes], materialTypes,
materialTypeIndex: materialTypeIndex.value, materialTypeIndex: materialTypeIndex.value,
pageName: pageName.value, pageName: pageName.value,
uploadedImage: uploadedImage.value, uploadedImage: uploadedImage.value,
uploadedImageBase64: uploadedImageBase64.value, uploadedImageBase64: uploadedImageBase64.value,
}) });
);
} catch (e) { return {
console.error('[CreatePage] laser entry payload', e); ...snap,
uni.showToast({ title: '保存失败,请重试', icon: 'none' }); image: isLenticularCraft.value ? uploadedSubject.value : uploadedImage.value,
return; imageBase64: isLenticularCraft.value ? uploadedSubjectBase64.value : uploadedImageBase64.value,
} type: pageType.value,
uni.navigateTo({ typeName: pageName.value,
url: '/pages/castlove/laser-card-studio', materialType: snap.material_type,
}); materialTypeIndex: materialTypeIndex.value,
materialTypes: [...materialTypes],
aiDescription: aiDescription.value,
nftInfo: nftInfo.value,
...(isLenticularCraft.value
? {
lenticularBgImage: uploadedBg.value,
lenticularBgBase64: uploadedBgBase64.value,
lenticularSubjectImage: uploadedSubject.value,
lenticularSubjectBase64: uploadedSubjectBase64.value,
}
: {}),
};
}; };
// const buildCraftPrompt = () => {
const handleTabChange = (newTab) => { let fullText = aiDescription.value.trim() || nftInfo.value.trim();
const routes = [ if (pageName.value) {
'/pages/square/square', fullText = `${pageName.value}${fullText}`;
'/pages/starbook/index',
'/pages/castlove/mall',
'/pages/starcity/index',
'/pages/ai-dazi/index'
];
if (newTab >= 0 && newTab < routes.length) {
uni.navigateTo({ url: routes[newTab] });
} }
return fullText;
}; };
// const startCraftStudioPipeline = (studioKind) => {
const handleGenerate = async () => {
if (isLenticularCraft.value) { if (isLenticularCraft.value) {
if (!uploadedBg.value || !uploadedSubject.value) { if (!uploadedBg.value || !uploadedSubject.value) {
uni.showToast({ title: '请上传背景图与主体图', icon: 'none' }); uni.showToast({ title: '请上传背景图与主体图', icon: 'none' });
@ -796,93 +757,110 @@ const handleGenerate = async () => {
return; return;
} }
// try {
const formData = buildCraftFormData();
startCraftGenerationFlow({ formData, studioKind });
} catch (e) {
console.error('[CreatePage] craft studio pipeline', e);
uni.showToast({ title: '启动失败,请重试', icon: 'none' });
}
};
const handleLenticularGenerate = () => {
startCraftStudioPipeline(STUDIO_LENTICULAR);
};
const handleSingleCraftLaserEntry = () => {
if (isLenticularCraft.value) {
uni.showToast({ title: '光栅卡请使用「生成」', icon: 'none' });
return;
}
startCraftStudioPipeline(STUDIO_LASER);
};
//
const handleTabChange = (newTab) => {
const routes = [
'/pages/square/square',
'/pages/starbook/index',
'/pages/castlove/mall',
'/pages/starcity/index',
'/pages/ai-dazi/index'
];
if (newTab >= 0 && newTab < routes.length) {
uni.navigateTo({ url: routes[newTab] });
}
};
// /
const handleGenerate = async () => {
if (isLenticularCraft.value) {
startCraftStudioPipeline(STUDIO_LENTICULAR);
return;
}
if (isLaserCardCraft.value) {
startCraftStudioPipeline(STUDIO_LASER);
return;
}
if (!uploadedImage.value) {
uni.showToast({ title: '请上传藏品图片', icon: 'none' });
return;
}
if (!uploadedImageBase64.value) {
uni.showToast({ title: '图片尚未处理完成', icon: 'none' });
return;
}
if (!nftInfo.value.trim()) {
uni.showToast({ title: '请输入藏品信息', icon: 'none' });
return;
}
let fullText = aiDescription.value.trim(); let fullText = aiDescription.value.trim();
if (pageName.value) { if (pageName.value) {
fullText = `${pageName.value}${fullText}`; fullText = `${pageName.value}${fullText}`;
} }
if (!fullText || fullText === `${pageName.value}`) { if (!fullText || fullText === `${pageName.value}`) {
uni.showToast({ title: '请输入AI描述', icon: 'none' }); uni.showToast({ title: '请输入AI描述', icon: 'none' });
return; return;
} }
const snap = isLenticularCraft.value const snap = buildCastloveFormSnapshot({
? buildCastloveFormSnapshot({ nftInfo: nftInfo.value,
nftInfo: nftInfo.value, materialTypes,
materialTypes, materialTypeIndex: materialTypeIndex.value,
materialTypeIndex: materialTypeIndex.value, pageName: pageName.value,
pageName: pageName.value, uploadedImage: uploadedImage.value,
uploadedImage: uploadedSubject.value, uploadedImageBase64: uploadedImageBase64.value,
uploadedImageBase64: uploadedSubjectBase64.value, });
})
: buildCastloveFormSnapshot({
nftInfo: nftInfo.value,
materialTypes,
materialTypeIndex: materialTypeIndex.value,
pageName: pageName.value,
uploadedImage: uploadedImage.value,
uploadedImageBase64: uploadedImageBase64.value,
});
const formData = { const formData = {
...snap, ...snap,
image: isLenticularCraft.value ? uploadedSubject.value : uploadedImage.value, image: uploadedImage.value,
imageBase64: isLenticularCraft.value ? uploadedSubjectBase64.value : uploadedImageBase64.value, imageBase64: uploadedImageBase64.value,
type: pageType.value, type: pageType.value,
typeName: pageName.value, typeName: pageName.value,
materialType: snap.material_type, materialType: snap.material_type,
...(isLenticularCraft.value
? {
lenticularBgImage: uploadedBg.value,
lenticularBgBase64: uploadedBgBase64.value,
lenticularSubjectImage: uploadedSubject.value,
lenticularSubjectBase64: uploadedSubjectBase64.value,
}
: {}),
}; };
try { try {
uni.setStorageSync('castlove_form_data', JSON.stringify(formData)); uni.setStorageSync('castlove_form_data', JSON.stringify(formData));
} catch (e) { } catch (e) {
console.error('保存表单数据失败:', e); console.error('保存表单数据失败:', e);
return; return;
} }
//
sendToBackend(fullText); sendToBackend(fullText);
}; };
// // Loading
const sendToBackend = (aiDescription) => { const sendToBackend = (aiDescription) => {
// base64 const formDataStr = uni.getStorageSync('castlove_form_data');
const requestData = { const formData = formDataStr ? JSON.parse(formDataStr) : {};
prompt: aiDescription, // AI startAiImageGenerationFlow({ prompt: aiDescription, formData });
model: "image-01",
aspect_ratio: "16:9",
subject_reference: [
{
type: "character",
image_file: "" // 使castlove_form_data
}
],
"n": 4
};
//
uni.setStorageSync('generation_request_data', JSON.stringify(requestData));
console.log('[CreatePage] 生成请求数据已保存');
// base64castlove_form_data
//
// resetForm();
//
uni.navigateTo({
url: '/pages/discover/generation-loading'
});
}; };
// //

View File

@ -45,6 +45,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { onUnload } from '@dcloudio/uni-app';
import Header from '../components/Header.vue'; import Header from '../components/Header.vue';
import NftCard from '../components/NftCard.vue'; import NftCard from '../components/NftCard.vue';
@ -88,25 +89,41 @@ onMounted(() => {
} }
}); });
// // temp_nft_data order_id
const handleViewDetails = () => { const handleViewDetails = () => {
let orderId = ''; let orderId = '';
let assetId = '';
try { try {
const raw = uni.getStorageSync('temp_nft_data'); const raw = uni.getStorageSync('temp_nft_data');
if (raw) { if (raw) {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
orderId = parsed.order_id || ''; orderId = parsed.order_id || '';
assetId = parsed.asset_id || '';
} }
} catch (e) { } catch (e) {
console.error('读取 order_id 失败:', e); console.error('读取铸造结果失败:', e);
} }
uni.removeStorageSync('temp_nft_data'); if (!orderId && !assetId) {
uni.showToast({ title: '订单信息已失效,请从星册查看', icon: 'none' });
return;
}
const query = assetId
? `asset_id=${encodeURIComponent(assetId)}`
: `order_id=${encodeURIComponent(orderId)}`;
uni.navigateTo({ uni.navigateTo({
url: `/pages/asset-detail/asset-detail?order_id=${orderId}&from=castlove` url: `/pages/asset-detail/asset-detail?${query}&from=castlove`,
}); });
}; };
onUnload(() => {
try {
uni.removeStorageSync('temp_nft_data');
} catch (e) {
/* noop */
}
});
</script> </script>
<style scoped> <style scoped>

View File

@ -88,6 +88,7 @@
<script setup> <script setup>
import { ref, onMounted, nextTick } from 'vue'; import { ref, onMounted, nextTick } from 'vue';
import ConfirmModal from '@/components/ConfirmModal.vue'; import ConfirmModal from '@/components/ConfirmModal.vue';
import { startAiImageGenerationFlow } from '@/utils/castloveGenerationFlow.js';
// //
const dreamInput = ref(''); const dreamInput = ref('');
@ -777,31 +778,11 @@ const handleInputConfirm = () => {
}); });
}; };
// // Loading
const sendToBackend = (aiDescription) => { const sendToBackend = (aiDescription) => {
// base64 const formDataStr = uni.getStorageSync('castlove_form_data');
const requestData = { const formData = formDataStr ? JSON.parse(formDataStr) : {};
prompt: aiDescription, // AI startAiImageGenerationFlow({ prompt: aiDescription, formData });
model: "image-01",
aspect_ratio: "16:9",
subject_reference: [
{
type: "character",
image_file: "" // 使castlove_form_data
}
],
"n": 4
};
//
uni.setStorageSync('generation_request_data', JSON.stringify(requestData));
// base64castlove_form_data
//
uni.navigateTo({
url: '/pages/discover/generation-loading'
});
}; };
onMounted(() => { onMounted(() => {

View File

@ -1,14 +1,11 @@
<template> <template>
<view class="generation-loading"> <view class="generation-loading">
<!-- 背景图 -->
<image class="background-image" src="/static/background/exhibitionSuccess.png" mode="aspectFill" /> <image class="background-image" src="/static/background/exhibitionSuccess.png" mode="aspectFill" />
<!-- 礼盒区域 -->
<view class="gift-box"> <view class="gift-box">
<image class="gift-image" src="/static/nft/lihe.png" mode="aspectFit" /> <image class="gift-image" src="/static/nft/lihe.png" mode="aspectFit" />
</view> </view>
<!-- 进度条区域 -->
<view class="progress-container"> <view class="progress-container">
<view class="progress-bar-wrapper"> <view class="progress-bar-wrapper">
<view class="progress-bar"> <view class="progress-bar">
@ -19,31 +16,53 @@
</view> </view>
</view> </view>
<text class="progress-text">{{ Math.round(progress) }}%</text> <text class="progress-text">{{ Math.round(progress) }}%</text>
<text class="loading-text">Loading</text> <text class="loading-text">Thinking</text>
</view> </view>
<!-- 镭射批量导出离屏 -->
<canvas
canvas-id="laserBatchCanvas"
class="laser-batch-canvas"
:style="{ width: laserCanvasW + 'px', height: laserCanvasH + 'px' }"
/>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { imageGenerationApi } from '@/utils/api.js'; import { imageGenerationApi } from '@/utils/api.js';
import {
consumeGenerationFlowPayload,
FLOW_MODE_PREFILLED,
FLOW_MODE_LENTICULAR,
FLOW_MODE_LASER,
GENERATED_IMAGES_KEY,
GENERATION_REQUEST_KEY,
CASTLOVE_FORM_KEY,
persistLenticularPreviewMeta,
persistLaserPreviewImages,
} from '@/utils/castloveGenerationFlow.js';
import {
generateLaserVariantBatch,
LASER_BATCH_CANVAS_SIZE,
} from '@/utils/laser-card/laserBatchExport.js';
const progress = ref(0); const progress = ref(0);
const laserCanvasW = LASER_BATCH_CANVAS_SIZE.w;
const laserCanvasH = LASER_BATCH_CANVAS_SIZE.h;
let progressTimer = null; let progressTimer = null;
let generationData = null; let generationData = null;
let flowStartedAt = 0;
//
const simulateProgress = () => { const simulateProgress = () => {
progressTimer = setInterval(() => { progressTimer = setInterval(() => {
if (progress.value < 90) { if (progress.value < 90) {
// 90%
const increment = Math.random() * 5 + 2; const increment = Math.random() * 5 + 2;
progress.value = Math.min(90, progress.value + increment); progress.value = Math.min(90, progress.value + increment);
} }
}, 500); }, 500);
}; };
//
const stopProgress = () => { const stopProgress = () => {
if (progressTimer) { if (progressTimer) {
clearInterval(progressTimer); clearInterval(progressTimer);
@ -51,7 +70,6 @@ const stopProgress = () => {
} }
}; };
//
const completeProgress = () => { const completeProgress = () => {
stopProgress(); stopProgress();
const finalInterval = setInterval(() => { const finalInterval = setInterval(() => {
@ -59,15 +77,11 @@ const completeProgress = () => {
progress.value = Math.min(100, progress.value + 5); progress.value = Math.min(100, progress.value + 5);
} else { } else {
clearInterval(finalInterval); clearInterval(finalInterval);
// setTimeout(() => handleSuccess(), 500);
setTimeout(() => {
handleSuccess();
}, 500);
} }
}, 50); }, 50);
}; };
// 退
const revertProgress = () => { const revertProgress = () => {
stopProgress(); stopProgress();
const revertInterval = setInterval(() => { const revertInterval = setInterval(() => {
@ -75,105 +89,132 @@ const revertProgress = () => {
progress.value = Math.max(0, progress.value - 10); progress.value = Math.max(0, progress.value - 10);
} else { } else {
clearInterval(revertInterval); clearInterval(revertInterval);
// 退 setTimeout(() => uni.navigateBack(), 500);
setTimeout(() => {
uni.navigateBack();
}, 500);
} }
}, 100); }, 100);
}; };
// API - const waitMinDuration = async (minMs) => {
const callImageGeneration = async () => { if (!minMs || minMs <= 0) return;
try { const elapsed = Date.now() - flowStartedAt;
const res = await imageGenerationApi(generationData); if (elapsed < minMs) {
await new Promise((r) => setTimeout(r, minMs - elapsed));
}
};
if (res.data && res.data.images && res.data.images.length > 0) { const handleSuccess = () => {
// uni.showToast({ title: '生成成功', icon: 'success', duration: 1200 });
uni.setStorageSync('generated_images', JSON.stringify(res.data.images)); setTimeout(() => {
completeProgress(); uni.navigateTo({ url: '/pages/discover/generation-result' });
} else { }, 1200);
uni.showToast({ };
title: '未生成图片',
icon: 'none', const runPrefilledFlow = async (flow) => {
duration: 2000 try {
}); const images = flow.images || [];
revertProgress(); if (!images.length) throw new Error('无候选图');
} await waitMinDuration(flow.minDurationMs ?? 1400);
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify(images));
completeProgress();
} catch (err) { } catch (err) {
console.error('[GenerationLoading] 生成失败:', err); console.error('[GenerationLoading] prefilled', err);
uni.showToast({ uni.showToast({ title: err.message || '加载失败', icon: 'none' });
title: err.message || '生成失败',
icon: 'none',
duration: 2000
});
revertProgress(); revertProgress();
} }
}; };
// const runLenticularFlow = async (flow) => {
const handleSuccess = () => { try {
uni.showToast({ const formStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
title: '生成成功', if (!formStr) throw new Error('缺少表单数据');
icon: 'success', const formData = JSON.parse(formStr);
duration: 1500 await waitMinDuration(flow.minDurationMs ?? 1600);
}); persistLenticularPreviewMeta(formData);
completeProgress();
} catch (err) {
console.error('[GenerationLoading] lenticular', err);
uni.showToast({ title: err.message || '光栅预览失败', icon: 'none' });
revertProgress();
}
};
// const runLaserFlow = async (flow) => {
setTimeout(() => { try {
uni.navigateTo({ const formStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
url: '/pages/discover/generation-result' if (!formStr) throw new Error('缺少表单数据');
}); const formData = JSON.parse(formStr);
}, 1500); const imagePath = formData.image || formData.uploadedImage || '';
if (!imagePath) throw new Error('缺少上传图片');
const paths = await generateLaserVariantBatch(imagePath, 'laserBatchCanvas');
await waitMinDuration(flow.minDurationMs ?? 1600);
persistLaserPreviewImages(paths);
completeProgress();
} catch (err) {
console.error('[GenerationLoading] laser', err);
uni.showToast({ title: err.message || '镭射生成失败', icon: 'none' });
revertProgress();
}
};
const callImageGeneration = async () => {
try {
const res = await imageGenerationApi(generationData);
if (res.data?.images?.length > 0) {
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify(res.data.images));
completeProgress();
} else {
uni.showToast({ title: '未生成图片', icon: 'none' });
revertProgress();
}
} catch (err) {
console.error('[GenerationLoading] API', err);
uni.showToast({ title: err.message || '生成失败', icon: 'none' });
revertProgress();
}
}; };
onMounted(() => { onMounted(() => {
// flowStartedAt = Date.now();
simulateProgress();
try { try {
const requestDataStr = uni.getStorageSync('generation_request_data'); const flow = consumeGenerationFlowPayload();
if (!requestDataStr) {
uni.showToast({ if (flow?.mode === FLOW_MODE_LENTICULAR) {
title: '数据错误', runLenticularFlow(flow);
icon: 'none' return;
}); }
setTimeout(() => { if (flow?.mode === FLOW_MODE_LASER) {
uni.navigateBack(); runLaserFlow(flow);
}, 1500); return;
}
if (flow?.mode === FLOW_MODE_PREFILLED) {
runPrefilledFlow(flow);
return; return;
} }
// const requestDataStr = uni.getStorageSync(GENERATION_REQUEST_KEY);
if (!requestDataStr) {
uni.showToast({ title: '数据错误', icon: 'none' });
setTimeout(() => uni.navigateBack(), 1500);
return;
}
generationData = JSON.parse(requestDataStr); generationData = JSON.parse(requestDataStr);
const castloveDataStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
// castlove_form_database64
const castloveDataStr = uni.getStorageSync('castlove_form_data');
if (castloveDataStr) { if (castloveDataStr) {
const castloveData = JSON.parse(castloveDataStr); const castloveData = JSON.parse(castloveDataStr);
// base64 if (generationData.subject_reference?.[0]) {
if (generationData.subject_reference && generationData.subject_reference[0]) { generationData.subject_reference[0].image_file =
generationData.subject_reference[0].image_file = castloveData.imageBase64; castloveData.imageBase64 ||
castloveData.lenticularSubjectBase64 ||
'';
} }
} }
uni.removeStorageSync(GENERATION_REQUEST_KEY);
// generation_request_data使
uni.removeStorageSync('generation_request_data');
console.log('[GenerationLoading] 接收到生成数据');
//
simulateProgress();
// API
callImageGeneration(); callImageGeneration();
} catch (e) { } catch (e) {
console.error('[GenerationLoading] 读取数据失败:', e); console.error('[GenerationLoading] mount', e);
uni.showToast({ uni.showToast({ title: '数据错误', icon: 'none' });
title: '数据错误', setTimeout(() => uni.navigateBack(), 1500);
icon: 'none'
});
setTimeout(() => {
uni.navigateBack();
}, 1500);
} }
}); });
</script> </script>
@ -187,6 +228,14 @@ onMounted(() => {
background: #000; background: #000;
} }
.laser-batch-canvas {
position: fixed;
left: -9999px;
top: -9999px;
opacity: 0;
pointer-events: none;
}
.background-image { .background-image {
position: absolute; position: absolute;
top: 0; top: 0;
@ -208,7 +257,8 @@ onMounted(() => {
} }
@keyframes float { @keyframes float {
0%, 100% { 0%,
100% {
transform: translateX(-50%) translateY(0) rotate(0deg); transform: translateX(-50%) translateY(0) rotate(0deg);
} }
50% { 50% {
@ -247,15 +297,13 @@ onMounted(() => {
border-radius: 20rpx; border-radius: 20rpx;
overflow: hidden; overflow: hidden;
border: 2rpx solid rgba(255, 255, 255, 0.5); border: 2rpx solid rgba(255, 255, 255, 0.5);
backdrop-filter: blur(10rpx);
} }
.progress-fill { .progress-fill {
height: 100%; height: 100%;
background: linear-gradient(90deg, #FF6B9D 0%, #FFA8C5 50%, #FFB6D9 100%); background: linear-gradient(90deg, #ff6b9d 0%, #ffa8c5 50%, #ffb6d9 100%);
border-radius: 20rpx; border-radius: 20rpx;
transition: width 0.3s ease; transition: width 0.3s ease;
box-shadow: 0 0 20rpx rgba(255, 107, 157, 0.6);
} }
.progress-icon { .progress-icon {
@ -270,37 +318,32 @@ onMounted(() => {
.icon-circle { .icon-circle {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(135deg, #FFB6D9 0%, #FF6B9D 100%); background: linear-gradient(135deg, #ffb6d9 0%, #ff6b9d 100%);
border-radius: 50%; border-radius: 50%;
border: 4rpx solid #FFFFFF; border: 4rpx solid #ffffff;
box-shadow: 0 4rpx 12rpx rgba(255, 107, 157, 0.5), 0 0 20rpx rgba(255, 107, 157, 0.4);
animation: pulse 1.5s ease-in-out infinite; animation: pulse 1.5s ease-in-out infinite;
} }
@keyframes pulse { @keyframes pulse {
0%, 100% { 0%,
100% {
transform: scale(1); transform: scale(1);
box-shadow: 0 4rpx 12rpx rgba(255, 107, 157, 0.5), 0 0 20rpx rgba(255, 107, 157, 0.4);
} }
50% { 50% {
transform: scale(1.1); transform: scale(1.1);
box-shadow: 0 6rpx 16rpx rgba(255, 107, 157, 0.7), 0 0 30rpx rgba(255, 107, 157, 0.6);
} }
} }
.progress-text { .progress-text {
font-size: 56rpx; font-size: 56rpx;
font-weight: bold; font-weight: bold;
color: #FFFFFF; color: #ffffff;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.4), 0 0 20rpx rgba(255, 107, 157, 0.5);
margin-bottom: 10rpx; margin-bottom: 10rpx;
letter-spacing: 2rpx;
} }
.loading-text { .loading-text {
font-size: 36rpx; font-size: 36rpx;
color: #FFFFFF; color: #ffffff;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.4);
letter-spacing: 6rpx; letter-spacing: 6rpx;
font-weight: 500; font-weight: 500;
} }

View File

@ -22,27 +22,38 @@
</view> </view>
</view> </view>
<!-- 图片卡片区域 --> <!-- 光栅卡单张工作台预览陀螺仪 -->
<view class="cards-container" :class="{ 'cards-visible': isGiftOpened }"> <view
<view v-if="isLenticularDisplay"
v-for="(image, index) in generatedImages" class="lenticular-result-wrap"
:key="index" :class="{ 'cards-visible': isGiftOpened }"
>
<view class="lenticular-result-card">
<LenticularCard
class="lenticular-preview"
:layers="lenticularLayers"
:transforms="layerTransforms"
:gyro-source="gyroSourceLabel"
:skip-built-in-touch="true"
tilt-hint-text="晃动查看"
:shimmer-mid-opacity="0.16"
@simulate="simulate"
/>
</view>
</view>
<!-- 镭射 / 星卡多图候选 -->
<view v-else class="cards-container" :class="{ 'cards-visible': isGiftOpened }">
<view
v-for="(image, index) in generatedImages"
:key="index"
class="card-item" class="card-item"
:class="{ 'card-selected': selectedIndex === index }" :class="{ 'card-selected': selectedIndex === index }"
:style="getCardStyle(index)" :style="getCardStyle(index)"
@click="selectCard(index)" @click="selectCard(index)"
> >
<view class="card-frame"> <view class="card-frame">
<!-- TOPFANS Logo标签 --> <image class="card-image" :src="image" mode="aspectFill" />
<!-- <view class="topfans-label">
<text class="topfans-text">TOPFANS</text>
</view> -->
<image
class="card-image"
:src="image"
mode="aspectFill"
/>
</view> </view>
</view> </view>
</view> </view>
@ -64,24 +75,84 @@
</view> </view>
<!-- 底部按钮 --> <!-- 底部按钮 -->
<view class="bottom-action"> <view class="bottom-action" :class="{ 'bottom-action--row': isCraftDetailFlow }">
<view
v-if="isCraftDetailFlow"
class="action-button action-button--secondary"
@tap="handleRegenerate"
>
<text class="button-text">重新生成</text>
</view>
<view class="action-button" @click="selectAsset" @tap="selectAsset"> <view class="action-button" @click="selectAsset" @tap="selectAsset">
<text class="button-text">选择您的藏品</text> <text class="button-text">{{ primaryActionLabel }}</text>
</view> </view>
</view> </view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, computed, onMounted, onUnmounted } from 'vue';
import { onShow, onHide } from '@dcloudio/uni-app';
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js'; import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js';
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js'; import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js';
import LenticularCard from '@/components/lenticular/LenticularCard.vue';
import { useLenticularCraftTiltPreview } from '@/composables/useLenticularCraftTiltPreview.js';
import {
buildLenticularLayersTwo,
LENTICULAR_STUDIO_STORAGE_KEY,
} from '@/utils/castloveMintForm.js';
import {
CASTLOVE_FORM_KEY,
GENERATED_IMAGES_KEY,
GENERATION_RESULT_META_KEY,
completeSelectionAndOpenDetail,
isDetailAfterSelect,
isLenticularKind,
isLaserKind,
STUDIO_LENTICULAR,
} from '@/utils/castloveGenerationFlow.js';
const generatedImages = ref([]); const generatedImages = ref([]);
const selectedIndex = ref(-1); const selectedIndex = ref(-1);
const isUploading = ref(false); const isUploading = ref(false);
const currentOrderId = ref(''); // ID const currentOrderId = ref('');
const isGiftOpened = ref(false); // const isGiftOpened = ref(false);
const craftFormData = ref(null);
const resultMeta = ref(null);
const lenticularLayers = ref([]);
const {
layerTransforms,
simulate,
gyroSourceLabel,
scheduleTiltStart,
stopTiltPreview,
} = useLenticularCraftTiltPreview(lenticularLayers);
const isCraftDetailFlow = computed(() => isDetailAfterSelect(craftFormData.value));
const isLenticularDisplay = computed(
() => resultMeta.value?.displayMode === STUDIO_LENTICULAR || isLenticularKind(craftFormData.value)
);
const isLaserDisplay = computed(
() => resultMeta.value?.displayMode === 'laser' || isLaserKind(craftFormData.value)
);
const primaryActionLabel = computed(() =>
isCraftDetailFlow.value ? '选择作品' : '选择您的藏品'
);
const handleRegenerate = () => {
uni.navigateBack({ delta: 2 });
};
const getSelectedImageUrl = () => {
if (isLenticularDisplay.value) {
const mid = lenticularLayers.value.find((l) => l.id === 'mid');
return mid?.src || craftFormData.value?.lenticularSubjectImage || '';
}
if (selectedIndex.value < 0) return '';
return generatedImages.value[selectedIndex.value] || '';
};
// //
const getStarStyle = (index) => { const getStarStyle = (index) => {
@ -627,7 +698,7 @@ const uploadFileToOss = (tempFilePath, fileName, ossData, resolve, reject, fs) =
// //
const selectAsset = async () => { const selectAsset = async () => {
if (selectedIndex.value === -1) { if (!isLenticularDisplay.value && selectedIndex.value === -1) {
uni.showToast({ uni.showToast({
title: '请先选择一张卡片', title: '请先选择一张卡片',
icon: 'none' icon: 'none'
@ -642,21 +713,40 @@ const selectAsset = async () => {
}); });
return; return;
} }
const formDataStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
const orderValue = formDataStr ? JSON.parse(formDataStr) : craftFormData.value || {};
const selectedImage = getSelectedImageUrl();
if (isDetailAfterSelect(orderValue)) {
isUploading.value = true;
uni.showLoading({ title: '准备中…', mask: true });
try {
await completeSelectionAndOpenDetail({
selectedImage,
selectedIndex: isLenticularDisplay.value ? 0 : selectedIndex.value,
formData: orderValue,
});
} catch (error) {
console.error('[GenerationResult] 打开确认页失败:', error);
uni.showToast({
title: error.message || '操作失败',
icon: 'none',
});
} finally {
isUploading.value = false;
uni.hideLoading();
}
return;
}
try { try {
// base64
const selectedImage = generatedImages.value[selectedIndex.value];
// OSScurrentOrderId // OSScurrentOrderId
const imageUrl = await uploadImageToOss(selectedImage); const imageUrl = await uploadImageToOss(selectedImage);
// //
uni.showLoading({ title: '创建订单中...', mask: true }); uni.showLoading({ title: '创建订单中...', mask: true });
// storage
const formDataStr = uni.getStorageSync('castlove_form_data');
const orderValue = formDataStr ? JSON.parse(formDataStr) : {};
// CreateMintOrderRequestDTO // CreateMintOrderRequestDTO
const orderData = { const orderData = {
order_id: currentOrderId.value, order_id: currentOrderId.value,
@ -676,7 +766,7 @@ const selectAsset = async () => {
if (response.code !== 200) { if (response.code !== 200) {
throw new Error(response.message || '创建订单失败'); throw new Error(response.message || '创建订单失败');
} }
uni.removeStorageSync('castlove_form_data'); uni.removeStorageSync(CASTLOVE_FORM_KEY);
// temp_nft_data // temp_nft_data
const nftData = { const nftData = {
image: imageUrl, image: imageUrl,
@ -705,40 +795,75 @@ const selectAsset = async () => {
} }
}; };
onMounted(() => { const initLenticularPreview = () => {
//
try { try {
const imagesData = uni.getStorageSync('generated_images'); const raw = uni.getStorageSync(LENTICULAR_STUDIO_STORAGE_KEY);
if (imagesData) { if (!raw) return;
generatedImages.value = JSON.parse(imagesData); const p = typeof raw === 'string' ? JSON.parse(raw) : raw;
console.log('[GenerationResult] 接收到生成的图片:', generatedImages.value.length); lenticularLayers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '');
selectedIndex.value = 0;
// scheduleTiltStart();
uni.removeStorageSync('generated_images'); } catch (e) {
console.error('[GenerationResult] lenticular init', e);
// }
setTimeout(() => { };
isGiftOpened.value = true;
}, 800); onMounted(() => {
} else { try {
uni.showToast({ const formDataStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
title: '未找到生成的图片', if (formDataStr) {
icon: 'none' craftFormData.value = JSON.parse(formDataStr);
}); }
setTimeout(() => { const metaStr = uni.getStorageSync(GENERATION_RESULT_META_KEY);
uni.navigateBack(); if (metaStr) {
}, 1500); resultMeta.value = JSON.parse(metaStr);
uni.removeStorageSync(GENERATION_RESULT_META_KEY);
} }
} catch (e) { } catch (e) {
console.error('[GenerationResult] 读取图片数据失败:', e); console.error('[GenerationResult] 读取表单失败:', e);
uni.showToast({
title: '数据错误',
icon: 'none'
});
setTimeout(() => {
uni.navigateBack();
}, 1500);
} }
try {
const imagesData = uni.getStorageSync(GENERATED_IMAGES_KEY);
if (!imagesData) {
uni.showToast({ title: '未找到生成的图片', icon: 'none' });
setTimeout(() => uni.navigateBack(), 1500);
return;
}
const parsed = JSON.parse(imagesData);
uni.removeStorageSync(GENERATED_IMAGES_KEY);
if (isLenticularDisplay.value || (parsed[0] && parsed[0].type === 'lenticular')) {
initLenticularPreview();
} else {
generatedImages.value = parsed;
if (isLaserDisplay.value && generatedImages.value.length > 0) {
selectedIndex.value = 0;
}
}
setTimeout(() => {
isGiftOpened.value = true;
}, 800);
} catch (e) {
console.error('[GenerationResult] 读取图片数据失败:', e);
uni.showToast({ title: '数据错误', icon: 'none' });
setTimeout(() => uni.navigateBack(), 1500);
}
});
onShow(() => {
if (isLenticularDisplay.value && lenticularLayers.value.length) {
scheduleTiltStart();
}
});
onHide(() => {
stopTiltPreview();
});
onUnmounted(() => {
stopTiltPreview();
}); });
</script> </script>
@ -1134,6 +1259,35 @@ onMounted(() => {
} }
} }
.lenticular-result-wrap {
position: absolute;
top: 280rpx;
left: 0;
right: 0;
display: flex;
justify-content: center;
z-index: 8;
opacity: 0;
transform: translateY(40rpx);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.lenticular-result-wrap.cards-visible {
opacity: 1;
transform: translateY(0);
}
.lenticular-result-card {
position: relative;
width: 420rpx;
height: 560rpx;
}
.lenticular-preview {
width: 100%;
height: 100%;
}
.bottom-action { .bottom-action {
position: absolute; position: absolute;
bottom: 80rpx; bottom: 80rpx;
@ -1142,10 +1296,16 @@ onMounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
/* gap: 16rpx; */
z-index: 11; z-index: 11;
} }
.bottom-action--row {
flex-direction: row;
gap: 24rpx;
width: 90%;
justify-content: center;
}
.action-button { .action-button {
padding: 24rpx 80rpx; padding: 24rpx 80rpx;
background: linear-gradient(90deg, #FF6B9D 0%, #FFA8C5 100%); background: linear-gradient(90deg, #FF6B9D 0%, #FFA8C5 100%);
@ -1161,10 +1321,15 @@ onMounted(() => {
} }
.action-button:active { .action-button:active {
transform: scale(1.2); transform: scale(1.05);
opacity: 0.9; opacity: 0.9;
} }
.action-button--secondary {
background: linear-gradient(90deg, #8e9eab 0%, #b8c6db 100%);
box-shadow: 0 8rpx 24rpx rgba(120, 130, 150, 0.45);
}
.button-text { .button-text {
font-size: 32rpx; font-size: 32rpx;
color: #FFFFFF; color: #FFFFFF;

View File

@ -0,0 +1,275 @@
/**
* 铸爱 统一Thinking 选择 详情确认 铸造路由与 Storage
*/
import {
LENTICULAR_STUDIO_STORAGE_KEY,
CASTLOVE_LASER_ENTRY_KEY,
} from '@/utils/castloveMintForm.js'
export const GENERATION_FLOW_KEY = 'generation_flow_payload'
export const GENERATION_REQUEST_KEY = 'generation_request_data'
export const GENERATED_IMAGES_KEY = 'generated_images'
export const GENERATION_RESULT_META_KEY = 'generation_result_meta'
export const CASTLOVE_FORM_KEY = 'castlove_form_data'
export const CRAFT_SELECTED_IMAGE_KEY = 'craft_selected_image'
export const CRAFT_SELECTED_INDEX_KEY = 'craft_selected_index'
export const FLOW_MODE_API = 'api'
export const FLOW_MODE_PREFILLED = 'prefilled'
export const FLOW_MODE_LENTICULAR = 'lenticular'
export const FLOW_MODE_LASER = 'laser'
export const AFTER_SELECT_MINT = 'mint'
export const AFTER_SELECT_DETAIL = 'detail'
export const STUDIO_LENTICULAR = 'lenticular'
export const STUDIO_LASER = 'laser'
const LOADING_URL = '/pages/discover/generation-loading'
const RESULT_URL = '/pages/discover/generation-result'
const ASSET_DETAIL_URL = '/pages/asset-detail/asset-detail'
export function padImagesForSelection(images, minCount = 4) {
if (!Array.isArray(images) || images.length === 0) {
return []
}
if (images.length >= minCount) {
return images.slice(0, minCount)
}
const out = [...images]
while (out.length < minCount) {
out.push(images[out.length % images.length])
}
return out
}
function persistFormData(formData) {
if (formData != null) {
uni.setStorageSync(CASTLOVE_FORM_KEY, JSON.stringify(formData))
}
}
function enrichFormData(formData, { afterSelect, studioKind } = {}) {
const next = { ...(formData || {}) }
if (afterSelect) {
next.generation_after = afterSelect
}
if (studioKind) {
next.studio_kind = studioKind
}
return next
}
export function materializeImageRef(src) {
const s = String(src || '').trim()
if (!s) {
return Promise.resolve({ path: '', base64: '' })
}
if (s.startsWith('data:')) {
return Promise.resolve({ path: '', base64: s })
}
if (s.startsWith('http://') || s.startsWith('https://')) {
return new Promise((resolve, reject) => {
uni.downloadFile({
url: s,
success: (res) => {
if (res.statusCode === 200 && res.tempFilePath) {
resolve({ path: res.tempFilePath, base64: '' })
} else {
reject(new Error('图片下载失败'))
}
},
fail: (err) => reject(err || new Error('图片下载失败')),
})
})
}
return Promise.resolve({ path: s, base64: '' })
}
function navigateToLoading() {
uni.navigateTo({ url: LOADING_URL })
}
export function startAiImageGenerationFlow({
prompt,
formData,
n = 4,
model = 'image-01',
aspectRatio = '16:9',
afterSelect = AFTER_SELECT_MINT,
studioKind = '',
}) {
const requestData = {
prompt,
model,
aspect_ratio: aspectRatio,
subject_reference: [{ type: 'character', image_file: '' }],
n,
}
const merged = enrichFormData(formData, { afterSelect, studioKind })
persistFormData(merged)
uni.setStorageSync(GENERATION_REQUEST_KEY, JSON.stringify(requestData))
uni.setStorageSync(
GENERATION_FLOW_KEY,
JSON.stringify({
mode: FLOW_MODE_API,
craft: merged?.craft_name || merged?.typeName || '',
afterSelect,
studioKind,
})
)
navigateToLoading()
}
/** 光栅 / 镭射:工作台生成预览,不走通用 AI 四图 */
export function startCraftGenerationFlow({ formData, studioKind }) {
const merged = enrichFormData(formData, {
afterSelect: AFTER_SELECT_DETAIL,
studioKind,
})
persistFormData(merged)
uni.removeStorageSync(GENERATION_REQUEST_KEY)
const mode =
studioKind === STUDIO_LENTICULAR ? FLOW_MODE_LENTICULAR : FLOW_MODE_LASER
uni.setStorageSync(
GENERATION_FLOW_KEY,
JSON.stringify({
mode,
studioKind,
afterSelect: AFTER_SELECT_DETAIL,
craft: merged?.craft_name || merged?.typeName || '',
minDurationMs: 1600,
})
)
navigateToLoading()
}
export function startPrefilledSelectionFlow({
images,
formData,
minDurationMs = 1400,
padToFour = true,
afterSelect = AFTER_SELECT_MINT,
studioKind = '',
}) {
const list = padToFour ? padImagesForSelection(images) : images
if (!list.length) {
throw new Error('startPrefilledSelectionFlow: images 不能为空')
}
const merged = enrichFormData(formData, { afterSelect, studioKind })
persistFormData(merged)
uni.removeStorageSync(GENERATION_REQUEST_KEY)
uni.setStorageSync(
GENERATION_FLOW_KEY,
JSON.stringify({
mode: FLOW_MODE_PREFILLED,
images: list,
minDurationMs,
craft: merged?.craft_name || merged?.typeName || '',
afterSelect,
studioKind,
})
)
navigateToLoading()
}
export function persistLenticularPreviewMeta(formData) {
uni.setStorageSync(
LENTICULAR_STUDIO_STORAGE_KEY,
JSON.stringify({
bgPath: formData.lenticularBgImage || '',
subjectPath: formData.lenticularSubjectImage || '',
bgBase64: formData.lenticularBgBase64 || '',
subjectBase64: formData.lenticularSubjectBase64 || '',
nftInfo: formData.info || formData.nftInfo || '',
materialTypeIndex: formData.materialTypeIndex ?? 0,
aiDescription: formData.aiDescription || '',
})
)
uni.setStorageSync(
GENERATION_RESULT_META_KEY,
JSON.stringify({ displayMode: STUDIO_LENTICULAR, imageCount: 1 })
)
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify([{ type: 'lenticular' }]))
}
export function persistLaserPreviewImages(paths) {
uni.setStorageSync(
GENERATION_RESULT_META_KEY,
JSON.stringify({ displayMode: STUDIO_LASER, imageCount: paths.length })
)
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify(paths))
}
export async function completeSelectionAndOpenDetail({
selectedImage,
selectedIndex,
formData,
}) {
const img = await materializeImageRef(selectedImage)
const storedImage = img.path || img.base64 || selectedImage
uni.setStorageSync(CRAFT_SELECTED_IMAGE_KEY, storedImage)
uni.setStorageSync(CRAFT_SELECTED_INDEX_KEY, String(selectedIndex ?? 0))
if (formData?.studio_kind === STUDIO_LENTICULAR) {
const subjectRef = storedImage
uni.setStorageSync(
LENTICULAR_STUDIO_STORAGE_KEY,
JSON.stringify({
bgPath: formData.lenticularBgImage || '',
subjectPath: subjectRef,
bgBase64: formData.lenticularBgBase64 || '',
subjectBase64: img.base64 || formData.lenticularSubjectBase64 || '',
nftInfo: formData.info || formData.nftInfo || '',
materialTypeIndex: formData.materialTypeIndex ?? 0,
aiDescription: formData.aiDescription || '',
})
)
} else if (formData?.studio_kind === STUDIO_LASER) {
uni.setStorageSync(
CASTLOVE_LASER_ENTRY_KEY,
JSON.stringify({
nftInfo: formData.info || formData.nftInfo || '',
materialTypes: formData.materialTypes || [],
materialTypeIndex: formData.materialTypeIndex ?? 0,
pageName: formData.craft_name || formData.typeName || '',
uploadedImage: storedImage,
uploadedImageBase64: img.base64 || formData.uploadedImageBase64 || '',
})
)
}
const kind = formData?.studio_kind || ''
uni.navigateTo({
url: `${ASSET_DETAIL_URL}?from=craft_confirm&studio_kind=${encodeURIComponent(kind)}`,
})
}
export function isDetailAfterSelect(formData) {
return formData?.generation_after === AFTER_SELECT_DETAIL
}
export function isLenticularKind(formData) {
return formData?.studio_kind === STUDIO_LENTICULAR
}
export function isLaserKind(formData) {
return formData?.studio_kind === STUDIO_LASER
}
export function consumeGenerationFlowPayload() {
try {
const raw = uni.getStorageSync(GENERATION_FLOW_KEY)
uni.removeStorageSync(GENERATION_FLOW_KEY)
if (!raw) {
return null
}
return JSON.parse(raw)
} catch (e) {
console.error('[castloveGenerationFlow] consume payload', e)
return null
}
}
export { RESULT_URL }

View File

@ -0,0 +1,125 @@
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js'
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js'
import { buildCastloveFormSnapshot } from '@/utils/castloveMintForm.js'
function uploadFileToOss(tempFilePath, ossData) {
return new Promise((resolve, reject) => {
const fileName = `${Date.now()}.jpg`
uni.uploadFile({
url: ossData.host,
filePath: tempFilePath,
name: 'file',
formData: {
key: ossData.dir + fileName,
policy: ossData.policy,
success_action_status: '200',
'x-oss-credential': ossData.x_oss_credential,
'x-oss-date': ossData.x_oss_date,
'x-oss-security-token': ossData.security_token,
'x-oss-signature': ossData.signature,
'x-oss-signature-version': ossData.x_oss_signature_version,
},
success: (res) => {
if (res.statusCode === 200 || res.statusCode === 204) {
resolve(`${ossData.host}/${ossData.dir}${fileName}`)
} else {
reject(new Error(`上传失败 ${res.statusCode}`))
}
},
fail: reject,
})
})
}
/**
* 铸爱确认页上传选中图并创建铸造订单
* @param {{ imagePath: string, formData: object }} opts
*/
export async function submitCraftMintFromPath({ imagePath, formData }) {
const path = String(imagePath || '').trim()
if (!path) {
throw new Error('缺少作品图片')
}
const signRes = await getOssSignatureApi('asset')
if (!signRes || signRes.code !== 200 || !signRes.data) {
throw new Error(signRes?.message || '获取签名失败')
}
const ossData = signRes.data
const orderId = ossData.order_id || ''
let imageUrl = path
if (!path.startsWith('http')) {
// #ifdef H5
if (path.startsWith('data:')) {
const blob = await fetch(path).then((r) => r.blob())
const fd = new FormData()
const fileName = `${Date.now()}.jpg`
fd.append('key', ossData.dir + fileName)
fd.append('policy', ossData.policy)
fd.append('success_action_status', '200')
fd.append('x-oss-credential', ossData.x_oss_credential)
fd.append('x-oss-date', ossData.x_oss_date)
fd.append('x-oss-security-token', ossData.security_token)
fd.append('x-oss-signature', ossData.signature)
fd.append('x-oss-signature-version', ossData.x_oss_signature_version)
fd.append('file', blob, fileName)
const res = await fetch(resolveH5OssPostUrl(ossData.host), { method: 'POST', body: fd })
if (!res.ok && res.status !== 204) {
throw new Error('上传失败')
}
imageUrl = `${ossData.host}/${ossData.dir}${fileName}`
} else {
imageUrl = await uploadFileToOss(path, ossData)
}
// #endif
// #ifndef H5
imageUrl = await uploadFileToOss(path, ossData)
// #endif
}
const snap = buildCastloveFormSnapshot({
nftInfo: formData.info || formData.nftInfo || '',
materialTypes: formData.materialTypes || ['粉丝自制'],
materialTypeIndex: formData.materialTypeIndex ?? 0,
pageName: formData.craft_name || formData.typeName || '',
uploadedImage: path,
uploadedImageBase64: formData.imageBase64 || '',
})
const orderData = {
order_id: orderId,
name: snap.name,
material_url: imageUrl,
description: snap.description || '',
grade: snap.grade ?? 0,
tags: Array.isArray(snap.tags) ? snap.tags : [],
material_type: snap.material_type,
info: snap.info || '',
}
const response = await createMintOrderApi(orderData)
if (!response || response.code !== 200) {
throw new Error(response?.message || '创建订单失败')
}
const assetId =
response.data?.asset?.asset_id ||
response.data?.asset_id ||
response.data?.assetId ||
''
const nftData = {
image: imageUrl,
name: snap.name,
description: snap.description || '',
material_type: snap.material_type,
tags: snap.tags,
order_id: orderId,
asset_id: assetId,
info: snap.info,
event: snap.info,
}
uni.setStorageSync('temp_nft_data', JSON.stringify(nftData))
uni.removeStorageSync('castlove_form_data')
return { imageUrl, orderId }
}

View File

@ -0,0 +1,579 @@
/**
* 镭射卡批量预览 合成逻辑对齐 laser-card-studio 导出链人物清晰 + 强镭射叠层
*/
const EXPORT_W = 450
const EXPORT_H = 600
const PRESET_VARIANTS = [
{
style: 'dream',
angle: 36,
beam: 'prism',
backdrop: 'liquidBlue',
laserStrength: 78,
diffractionDensity: 68,
marbleFlow: 52,
beamIntensity: 92,
grainDensity: 82,
},
{
style: 'classic',
angle: 24,
beam: 'aurora',
backdrop: 'liquidLavender',
laserStrength: 85,
diffractionDensity: 74,
marbleFlow: 38,
beamIntensity: 96,
grainDensity: 76,
},
{
style: 'holoFull',
angle: 58,
beam: 'neon',
backdrop: 'liquidPearl',
laserStrength: 90,
diffractionDensity: 80,
marbleFlow: 58,
beamIntensity: 100,
grainDensity: 88,
},
{
style: 'ice',
angle: 46,
beam: 'white',
backdrop: 'liquidBlue',
laserStrength: 72,
diffractionDensity: 60,
marbleFlow: 44,
beamIntensity: 88,
grainDensity: 74,
},
{
style: 'sunset',
angle: 30,
beam: 'sunset',
backdrop: 'liquidLavender',
laserStrength: 82,
diffractionDensity: 66,
marbleFlow: 50,
beamIntensity: 94,
grainDensity: 84,
},
]
const BACKDROP_MAP = {
liquidBlue: '/static/castlove/laser-bg/laser-bg-1.png',
liquidLavender: '/static/castlove/laser-bg/laser-bg-2.png',
liquidPearl: '/static/castlove/laser-bg/laser-bg-3.png',
}
const BLOB_PALETTES = {
dream: [
{ x: 0.1, y: 0.14, c0: 'rgba(186,104,255,0.55)', c1: 'rgba(186,104,255,0)' },
{ x: 0.9, y: 0.18, c0: 'rgba(255,105,180,0.48)', c1: 'rgba(255,105,180,0)' },
{ x: 0.14, y: 0.86, c0: 'rgba(0,229,255,0.42)', c1: 'rgba(0,229,255,0)' },
{ x: 0.82, y: 0.78, c0: 'rgba(255,224,140,0.38)', c1: 'rgba(255,224,140,0)' },
],
classic: [
{ x: 0.48, y: 0.1, c0: 'rgba(255,80,200,0.48)', c1: 'rgba(255,80,200,0)' },
{ x: 0.08, y: 0.58, c0: 'rgba(0,200,255,0.46)', c1: 'rgba(0,200,255,0)' },
{ x: 0.92, y: 0.76, c0: 'rgba(180,255,120,0.4)', c1: 'rgba(180,255,120,0)' },
],
ice: [
{ x: 0.18, y: 0.22, c0: 'rgba(100,180,255,0.52)', c1: 'rgba(100,180,255,0)' },
{ x: 0.88, y: 0.32, c0: 'rgba(160,140,255,0.44)', c1: 'rgba(160,140,255,0)' },
{ x: 0.5, y: 0.9, c0: 'rgba(200,240,255,0.4)', c1: 'rgba(200,240,255,0)' },
],
sunset: [
{ x: 0.12, y: 0.28, c0: 'rgba(255,150,120,0.5)', c1: 'rgba(255,150,120,0)' },
{ x: 0.9, y: 0.24, c0: 'rgba(255,200,140,0.46)', c1: 'rgba(255,200,140,0)' },
{ x: 0.52, y: 0.88, c0: 'rgba(255,100,160,0.42)', c1: 'rgba(255,100,160,0)' },
],
holoFull: [
{ x: 0.12, y: 0.16, c0: 'rgba(255,80,180,0.5)', c1: 'rgba(255,80,180,0)' },
{ x: 0.9, y: 0.22, c0: 'rgba(255,180,80,0.46)', c1: 'rgba(255,180,80,0)' },
{ x: 0.1, y: 0.78, c0: 'rgba(0,220,255,0.48)', c1: 'rgba(0,220,255,0)' },
{ x: 0.88, y: 0.82, c0: 'rgba(160,100,255,0.5)', c1: 'rgba(160,100,255,0)' },
{ x: 0.5, y: 0.5, c0: 'rgba(255,255,255,0.2)', c1: 'rgba(255,255,255,0)' },
],
}
const MARBLE_PALETTES = {
dream: [
{ x: 0.16, y: 0.26, rgb: [200, 170, 255] },
{ x: 0.84, y: 0.22, rgb: [255, 150, 210] },
{ x: 0.48, y: 0.88, rgb: [120, 210, 255] },
],
classic: [
{ x: 0.14, y: 0.2, rgb: [255, 100, 200] },
{ x: 0.86, y: 0.26, rgb: [200, 100, 255] },
{ x: 0.5, y: 0.86, rgb: [80, 220, 255] },
],
ice: [
{ x: 0.18, y: 0.24, rgb: [170, 210, 255] },
{ x: 0.82, y: 0.2, rgb: [110, 160, 255] },
{ x: 0.5, y: 0.88, rgb: [210, 235, 255] },
],
sunset: [
{ x: 0.14, y: 0.28, rgb: [255, 190, 160] },
{ x: 0.86, y: 0.24, rgb: [255, 130, 150] },
{ x: 0.5, y: 0.88, rgb: [255, 220, 180] },
],
holoFull: [
{ x: 0.12, y: 0.18, rgb: [255, 100, 200] },
{ x: 0.88, y: 0.2, rgb: [255, 180, 100] },
{ x: 0.1, y: 0.78, rgb: [80, 230, 255] },
{ x: 0.88, y: 0.8, rgb: [160, 100, 255] },
],
}
const backdropLayoutCache = {}
function getImageInfo(src) {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src,
success: resolve,
fail: (e) => reject(e || new Error('读取图片失败')),
})
})
}
function computeCover(iw, ih, cw, ch) {
const w = Number(iw) || 1
const h = Number(ih) || 1
const scale = Math.max(cw / w, ch / h)
const dw = w * scale
const dh = h * scale
return { dx: (cw - dw) / 2, dy: (ch - dh) / 2, dw, dh }
}
async function getBackdropLayout(key, cw, ch) {
const cacheKey = `${key}_${cw}_${ch}`
if (backdropLayoutCache[cacheKey]) {
return backdropLayoutCache[cacheKey]
}
const bd = BACKDROP_MAP[key] || BACKDROP_MAP.liquidBlue
const info = await getImageInfo(bd)
const lay = computeCover(info.width, info.height, cw, ch)
backdropLayoutCache[cacheKey] = { path: bd, lay }
return backdropLayoutCache[cacheKey]
}
function canvasDraw(ctx) {
return new Promise((resolve) => {
ctx.draw(false, () => setTimeout(resolve, 120))
})
}
function canvasToTemp(canvasId, w, h) {
return new Promise((resolve, reject) => {
uni.canvasToTempFilePath({
canvasId,
width: w,
height: h,
destWidth: w * 2,
destHeight: h * 2,
fileType: 'jpg',
quality: 0.96,
success: (res) => resolve(res.tempFilePath),
fail: (e) => reject(e || new Error('导出失败')),
})
})
}
function clipRoundRect(ctx, cw, ch) {
const r = Math.min(32, cw * 0.065, ch * 0.048)
ctx.beginPath()
ctx.moveTo(r, 0)
ctx.lineTo(cw - r, 0)
ctx.arc(cw - r, r, r, -Math.PI / 2, 0, false)
ctx.lineTo(cw, ch - r)
ctx.arc(cw - r, ch - r, r, 0, Math.PI / 2, false)
ctx.lineTo(r, ch)
ctx.arc(r, ch - r, r, Math.PI / 2, Math.PI, false)
ctx.lineTo(0, r)
ctx.arc(r, r, r, Math.PI, Math.PI * 1.5, false)
ctx.closePath()
ctx.clip()
}
function setBlend(ctx, mode) {
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation(mode)
}
}
/**
* uni-app 旧版 canvasApp/小程序 createRadialGradient需兼容 createCircularGradient 或线性渐变
*/
function createRadialGradientCompat(ctx, cx, cy, innerR, outerR) {
const ox = Number(cx) || 0
const oy = Number(cy) || 0
const r0 = Math.max(0, Number(innerR) || 0)
const r1 = Math.max(r0 + 0.5, Number(outerR) || 1)
if (typeof ctx.createRadialGradient === 'function') {
return ctx.createRadialGradient(ox, oy, r0, ox, oy, r1)
}
if (typeof ctx.createCircularGradient === 'function') {
return ctx.createCircularGradient(ox, oy, r1)
}
return ctx.createLinearGradient(ox - r1, oy - r1, ox + r1, oy + r1)
}
function hslToRgba(h, s, l, a) {
const hh = ((Number(h) % 360) + 360) % 360
const ss = Math.max(0, Math.min(100, s)) / 100
const ll = Math.max(0, Math.min(100, l)) / 100
const c = (1 - Math.abs(2 * ll - 1)) * ss
const x = c * (1 - Math.abs(((hh / 60) % 2) - 1))
const m = ll - c / 2
let rp = 0
let gp = 0
let bp = 0
if (hh < 60) {
rp = c
gp = x
} else if (hh < 120) {
rp = x
gp = c
} else if (hh < 180) {
gp = c
bp = x
} else if (hh < 240) {
gp = x
bp = c
} else if (hh < 300) {
rp = x
bp = c
} else {
rp = c
bp = x
}
const r = Math.round(255 * (rp + m))
const g = Math.round(255 * (gp + m))
const b = Math.round(255 * (bp + m))
const aa = Math.max(0, Math.min(1, a))
return `rgba(${r},${g},${b},${aa.toFixed(3)})`
}
function linearGradientThroughPivot(ctx, cw, ch, pivotPctX, pivotPctY, deg, stops) {
const rad = (deg * Math.PI) / 180
const len = Math.sqrt(cw * cw + ch * ch) / 2
const px = (Math.max(0, Math.min(100, pivotPctX)) / 100) * cw
const py = (Math.max(0, Math.min(100, pivotPctY)) / 100) * ch
const x0 = px - Math.cos(rad) * len
const y0 = py - Math.sin(rad) * len
const x1 = px + Math.cos(rad) * len
const y1 = py + Math.sin(rad) * len
const g = ctx.createLinearGradient(x0, y0, x1, y1)
stops.forEach(([t, c]) => g.addColorStop(t, c))
return g
}
function drawSolidLaserBackdrop(ctx, cw, ch, preset) {
const strength = preset.laserStrength / 100
const vivid = 1.1
const list = BLOB_PALETTES[preset.style] || BLOB_PALETTES.dream
const R = Math.max(cw, ch)
setBlend(ctx, 'soft-light')
list.forEach((b) => {
try {
const bx = b.x * cw
const by = b.y * ch
const br = R * 0.62
const g = createRadialGradientCompat(ctx, bx, by, 0, br)
g.addColorStop(0, b.c0)
g.addColorStop(1, b.c1)
ctx.setGlobalAlpha(0.82 * strength * vivid * 0.42)
ctx.setFillStyle(g)
ctx.fillRect(0, 0, cw, ch)
} catch (e) {
ctx.setGlobalAlpha(0.35 * strength)
ctx.setFillStyle(b.c0)
ctx.fillRect(0, 0, cw, ch)
}
})
ctx.setGlobalAlpha(1)
setBlend(ctx, 'source-over')
}
function drawHslRainbowBase(ctx, cw, ch, preset, timeMs) {
const strength = preset.laserStrength / 100
const vivid = 1.1
const hueShift = ((timeMs * 0.055) % 360 + 360) % 360
const g = ctx.createLinearGradient(0, 0, cw * 1.05, ch * 0.92)
const stops = 12
for (let i = 0; i <= stops; i++) {
const p = i / stops
const h = (hueShift + p * 360) % 360
const alpha = (0.052 + strength * 0.1) * vivid * (0.55 + p * 0.22) * 0.65
g.addColorStop(p, hslToRgba(h, 88, 54, alpha))
}
setBlend(ctx, 'soft-light')
ctx.setGlobalAlpha(1)
ctx.setFillStyle(g)
ctx.fillRect(0, 0, cw, ch)
setBlend(ctx, 'source-over')
}
function drawMarbleFlow(ctx, cw, ch, preset) {
const mf = preset.marbleFlow / 100
if (mf < 0.04) return
const strength = preset.laserStrength / 100
const pts = MARBLE_PALETTES[preset.style] || MARBLE_PALETTES.dream
const phase = (preset.angle * Math.PI) / 180
const Rmax = Math.max(cw, ch) * 0.48
const baseA = (0.1 + mf * 0.38) * strength * 0.85
ctx.save()
setBlend(ctx, 'soft-light')
pts.forEach((p, i) => {
const gx = p.x * cw + Math.cos(phase * 0.35) * 10 * (i % 2)
const gy = p.y * ch + Math.sin(phase * 0.3) * 8 * ((i + 1) % 2)
const rad = Rmax * (0.28 + (i % 3) * 0.08)
const g = createRadialGradientCompat(ctx, gx, gy, 0, rad)
const [r, gg, b] = p.rgb
const a0 = Math.min(0.75, baseA * 0.95)
g.addColorStop(0, `rgba(${r},${gg},${b},${a0.toFixed(3)})`)
g.addColorStop(0.55, `rgba(${r},${gg},${b},${(baseA * 0.5).toFixed(3)})`)
g.addColorStop(1, `rgba(${r},${gg},${b},0)`)
ctx.setGlobalAlpha(1)
ctx.setFillStyle(g)
ctx.fillRect(0, 0, cw, ch)
})
ctx.restore()
setBlend(ctx, 'source-over')
}
function drawDiffractionStripes(ctx, cw, ch, preset) {
const density = preset.diffractionDensity / 100
if (density < 0.02) return
const strength = preset.laserStrength / 100
const deg = preset.angle + 90
const rad = (deg * Math.PI) / 180
const stripeW = Math.max(1.1, (3.2 - density * 2.4) * 0.42)
const span = Math.sqrt(cw * cw + ch * ch) * 1.2
const baseA = (0.08 + strength * 0.12) * 0.75
const hiA = (0.1 + strength * 0.14) * 0.75
const prefix = [
'rgba(255,60,180,',
'rgba(255,170,60,',
'rgba(180,255,80,',
'rgba(0,220,255,',
'rgba(120,90,255,',
'rgba(255,80,200,',
]
const alphas = [baseA, baseA, hiA, hiA, baseA, baseA * 0.85]
ctx.save()
ctx.translate(cw / 2, ch / 2)
ctx.rotate(rad)
setBlend(ctx, 'overlay')
ctx.setGlobalAlpha(Math.min(0.42, (0.28 + strength * 0.55 + density * 0.2) * 0.52))
const count = Math.ceil((span * 2) / stripeW)
for (let i = -count; i <= count; i++) {
const pos = i * stripeW
const ci = ((i % 6) + 6) % 6
ctx.setFillStyle(prefix[ci] + alphas[ci].toFixed(3) + ')')
ctx.fillRect(pos - span, -span, stripeW * 0.92, span * 2)
}
ctx.restore()
ctx.setGlobalAlpha(1)
setBlend(ctx, 'source-over')
}
function drawOilSweep(ctx, cw, ch, preset, timeMs) {
const strength = preset.laserStrength / 100
const bi = preset.beamIntensity / 100
const pvx = 0.5 * cw
const pvy = 0.48 * ch
const deg = preset.angle
const rad = (deg * Math.PI) / 180
const perp = rad + Math.PI / 2
const len = Math.sqrt(cw * cw + ch * ch) * 0.55
const phase = ((timeMs % 8000) / 8000 - 0.5) * len * 0.55 * 2
const cx = pvx + Math.cos(perp) * phase
const cy = pvy + Math.sin(perp) * phase
const x0 = cx - Math.cos(rad) * len
const y0 = cy - Math.sin(rad) * len
const x1 = cx + Math.cos(rad) * len
const y1 = cy + Math.sin(rad) * len
const g = ctx.createLinearGradient(x0, y0, x1, y1)
const peak = (0.2 + strength * 0.16 + bi * 0.12) * 1.1
const be = preset.beam
let cHi = `rgba(255,255,255,${Math.min(0.9, peak).toFixed(3)})`
let cMid = `rgba(220,240,255,${Math.min(0.6, peak * 0.72).toFixed(3)})`
if (be === 'prism') {
cHi = `rgba(255,240,200,${Math.min(0.88, peak).toFixed(3)})`
cMid = `rgba(200,80,255,${Math.min(0.55, peak * 0.65).toFixed(3)})`
} else if (be === 'aurora') {
cHi = `rgba(200,255,250,${Math.min(0.85, peak).toFixed(3)})`
cMid = `rgba(140,80,255,${Math.min(0.52, peak * 0.62).toFixed(3)})`
} else if (be === 'sunset') {
cHi = `rgba(255,230,180,${Math.min(0.88, peak).toFixed(3)})`
cMid = `rgba(255,120,120,${Math.min(0.54, peak * 0.64).toFixed(3)})`
} else if (be === 'neon') {
cHi = `rgba(0,255,255,${Math.min(0.82, peak).toFixed(3)})`
cMid = `rgba(255,80,200,${Math.min(0.52, peak * 0.62).toFixed(3)})`
}
g.addColorStop(0, 'rgba(255,255,255,0)')
g.addColorStop(0.38, 'rgba(255,255,255,0)')
g.addColorStop(0.48, cMid)
g.addColorStop(0.5, cHi)
g.addColorStop(0.52, cMid)
g.addColorStop(0.62, 'rgba(255,255,255,0)')
g.addColorStop(1, 'rgba(255,255,255,0)')
setBlend(ctx, 'soft-light')
ctx.setGlobalAlpha((0.42 + bi * 0.32) * 0.62)
ctx.setFillStyle(g)
ctx.fillRect(0, 0, cw, ch)
ctx.setGlobalAlpha(1)
setBlend(ctx, 'source-over')
}
function drawHueFlowBeams(ctx, cw, ch, preset, timeMs) {
const strength = preset.laserStrength / 100
const flow = ((timeMs * 0.00012) % 1) * 360
const layers = [
{ off: 0, span: 260 },
{ off: 58, span: 200 },
{ off: -44, span: 220 },
]
ctx.save()
setBlend(ctx, 'soft-light')
layers.forEach((layer, idx) => {
const deg = preset.angle + layer.off + Math.sin((timeMs + idx * 900) * 0.0017) * 14
const h0 = (flow + idx * 72) % 360
const stops = []
for (let i = 0; i <= 7; i++) {
const u = i / 7
const h = (h0 + u * layer.span) % 360
const a = (0.03 + strength * 0.065) * (0.5 + 0.5 * Math.sin(u * Math.PI)) * 0.7
stops.push([u, hslToRgba(h, 90, 58, a)])
}
const g = linearGradientThroughPivot(ctx, cw, ch, 50, 48, deg, stops)
ctx.setGlobalAlpha(0.28 + idx * 0.04)
ctx.setFillStyle(g)
ctx.fillRect(0, 0, cw, ch)
})
ctx.restore()
ctx.setGlobalAlpha(1)
setBlend(ctx, 'source-over')
}
function drawDensePixelNoise(ctx, cw, ch, preset, seed) {
const strength = preset.laserStrength / 100
const grain = preset.grainDensity / 100
const n = Math.floor(1800 + grain * 3500 + strength * 1500)
let s = seed
const rnd = () => {
s = (s * 9301 + 49297) % 233280
return s / 233280
}
ctx.save()
setBlend(ctx, 'overlay')
ctx.setGlobalAlpha(0.5)
for (let k = 0; k < n; k++) {
const x = Math.floor(rnd() * cw)
const y = Math.floor(rnd() * ch)
const lit = rnd() > 0.5
const v = lit ? 255 : 198 + Math.floor(rnd() * 40)
const a = 0.028 + rnd() * 0.08
ctx.setFillStyle(`rgba(${v},${v},${Math.min(255, v + 8)},${a.toFixed(3)})`)
ctx.fillRect(x, y, 1, 1)
}
ctx.restore()
ctx.setGlobalAlpha(1)
setBlend(ctx, 'source-over')
}
function drawFilmGrain(ctx, cw, ch, seed) {
let s = seed + 17
const rnd = () => {
s = (s * 1103515245 + 12345) & 0x7fffffff
return s / 0x7fffffff
}
for (let i = 0; i < 90; i++) {
const x = rnd() * cw
const y = rnd() * ch
ctx.setGlobalAlpha(0.08 + rnd() * 0.12)
ctx.setFillStyle('#ffffff')
ctx.beginPath()
ctx.arc(x, y, rnd() * 1.5 + 0.3, 0, Math.PI * 2)
ctx.fill()
}
ctx.setGlobalAlpha(1)
}
async function drawBackdropImage(ctx, bdPath, lay) {
try {
ctx.drawImage(bdPath, lay.dx, lay.dy, lay.dw, lay.dh)
} catch (e) {
/* noop */
}
}
async function drawLaserVariantComposite(ctx, cw, ch, imagePath, layout, preset, variantIndex) {
const timeMs = Date.now() + variantIndex * 1379
const { path: bdPath, lay: bdLay } = await getBackdropLayout(preset.backdrop, cw, ch)
ctx.setFillStyle('#141018')
ctx.fillRect(0, 0, cw, ch)
ctx.save()
clipRoundRect(ctx, cw, ch)
await drawBackdropImage(ctx, bdPath, bdLay)
try {
ctx.drawImage(imagePath, layout.dx, layout.dy, layout.dw, layout.dh)
} catch (e) {
/* noop */
}
ctx.save()
setBlend(ctx, 'overlay')
ctx.setGlobalAlpha(0.48)
await drawBackdropImage(ctx, bdPath, bdLay)
ctx.restore()
ctx.save()
setBlend(ctx, 'screen')
ctx.setGlobalAlpha(0.28)
await drawBackdropImage(ctx, bdPath, bdLay)
ctx.restore()
drawSolidLaserBackdrop(ctx, cw, ch, preset)
drawHslRainbowBase(ctx, cw, ch, preset, timeMs)
drawMarbleFlow(ctx, cw, ch, preset)
drawHueFlowBeams(ctx, cw, ch, preset, timeMs)
drawDiffractionStripes(ctx, cw, ch, preset)
drawOilSweep(ctx, cw, ch, preset, timeMs)
drawDensePixelNoise(ctx, cw, ch, preset, variantIndex * 7919 + 42)
drawFilmGrain(ctx, cw, ch, variantIndex * 313)
ctx.setGlobalAlpha(1)
setBlend(ctx, 'source-over')
ctx.restore()
}
export async function generateLaserVariantBatch(imagePath, canvasId = 'laserBatchCanvas') {
const src = String(imagePath || '').trim()
if (!src) {
throw new Error('缺少上传图片')
}
const info = await getImageInfo(src)
const layout = computeCover(info.width, info.height, EXPORT_W, EXPORT_H)
const ctx = uni.createCanvasContext(canvasId)
const paths = []
for (let i = 0; i < PRESET_VARIANTS.length; i++) {
await drawLaserVariantComposite(ctx, EXPORT_W, EXPORT_H, src, layout, PRESET_VARIANTS[i], i)
await canvasDraw(ctx)
paths.push(await canvasToTemp(canvasId, EXPORT_W, EXPORT_H))
}
return paths
}
export const LASER_BATCH_CANVAS_SIZE = { w: EXPORT_W, h: EXPORT_H }