feat:新增光栅卡与镭射卡与新版UI的对接
This commit is contained in:
parent
15834c6719
commit
39209849cc
@ -70,25 +70,11 @@ const pageProxy = getCurrentInstance()?.proxy
|
||||
|
||||
const MOTION_HIDE_PX = 0.45
|
||||
|
||||
/** 仅在画面因倾斜产生可见位移后隐藏提示(非陀螺仪连接即隐藏) */
|
||||
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)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.gyroSource,
|
||||
() => {
|
||||
if (shouldHideTiltHint()) showHint.value = false
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.transforms,
|
||||
() => {
|
||||
|
||||
163
frontend/composables/useLenticularCraftTiltPreview.js
Normal file
163
frontend/composables/useLenticularCraftTiltPreview.js
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -10,8 +10,104 @@
|
||||
</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"></view>
|
||||
@ -27,7 +123,7 @@
|
||||
</view>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<view v-else-if="loadError" class="error-wrapper">
|
||||
<view v-else-if="loadError && !craftConfirmMode" class="error-wrapper">
|
||||
<text class="error-text">{{ loadError }}</text>
|
||||
<button class="retry-btn" @tap="loadData">重试</button>
|
||||
</view>
|
||||
@ -189,15 +285,62 @@
|
||||
|
||||
<script setup>
|
||||
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 { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js';
|
||||
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 orderIdParam = 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);
|
||||
@ -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 = () => {
|
||||
if (fromParam.value === 'castlove') {
|
||||
@ -428,26 +619,45 @@ const handleBack = () => {
|
||||
};
|
||||
|
||||
onLoad((options) => {
|
||||
console.log('onLoad 触发,参数:', options);
|
||||
assetIdParam.value = options?.asset_id || '';
|
||||
orderIdParam.value = options?.order_id || '';
|
||||
fromParam.value = options?.from || '';
|
||||
studioKindParam.value = options?.studio_kind || '';
|
||||
craftConfirmMode.value = fromParam.value === 'craft_confirm';
|
||||
if (craftConfirmMode.value) {
|
||||
loadCraftConfirm();
|
||||
}
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
console.log('onShow 触发');
|
||||
if (craftConfirmMode.value) {
|
||||
if (isCraftLenticular.value && lenticularLayers.value.length) {
|
||||
scheduleTiltStart();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const currentKey = `${assetIdParam.value}_${orderIdParam.value}`;
|
||||
console.log('当前key:', currentKey, '上次key:', lastLoadKey.value);
|
||||
|
||||
// 如果参数变化了,或者是首次加载,则重新加载数据
|
||||
if ((assetIdParam.value || orderIdParam.value) && currentKey !== lastLoadKey.value) {
|
||||
const hasTarget = !!(assetIdParam.value || orderIdParam.value);
|
||||
if (!hasTarget) {
|
||||
loading.value = false;
|
||||
loadError.value = '藏品信息不完整';
|
||||
return;
|
||||
}
|
||||
if (currentKey !== lastLoadKey.value) {
|
||||
lastLoadKey.value = currentKey;
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
if (craftConfirmMode.value && isCraftLenticular.value) {
|
||||
stopTiltPreview();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
stopTiltPreview();
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -1039,4 +1249,71 @@ onUnmounted(() => {
|
||||
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>
|
||||
|
||||
@ -159,7 +159,7 @@
|
||||
:class="isLaserCardCraft ? 'btn-confirm-laser' : 'btn-skip'"
|
||||
@click="handleSingleCraftLaserEntry"
|
||||
>
|
||||
{{ isLaserCardCraft ? '确认' : '生成' }}
|
||||
生成
|
||||
</button>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@ -196,9 +196,13 @@ import {
|
||||
buildCastloveFormSnapshot,
|
||||
CRAFT_LENTICULAR_CN,
|
||||
CRAFT_LASER_CARD_CN,
|
||||
CASTLOVE_LASER_ENTRY_KEY,
|
||||
LENTICULAR_STUDIO_STORAGE_KEY,
|
||||
} from '@/utils/castloveMintForm.js';
|
||||
import {
|
||||
startAiImageGenerationFlow,
|
||||
startCraftGenerationFlow,
|
||||
STUDIO_LENTICULAR,
|
||||
STUDIO_LASER,
|
||||
} from '@/utils/castloveGenerationFlow.js';
|
||||
|
||||
// 获取页面参数(H5 必须用 onLoad 读 query;仅靠 onMounted + getCurrentPages().options 常为 undefined)
|
||||
const pageType = ref('');
|
||||
@ -677,100 +681,57 @@ const handleBack = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 光栅卡:双图校验后进入工作室预览 */
|
||||
const handleLenticularGenerate = () => {
|
||||
if (!uploadedBg.value || !uploadedSubject.value) {
|
||||
uni.showToast({ title: '请上传背景图与主体图', icon: 'none' });
|
||||
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,
|
||||
/** 光栅 / 镭射:上传 → Loading → 选图 → 工作室 */
|
||||
const buildCraftFormData = () => {
|
||||
const snap = isLenticularCraft.value
|
||||
? buildCastloveFormSnapshot({
|
||||
nftInfo: nftInfo.value,
|
||||
materialTypes,
|
||||
materialTypeIndex: materialTypeIndex.value,
|
||||
aiDescription: aiDescription.value,
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[CreatePage] lenticular studio payload', e);
|
||||
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({
|
||||
pageName: pageName.value,
|
||||
uploadedImage: uploadedSubject.value,
|
||||
uploadedImageBase64: uploadedSubjectBase64.value,
|
||||
})
|
||||
: buildCastloveFormSnapshot({
|
||||
nftInfo: nftInfo.value,
|
||||
materialTypes: [...materialTypes],
|
||||
materialTypes,
|
||||
materialTypeIndex: materialTypeIndex.value,
|
||||
pageName: pageName.value,
|
||||
uploadedImage: uploadedImage.value,
|
||||
uploadedImageBase64: uploadedImageBase64.value,
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[CreatePage] laser entry payload', e);
|
||||
uni.showToast({ title: '保存失败,请重试', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: '/pages/castlove/laser-card-studio',
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
...snap,
|
||||
image: isLenticularCraft.value ? uploadedSubject.value : uploadedImage.value,
|
||||
imageBase64: isLenticularCraft.value ? uploadedSubjectBase64.value : uploadedImageBase64.value,
|
||||
type: pageType.value,
|
||||
typeName: pageName.value,
|
||||
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 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 buildCraftPrompt = () => {
|
||||
let fullText = aiDescription.value.trim() || nftInfo.value.trim();
|
||||
if (pageName.value) {
|
||||
fullText = `${pageName.value},${fullText}`;
|
||||
}
|
||||
return fullText;
|
||||
};
|
||||
|
||||
// 开始生成
|
||||
const handleGenerate = async () => {
|
||||
const startCraftStudioPipeline = (studioKind) => {
|
||||
if (isLenticularCraft.value) {
|
||||
if (!uploadedBg.value || !uploadedSubject.value) {
|
||||
uni.showToast({ title: '请上传背景图与主体图', icon: 'none' });
|
||||
@ -796,93 +757,110 @@ const handleGenerate = async () => {
|
||||
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();
|
||||
if (pageName.value) {
|
||||
fullText = `${pageName.value},${fullText}`;
|
||||
}
|
||||
|
||||
|
||||
if (!fullText || fullText === `${pageName.value},`) {
|
||||
uni.showToast({ title: '请输入AI描述', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
const snap = isLenticularCraft.value
|
||||
? buildCastloveFormSnapshot({
|
||||
nftInfo: nftInfo.value,
|
||||
materialTypes,
|
||||
materialTypeIndex: materialTypeIndex.value,
|
||||
pageName: pageName.value,
|
||||
uploadedImage: uploadedSubject.value,
|
||||
uploadedImageBase64: uploadedSubjectBase64.value,
|
||||
})
|
||||
: buildCastloveFormSnapshot({
|
||||
nftInfo: nftInfo.value,
|
||||
materialTypes,
|
||||
materialTypeIndex: materialTypeIndex.value,
|
||||
pageName: pageName.value,
|
||||
uploadedImage: uploadedImage.value,
|
||||
uploadedImageBase64: uploadedImageBase64.value,
|
||||
});
|
||||
const snap = buildCastloveFormSnapshot({
|
||||
nftInfo: nftInfo.value,
|
||||
materialTypes,
|
||||
materialTypeIndex: materialTypeIndex.value,
|
||||
pageName: pageName.value,
|
||||
uploadedImage: uploadedImage.value,
|
||||
uploadedImageBase64: uploadedImageBase64.value,
|
||||
});
|
||||
|
||||
const formData = {
|
||||
...snap,
|
||||
image: isLenticularCraft.value ? uploadedSubject.value : uploadedImage.value,
|
||||
imageBase64: isLenticularCraft.value ? uploadedSubjectBase64.value : uploadedImageBase64.value,
|
||||
image: uploadedImage.value,
|
||||
imageBase64: uploadedImageBase64.value,
|
||||
type: pageType.value,
|
||||
typeName: pageName.value,
|
||||
materialType: snap.material_type,
|
||||
...(isLenticularCraft.value
|
||||
? {
|
||||
lenticularBgImage: uploadedBg.value,
|
||||
lenticularBgBase64: uploadedBgBase64.value,
|
||||
lenticularSubjectImage: uploadedSubject.value,
|
||||
lenticularSubjectBase64: uploadedSubjectBase64.value,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
try {
|
||||
uni.setStorageSync('castlove_form_data', JSON.stringify(formData));
|
||||
|
||||
} catch (e) {
|
||||
console.error('保存表单数据失败:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送到后端生成
|
||||
sendToBackend(fullText);
|
||||
};
|
||||
|
||||
// 发送数据到后端
|
||||
// 发送数据到后端 → 统一 Loading → 选择藏品
|
||||
const sendToBackend = (aiDescription) => {
|
||||
// 构建后端需要的数据格式(不包含base64图片)
|
||||
const requestData = {
|
||||
prompt: aiDescription, // AI描述文本
|
||||
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] 生成请求数据已保存');
|
||||
|
||||
// base64图片已经在castlove_form_data中,不需要重复存储
|
||||
|
||||
// 清空表单
|
||||
// resetForm();
|
||||
|
||||
// 跳转到加载页面
|
||||
uni.navigateTo({
|
||||
url: '/pages/discover/generation-loading'
|
||||
});
|
||||
const formDataStr = uni.getStorageSync('castlove_form_data');
|
||||
const formData = formDataStr ? JSON.parse(formDataStr) : {};
|
||||
startAiImageGenerationFlow({ prompt: aiDescription, formData });
|
||||
};
|
||||
|
||||
// 重置表单
|
||||
|
||||
@ -45,6 +45,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { onUnload } from '@dcloudio/uni-app';
|
||||
import Header from '../components/Header.vue';
|
||||
import NftCard from '../components/NftCard.vue';
|
||||
|
||||
@ -88,25 +89,41 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// 查看详情按钮
|
||||
// 查看详情(保留 temp_nft_data,避免第二次点击时 order_id 丢失)
|
||||
const handleViewDetails = () => {
|
||||
let orderId = '';
|
||||
let assetId = '';
|
||||
try {
|
||||
const raw = uni.getStorageSync('temp_nft_data');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
orderId = parsed.order_id || '';
|
||||
assetId = parsed.asset_id || '';
|
||||
}
|
||||
} 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({
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -88,6 +88,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import ConfirmModal from '@/components/ConfirmModal.vue';
|
||||
import { startAiImageGenerationFlow } from '@/utils/castloveGenerationFlow.js';
|
||||
|
||||
// 梦境输入
|
||||
const dreamInput = ref('');
|
||||
@ -777,31 +778,11 @@ const handleInputConfirm = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 发送数据到后端
|
||||
// 发送数据到后端 → 统一 Loading → 选择藏品
|
||||
const sendToBackend = (aiDescription) => {
|
||||
// 构建后端需要的数据格式(不包含base64图片)
|
||||
const requestData = {
|
||||
prompt: aiDescription, // AI描述文本
|
||||
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));
|
||||
|
||||
// base64图片已经在castlove_form_data中,不需要重复存储
|
||||
|
||||
// 跳转到加载页面
|
||||
uni.navigateTo({
|
||||
url: '/pages/discover/generation-loading'
|
||||
});
|
||||
const formDataStr = uni.getStorageSync('castlove_form_data');
|
||||
const formData = formDataStr ? JSON.parse(formDataStr) : {};
|
||||
startAiImageGenerationFlow({ prompt: aiDescription, formData });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
<template>
|
||||
<view class="generation-loading">
|
||||
<!-- 背景图 -->
|
||||
<image class="background-image" src="/static/background/exhibitionSuccess.png" mode="aspectFill" />
|
||||
|
||||
<!-- 礼盒区域 -->
|
||||
|
||||
<view class="gift-box">
|
||||
<image class="gift-image" src="/static/nft/lihe.png" mode="aspectFit" />
|
||||
</view>
|
||||
|
||||
<!-- 进度条区域 -->
|
||||
|
||||
<view class="progress-container">
|
||||
<view class="progress-bar-wrapper">
|
||||
<view class="progress-bar">
|
||||
@ -19,31 +16,53 @@
|
||||
</view>
|
||||
</view>
|
||||
<text class="progress-text">{{ Math.round(progress) }}%</text>
|
||||
<text class="loading-text">Loading</text>
|
||||
<text class="loading-text">Thinking</text>
|
||||
</view>
|
||||
|
||||
<!-- 镭射批量导出(离屏) -->
|
||||
<canvas
|
||||
canvas-id="laserBatchCanvas"
|
||||
class="laser-batch-canvas"
|
||||
:style="{ width: laserCanvasW + 'px', height: laserCanvasH + 'px' }"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
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 laserCanvasW = LASER_BATCH_CANVAS_SIZE.w;
|
||||
const laserCanvasH = LASER_BATCH_CANVAS_SIZE.h;
|
||||
let progressTimer = null;
|
||||
let generationData = null;
|
||||
let flowStartedAt = 0;
|
||||
|
||||
// 模拟进度增长
|
||||
const simulateProgress = () => {
|
||||
progressTimer = setInterval(() => {
|
||||
if (progress.value < 90) {
|
||||
// 进度到90%前缓慢增长
|
||||
const increment = Math.random() * 5 + 2;
|
||||
progress.value = Math.min(90, progress.value + increment);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 停止进度模拟
|
||||
const stopProgress = () => {
|
||||
if (progressTimer) {
|
||||
clearInterval(progressTimer);
|
||||
@ -51,7 +70,6 @@ const stopProgress = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 完成进度
|
||||
const completeProgress = () => {
|
||||
stopProgress();
|
||||
const finalInterval = setInterval(() => {
|
||||
@ -59,15 +77,11 @@ const completeProgress = () => {
|
||||
progress.value = Math.min(100, progress.value + 5);
|
||||
} else {
|
||||
clearInterval(finalInterval);
|
||||
// 进度完成后延迟跳转
|
||||
setTimeout(() => {
|
||||
handleSuccess();
|
||||
}, 500);
|
||||
setTimeout(() => handleSuccess(), 500);
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
// 回退进度
|
||||
const revertProgress = () => {
|
||||
stopProgress();
|
||||
const revertInterval = setInterval(() => {
|
||||
@ -75,105 +89,132 @@ const revertProgress = () => {
|
||||
progress.value = Math.max(0, progress.value - 10);
|
||||
} else {
|
||||
clearInterval(revertInterval);
|
||||
// 回退完成后返回上一页
|
||||
setTimeout(() => {
|
||||
uni.navigateBack();
|
||||
}, 500);
|
||||
setTimeout(() => uni.navigateBack(), 500);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 调用图生图API - 同步模式
|
||||
const callImageGeneration = async () => {
|
||||
try {
|
||||
const res = await imageGenerationApi(generationData);
|
||||
const waitMinDuration = async (minMs) => {
|
||||
if (!minMs || minMs <= 0) return;
|
||||
const elapsed = Date.now() - flowStartedAt;
|
||||
if (elapsed < minMs) {
|
||||
await new Promise((r) => setTimeout(r, minMs - elapsed));
|
||||
}
|
||||
};
|
||||
|
||||
if (res.data && res.data.images && res.data.images.length > 0) {
|
||||
// 保存生成的图片
|
||||
uni.setStorageSync('generated_images', JSON.stringify(res.data.images));
|
||||
completeProgress();
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '未生成图片',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
revertProgress();
|
||||
}
|
||||
const handleSuccess = () => {
|
||||
uni.showToast({ title: '生成成功', icon: 'success', duration: 1200 });
|
||||
setTimeout(() => {
|
||||
uni.navigateTo({ url: '/pages/discover/generation-result' });
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
const runPrefilledFlow = async (flow) => {
|
||||
try {
|
||||
const images = flow.images || [];
|
||||
if (!images.length) throw new Error('无候选图');
|
||||
await waitMinDuration(flow.minDurationMs ?? 1400);
|
||||
uni.setStorageSync(GENERATED_IMAGES_KEY, JSON.stringify(images));
|
||||
completeProgress();
|
||||
} catch (err) {
|
||||
console.error('[GenerationLoading] 生成失败:', err);
|
||||
uni.showToast({
|
||||
title: err.message || '生成失败',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
console.error('[GenerationLoading] prefilled', err);
|
||||
uni.showToast({ title: err.message || '加载失败', icon: 'none' });
|
||||
revertProgress();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理成功
|
||||
const handleSuccess = () => {
|
||||
uni.showToast({
|
||||
title: '生成成功',
|
||||
icon: 'success',
|
||||
duration: 1500
|
||||
});
|
||||
const runLenticularFlow = async (flow) => {
|
||||
try {
|
||||
const formStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
|
||||
if (!formStr) throw new Error('缺少表单数据');
|
||||
const formData = JSON.parse(formStr);
|
||||
await waitMinDuration(flow.minDurationMs ?? 1600);
|
||||
persistLenticularPreviewMeta(formData);
|
||||
completeProgress();
|
||||
} catch (err) {
|
||||
console.error('[GenerationLoading] lenticular', err);
|
||||
uni.showToast({ title: err.message || '光栅预览失败', icon: 'none' });
|
||||
revertProgress();
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转到结果页面
|
||||
setTimeout(() => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/discover/generation-result'
|
||||
});
|
||||
}, 1500);
|
||||
const runLaserFlow = async (flow) => {
|
||||
try {
|
||||
const formStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
|
||||
if (!formStr) throw new Error('缺少表单数据');
|
||||
const formData = JSON.parse(formStr);
|
||||
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(() => {
|
||||
// 从存储中获取传递的数据
|
||||
flowStartedAt = Date.now();
|
||||
simulateProgress();
|
||||
try {
|
||||
const requestDataStr = uni.getStorageSync('generation_request_data');
|
||||
if (!requestDataStr) {
|
||||
uni.showToast({
|
||||
title: '数据错误',
|
||||
icon: 'none'
|
||||
});
|
||||
setTimeout(() => {
|
||||
uni.navigateBack();
|
||||
}, 1500);
|
||||
const flow = consumeGenerationFlowPayload();
|
||||
|
||||
if (flow?.mode === FLOW_MODE_LENTICULAR) {
|
||||
runLenticularFlow(flow);
|
||||
return;
|
||||
}
|
||||
if (flow?.mode === FLOW_MODE_LASER) {
|
||||
runLaserFlow(flow);
|
||||
return;
|
||||
}
|
||||
if (flow?.mode === FLOW_MODE_PREFILLED) {
|
||||
runPrefilledFlow(flow);
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析请求数据
|
||||
const requestDataStr = uni.getStorageSync(GENERATION_REQUEST_KEY);
|
||||
if (!requestDataStr) {
|
||||
uni.showToast({ title: '数据错误', icon: 'none' });
|
||||
setTimeout(() => uni.navigateBack(), 1500);
|
||||
return;
|
||||
}
|
||||
generationData = JSON.parse(requestDataStr);
|
||||
|
||||
// 从castlove_form_data获取base64图片
|
||||
const castloveDataStr = uni.getStorageSync('castlove_form_data');
|
||||
const castloveDataStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
|
||||
if (castloveDataStr) {
|
||||
const castloveData = JSON.parse(castloveDataStr);
|
||||
// 将base64图片填充到请求数据中
|
||||
if (generationData.subject_reference && generationData.subject_reference[0]) {
|
||||
generationData.subject_reference[0].image_file = castloveData.imageBase64;
|
||||
if (generationData.subject_reference?.[0]) {
|
||||
generationData.subject_reference[0].image_file =
|
||||
castloveData.imageBase64 ||
|
||||
castloveData.lenticularSubjectBase64 ||
|
||||
'';
|
||||
}
|
||||
}
|
||||
|
||||
// 清除generation_request_data存储,避免重复使用
|
||||
uni.removeStorageSync('generation_request_data');
|
||||
|
||||
console.log('[GenerationLoading] 接收到生成数据');
|
||||
|
||||
// 开始模拟进度
|
||||
simulateProgress();
|
||||
|
||||
// 调用API
|
||||
uni.removeStorageSync(GENERATION_REQUEST_KEY);
|
||||
callImageGeneration();
|
||||
} catch (e) {
|
||||
console.error('[GenerationLoading] 读取数据失败:', e);
|
||||
uni.showToast({
|
||||
title: '数据错误',
|
||||
icon: 'none'
|
||||
});
|
||||
setTimeout(() => {
|
||||
uni.navigateBack();
|
||||
}, 1500);
|
||||
console.error('[GenerationLoading] mount', e);
|
||||
uni.showToast({ title: '数据错误', icon: 'none' });
|
||||
setTimeout(() => uni.navigateBack(), 1500);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -187,6 +228,14 @@ onMounted(() => {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.laser-batch-canvas {
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.background-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@ -208,7 +257,8 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(-50%) translateY(0) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
@ -247,15 +297,13 @@ onMounted(() => {
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(10rpx);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
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;
|
||||
transition: width 0.3s ease;
|
||||
box-shadow: 0 0 20rpx rgba(255, 107, 157, 0.6);
|
||||
}
|
||||
|
||||
.progress-icon {
|
||||
@ -270,37 +318,32 @@ onMounted(() => {
|
||||
.icon-circle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #FFB6D9 0%, #FF6B9D 100%);
|
||||
background: linear-gradient(135deg, #ffb6d9 0%, #ff6b9d 100%);
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid #FFFFFF;
|
||||
box-shadow: 0 4rpx 12rpx rgba(255, 107, 157, 0.5), 0 0 20rpx rgba(255, 107, 157, 0.4);
|
||||
border: 4rpx solid #ffffff;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 4rpx 12rpx rgba(255, 107, 157, 0.5), 0 0 20rpx rgba(255, 107, 157, 0.4);
|
||||
}
|
||||
50% {
|
||||
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 {
|
||||
font-size: 56rpx;
|
||||
font-weight: bold;
|
||||
color: #FFFFFF;
|
||||
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.4), 0 0 20rpx rgba(255, 107, 157, 0.5);
|
||||
color: #ffffff;
|
||||
margin-bottom: 10rpx;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 36rpx;
|
||||
color: #FFFFFF;
|
||||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.4);
|
||||
color: #ffffff;
|
||||
letter-spacing: 6rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@ -22,27 +22,38 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 图片卡片区域 -->
|
||||
<view class="cards-container" :class="{ 'cards-visible': isGiftOpened }">
|
||||
<view
|
||||
v-for="(image, index) in generatedImages"
|
||||
:key="index"
|
||||
<!-- 光栅卡:单张工作台预览(陀螺仪) -->
|
||||
<view
|
||||
v-if="isLenticularDisplay"
|
||||
class="lenticular-result-wrap"
|
||||
: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-selected': selectedIndex === index }"
|
||||
:style="getCardStyle(index)"
|
||||
@click="selectCard(index)"
|
||||
>
|
||||
<view class="card-frame">
|
||||
<!-- TOPFANS Logo标签 -->
|
||||
<!-- <view class="topfans-label">
|
||||
<text class="topfans-text">TOPFANS</text>
|
||||
</view> -->
|
||||
|
||||
<image
|
||||
class="card-image"
|
||||
:src="image"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<image class="card-image" :src="image" mode="aspectFill" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@ -64,24 +75,84 @@
|
||||
</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">
|
||||
<text class="button-text">选择您的藏品</text>
|
||||
<text class="button-text">{{ primaryActionLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<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 { 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 selectedIndex = ref(-1);
|
||||
const isUploading = ref(false);
|
||||
const currentOrderId = ref(''); // 保存当前订单ID
|
||||
const isGiftOpened = ref(false); // 礼盒是否已打开
|
||||
const currentOrderId = ref('');
|
||||
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) => {
|
||||
@ -627,7 +698,7 @@ const uploadFileToOss = (tempFilePath, fileName, ossData, resolve, reject, fs) =
|
||||
|
||||
// 选择藏品
|
||||
const selectAsset = async () => {
|
||||
if (selectedIndex.value === -1) {
|
||||
if (!isLenticularDisplay.value && selectedIndex.value === -1) {
|
||||
uni.showToast({
|
||||
title: '请先选择一张卡片',
|
||||
icon: 'none'
|
||||
@ -642,21 +713,40 @@ const selectAsset = async () => {
|
||||
});
|
||||
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 {
|
||||
// 获取选中的图片(base64格式)
|
||||
const selectedImage = generatedImages.value[selectedIndex.value];
|
||||
|
||||
// 上传到OSS(会自动设置currentOrderId)
|
||||
const imageUrl = await uploadImageToOss(selectedImage);
|
||||
|
||||
// 创建铸造订单
|
||||
uni.showLoading({ title: '创建订单中...', mask: true });
|
||||
|
||||
// 从storage获取表单数据
|
||||
const formDataStr = uni.getStorageSync('castlove_form_data');
|
||||
const orderValue = formDataStr ? JSON.parse(formDataStr) : {};
|
||||
|
||||
// 构建订单数据(对齐 CreateMintOrderRequestDTO)
|
||||
const orderData = {
|
||||
order_id: currentOrderId.value,
|
||||
@ -676,7 +766,7 @@ const selectAsset = async () => {
|
||||
if (response.code !== 200) {
|
||||
throw new Error(response.message || '创建订单失败');
|
||||
}
|
||||
uni.removeStorageSync('castlove_form_data');
|
||||
uni.removeStorageSync(CASTLOVE_FORM_KEY);
|
||||
// 构建藏品数据,存储到temp_nft_data
|
||||
const nftData = {
|
||||
image: imageUrl,
|
||||
@ -705,40 +795,75 @@ const selectAsset = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 从存储中获取生成的图片
|
||||
const initLenticularPreview = () => {
|
||||
try {
|
||||
const imagesData = uni.getStorageSync('generated_images');
|
||||
if (imagesData) {
|
||||
generatedImages.value = JSON.parse(imagesData);
|
||||
console.log('[GenerationResult] 接收到生成的图片:', generatedImages.value.length);
|
||||
|
||||
// 清除存储
|
||||
uni.removeStorageSync('generated_images');
|
||||
|
||||
// 延迟打开礼盒动画
|
||||
setTimeout(() => {
|
||||
isGiftOpened.value = true;
|
||||
}, 800);
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '未找到生成的图片',
|
||||
icon: 'none'
|
||||
});
|
||||
setTimeout(() => {
|
||||
uni.navigateBack();
|
||||
}, 1500);
|
||||
const raw = uni.getStorageSync(LENTICULAR_STUDIO_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const p = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
lenticularLayers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '');
|
||||
selectedIndex.value = 0;
|
||||
scheduleTiltStart();
|
||||
} catch (e) {
|
||||
console.error('[GenerationResult] lenticular init', e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
const formDataStr = uni.getStorageSync(CASTLOVE_FORM_KEY);
|
||||
if (formDataStr) {
|
||||
craftFormData.value = JSON.parse(formDataStr);
|
||||
}
|
||||
const metaStr = uni.getStorageSync(GENERATION_RESULT_META_KEY);
|
||||
if (metaStr) {
|
||||
resultMeta.value = JSON.parse(metaStr);
|
||||
uni.removeStorageSync(GENERATION_RESULT_META_KEY);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[GenerationResult] 读取图片数据失败:', e);
|
||||
uni.showToast({
|
||||
title: '数据错误',
|
||||
icon: 'none'
|
||||
});
|
||||
setTimeout(() => {
|
||||
uni.navigateBack();
|
||||
}, 1500);
|
||||
console.error('[GenerationResult] 读取表单失败:', e);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@ -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 {
|
||||
position: absolute;
|
||||
bottom: 80rpx;
|
||||
@ -1142,10 +1296,16 @@ onMounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* gap: 16rpx; */
|
||||
z-index: 11;
|
||||
}
|
||||
|
||||
.bottom-action--row {
|
||||
flex-direction: row;
|
||||
gap: 24rpx;
|
||||
width: 90%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 24rpx 80rpx;
|
||||
background: linear-gradient(90deg, #FF6B9D 0%, #FFA8C5 100%);
|
||||
@ -1161,10 +1321,15 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.action-button:active {
|
||||
transform: scale(1.2);
|
||||
transform: scale(1.05);
|
||||
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 {
|
||||
font-size: 32rpx;
|
||||
color: #FFFFFF;
|
||||
|
||||
275
frontend/utils/castloveGenerationFlow.js
Normal file
275
frontend/utils/castloveGenerationFlow.js
Normal 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 }
|
||||
125
frontend/utils/craftMintSubmit.js
Normal file
125
frontend/utils/craftMintSubmit.js
Normal 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 }
|
||||
}
|
||||
579
frontend/utils/laser-card/laserBatchExport.js
Normal file
579
frontend/utils/laser-card/laserBatchExport.js
Normal 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 旧版 canvas(App/小程序)无 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 }
|
||||
Loading…
Reference in New Issue
Block a user