topfans/frontend/pages/castlove/create.vue

1613 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page-container" :class="{ 'page-container--laser': isLaserCardCraft }">
<!-- 背景图片 -->
<image class="background-image" src="/static/background/starbook.jpg" mode="aspectFill"></image>
<view v-if="isLaserCardCraft" class="laser-bg-veil" aria-hidden="true" />
<!-- 镭射卡:左上角关闭 -->
<view v-if="isLaserCardCraft" class="laser-close-hit" @click="handleBack">
<text class="laser-close-x">×</text>
</view>
<!-- 主内容区域 -->
<scroll-view
class="content-wrapper"
:class="{ 'content-wrapper--laser': isLaserCardCraft }"
scroll-y="true"
:show-scrollbar="false"
:enhanced="scrollEnhanced"
>
<!-- 页面标题 -->
<!-- <view v-if="pageName" class="page-title">
<text class="title-text">创建{{ pageName }}</text>
</view> -->
<!-- 光栅卡:必须分别上传背景 + 主体 -->
<view v-if="isLenticularCraft" class="upload-section">
<view class="lenticular-upload-row">
<view class="upload-box upload-box--half" @click="chooseLenticularBg">
<image v-if="uploadedBg" class="uploaded-image" :src="uploadedBg" mode="aspectFit"></image>
<view v-else class="upload-placeholder">
<image class="upload-icon" src="/static/icon/add.png" mode="aspectFit"></image>
<text class="upload-text">背景图</text>
</view>
</view>
<view class="upload-box upload-box--half" @click="chooseLenticularSubject">
<image v-if="uploadedSubject" class="uploaded-image":src="uploadedSubject" mode="aspectFit"></image>
<view v-else class="upload-placeholder">
<image class="upload-icon" src="/static/icon/add.png" mode="aspectFit"></image>
<text class="upload-text">主体图</text>
</view>
</view>
</view>
<view class="upload-hint">请各上传一张JPG / PNG单张不超过 5MB</view>
</view>
<!-- 其他工艺:单图上传 -->
<view v-else class="upload-section" :class="{ 'upload-section--laser': isLaserCardCraft }">
<view class="upload-box-wrap">
<view
:class="isLaserCardCraft ? 'castlove-laser-upload-root' : 'upload-box'"
@click="onSingleUploadTap"
>
<image v-if="uploadedImage" class="uploaded-image" :src="uploadedImage" mode="aspectFill"></image>
<view v-else-if="isLaserCardCraft" class="upload-laser-stack">
<image
class="upload-laser-photo-bg"
src="/static/castlove/leisheka.png"
mode="aspectFill"
/>
<view class="upload-laser-watermark" aria-hidden="true" />
<view class="upload-laser-inset">
<view class="upload-laser-dashed-inner">
<text class="upload-plus upload-plus--laser">+</text>
<text class="upload-text upload-text--laser">点击上传图片</text>
</view>
</view>
</view>
<view v-else class="upload-placeholder">
<image class="upload-icon" src="/static/icon/add.png" mode="aspectFit" />
<text class="upload-text">点击上传图片</text>
</view>
</view>
<view
v-if="isLaserCardCraft && uploadedImage"
class="upload-clear upload-clear--laser"
@click.stop="clearSingleUploadedImage"
>
<text class="upload-clear-x upload-clear-x--laser">×</text>
</view>
</view>
<view class="upload-hint">{{ isLaserCardCraft ? '支持JPG.PNG格式大小不超过5M' : '支持JPG、PNG格式大小不超过5MB' }}</view>
</view>
<!-- 表单区域 -->
<view class="form-section" :class="{ 'form-section--laser': isLaserCardCraft }">
<!-- 素材类型选择器 -->
<view class="form-item form-item-picker">
<text class="form-label" :class="{ 'form-label--laser': isLaserCardCraft }">素材类型</text>
<view class="custom-picker">
<view class="picker-display" :class="{ 'picker-display--laser': isLaserCardCraft }" @click="toggleMaterialTypePicker">
<text class="picker-text">{{ materialTypes[materialTypeIndex] }}</text>
<text
class="picker-arrow"
:class="{
'picker-arrow-up': showMaterialTypePicker,
'picker-arrow--laser': isLaserCardCraft,
}"
>{{ isLaserCardCraft ? '▼' : '' }}</text>
</view>
<view v-if="showMaterialTypePicker" class="picker-options">
<view v-for="(type, index) in materialTypes" :key="index" class="picker-option"
:class="{ 'picker-option-active': materialTypeIndex === index }"
@click.stop="selectMaterialType(index)">
<text class="picker-option-text">{{ type }}</text>
<text v-if="materialTypeIndex === index" class="picker-option-check">✓</text>
</view>
</view>
</view>
</view>
<!-- 藏品信息 -->
<view class="form-item">
<text class="form-label" :class="{ 'form-label--laser': isLaserCardCraft }">藏品信息</text>
<textarea
class="form-textarea"
:class="{ 'form-textarea--laser': isLaserCardCraft }"
v-model="nftInfo"
placeholder="请输入藏品相关信息"
placeholder-class="textarea-placeholder"
maxlength="500"
auto-height
:show-confirm-bar="false"
/>
</view>
</view>
<!-- AI描述区域 -->
<view class="ai-section" :class="{ 'ai-section--laser': isLaserCardCraft }">
<text class="section-title" :class="{ 'section-title--laser': isLaserCardCraft }">{{ isLaserCardCraft ? 'AI生成描述' : 'AI 生成描述' }}</text>
<!-- AI描述输入框 -->
<view class="ai-input-wrapper" :class="{ 'ai-input-wrapper--laser': isLaserCardCraft }">
<textarea
class="ai-input"
v-model="aiDescription"
placeholder="背景有什么想法?边框想要什么样式的?有没有特别想加的装饰元素?整体材质的效果想要什么样的?主色调是应援色吗,还是别的颜色?"
placeholder-class="ai-placeholder"
auto-height
:show-confirm-bar="false"
maxlength="500"
/>
<view v-if="!isLaserCardCraft" class="input-actions">
<!-- 发送按钮 -->
<view class="action-icon-btn send-btn" @click="handleGenerate">
<image class="action-icon" src="/static/icon/send.png" mode="aspectFit" />
<text class="action-text">发送</text>
</view>
</view>
</view>
</view>
<!-- 底部按钮 -->
<view class="button-section" :class="{ 'button-section--laser': isLaserCardCraft }">
<button class="btn-secondary" :class="{ 'btn-secondary--laser': isLaserCardCraft }" @click="handleBack">返回</button>
<button v-if="isLenticularCraft" class="btn-skip" @click="handleLenticularGenerate">生成</button>
<button
v-else
:class="isLaserCardCraft ? 'btn-confirm-laser' : 'btn-skip'"
@click="handleSingleCraftLaserEntry"
>
{{ isLaserCardCraft ? '确认' : '生成' }}
</button>
</view>
</scroll-view>
</view>
<!-- 通用确认弹窗 -->
<ConfirmModal
:visible="confirmModal.visible"
:title="confirmModal.title"
:content="confirmModal.content"
:confirmText="confirmModal.confirmText"
:cancelText="confirmModal.cancelText"
:showCancel="confirmModal.showCancel"
@confirm="onConfirmModal"
@cancel="onCancelModal"
/>
</template>
<script>
export default {
inheritAttrs: false,
};
</script>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { onLoad, onUnload } from '@dcloudio/uni-app';
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js';
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js';
import ConfirmModal from '@/components/ConfirmModal.vue';
import './create-laser-upload.css';
import {
buildCastloveFormSnapshot,
CRAFT_LENTICULAR_CN,
CRAFT_LASER_CARD_CN,
CASTLOVE_LASER_ENTRY_KEY,
LENTICULAR_STUDIO_STORAGE_KEY,
} from '@/utils/castloveMintForm.js';
// 获取页面参数H5 必须用 onLoad 读 query仅靠 onMounted + getCurrentPages().options 常为 undefined
const pageType = ref('');
const pageName = ref('');
// #ifdef MP-WEIXIN
const scrollEnhanced = true;
// #endif
// #ifndef MP-WEIXIN
const scrollEnhanced = false;
// #endif
const isLenticularCraft = computed(() => (pageName.value || '').trim() === CRAFT_LENTICULAR_CN);
const isLaserCardCraft = computed(() => (pageName.value || '').trim() === CRAFT_LASER_CARD_CN);
onUnload(() => {
try {
uni.hideToast();
} catch (e) {
/* noop */
}
try {
uni.hideLoading();
} catch (e) {
/* noop */
}
});
function safeDecodeParam(v) {
if (v == null || v === '') return '';
const s = typeof v === 'string' ? v : String(v);
try {
return decodeURIComponent(s.replace(/\+/g, ' '));
} catch {
return s;
}
}
function applyCreateRouteOptions(options) {
if (!options || typeof options !== 'object') return;
if (options.type) pageType.value = safeDecodeParam(options.type);
if (options.name != null && String(options.name) !== '') {
pageName.value = safeDecodeParam(options.name);
console.log('[CreatePage] 工艺名称:', pageName.value);
}
}
// 通用确认弹窗状态
const confirmModal = ref({
visible: false,
title: '',
content: '',
confirmText: '确认',
cancelText: '取消',
showCancel: true,
confirmCallback: null
});
// 弹窗确认回调
const onConfirmModal = () => {
if (confirmModal.value.confirmCallback) {
confirmModal.value.confirmCallback({ confirm: true });
}
confirmModal.value.visible = false;
};
// 弹窗取消回调
const onCancelModal = () => {
if (confirmModal.value.confirmCallback) {
confirmModal.value.confirmCallback({ confirm: false });
}
confirmModal.value.visible = false;
};
// 显示通用确认弹窗
const showConfirmModal = (options) => {
confirmModal.value = {
visible: true,
title: options.title || '',
content: options.content || '',
confirmText: options.confirmText || '确认',
cancelText: options.cancelText || '取消',
showCancel: options.showCancel !== false,
confirmCallback: options.success || null
};
};
// 表单数据
const uploadedImage = ref('');
const uploadedImageBase64 = ref('');
/** 光栅:背景 / 主体双槽位;空字符串表示普通单图上传 */
const pendingLenticularSlot = ref('');
const uploadedBg = ref('');
const uploadedBgBase64 = ref('');
const uploadedSubject = ref('');
const uploadedSubjectBase64 = ref('');
const originalFileName = ref('');
const isUploading = ref(false);
const materialTypes = ['粉丝自制', '热爱痕迹', '其他'];
const materialTypeIndex = ref(0);
const showMaterialTypePicker = ref(false);
const nftInfo = ref(''); // 藏品信息(合并了名称和事件)
// AI描述相关
const aiDescription = ref('');
// 风格标签数据 - 已移除
function applyUploadResult(filePath, dataUrl) {
const slot = pendingLenticularSlot.value;
if (slot === 'bg') {
uploadedBg.value = filePath;
uploadedBgBase64.value = dataUrl;
} else if (slot === 'subject') {
uploadedSubject.value = filePath;
uploadedSubjectBase64.value = dataUrl;
} else {
uploadedImage.value = filePath;
uploadedImageBase64.value = dataUrl;
}
pendingLenticularSlot.value = '';
uni.hideLoading();
uni.showToast({ title: '图片加载成功', icon: 'success', duration: 1500 });
isUploading.value = false;
}
// 选择图片(非光栅单图)
const chooseImage = () => {
openImagePicker('');
};
const onSingleUploadTap = () => {
chooseImage();
};
const clearSingleUploadedImage = () => {
uploadedImage.value = '';
uploadedImageBase64.value = '';
originalFileName.value = '';
};
const chooseLenticularBg = () => {
openImagePicker('bg');
};
const chooseLenticularSubject = () => {
openImagePicker('subject');
};
const openImagePicker = (lenticularSlot) => {
if (isUploading.value) {
uni.showToast({ title: '图片上传中,请稍候', icon: 'none' });
return;
}
pendingLenticularSlot.value = lenticularSlot;
uni.chooseImage({
count: 1,
sourceType: ['album', 'camera'],
success: (res) => {
const filePath = res.tempFilePaths[0];
const tempFile = res.tempFiles && res.tempFiles[0];
uni.getFileInfo({
filePath: filePath,
success: (fileInfo) => {
const maxSize = 5 * 1024 * 1024;
if (fileInfo.size > maxSize) {
pendingLenticularSlot.value = '';
uni.showToast({ title: '图片大小不能超过5MB', icon: 'none', duration: 2000 });
return;
}
const mimeType = (tempFile && tempFile.type) ? tempFile.type.toLowerCase() : '';
const pathLower = filePath.toLowerCase();
const validByMime = mimeType === 'image/jpeg' || mimeType === 'image/png';
const validByExt = pathLower.endsWith('.jpg') || pathLower.endsWith('.jpeg') || pathLower.endsWith('.png');
if (!validByMime && !validByExt) {
pendingLenticularSlot.value = '';
uni.showToast({ title: '只支持JPG和PNG格式', icon: 'none', duration: 2000 });
return;
}
const rawName = (tempFile && tempFile.name) ? tempFile.name : filePath.split('/').pop();
originalFileName.value = rawName;
convertImageToBase64(filePath, originalFileName.value);
},
fail: (error) => {
pendingLenticularSlot.value = '';
console.error('获取文件信息失败:', error);
uni.showToast({ title: '获取文件信息失败', icon: 'none' });
}
});
},
fail: (err) => {
pendingLenticularSlot.value = '';
console.error('选择图片失败:', err);
uni.showToast({ title: '选择图片失败', icon: 'none' });
}
});
};
// 将图片转换为 base64
const convertImageToBase64 = (filePath, fileName) => {
isUploading.value = true;
uni.showLoading({ title: '处理中...', mask: true });
// #ifdef H5
convertImageToBase64H5(filePath, fileName);
// #endif
// #ifndef H5
convertImageToBase64Native(filePath, fileName);
// #endif
};
const convertImageToBase64H5 = (filePath, fileName) => {
fetch(filePath)
.then(res => res.blob())
.then(blob => {
const reader = new FileReader();
reader.onload = (e) => {
applyUploadResult(filePath, e.target.result);
console.log('[CreatePage] Base64转换成功 (H5)');
console.log('[CreatePage] Base64长度:', (e.target.result && e.target.result.length) || 0);
};
reader.onerror = (error) => {
pendingLenticularSlot.value = '';
console.error('[CreatePage] Base64转换失败 (H5):', error);
uni.hideLoading();
uni.showToast({ title: '图片处理失败', icon: 'none', duration: 2000 });
isUploading.value = false;
};
reader.readAsDataURL(blob);
})
.catch(error => {
pendingLenticularSlot.value = '';
console.error('[CreatePage] 获取图片失败 (H5):', error);
uni.hideLoading();
uni.showToast({ title: '图片处理失败', icon: 'none', duration: 2000 });
isUploading.value = false;
});
};
const convertImageToBase64Native = (filePath, fileName) => {
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
const fs = uni.getFileSystemManager();
fs.readFile({
filePath: filePath,
encoding: 'base64',
success: (res) => {
const ext = fileName.toLowerCase().split('.').pop();
let mimeType = 'image/jpeg';
if (ext === 'png') mimeType = 'image/png';
const dataUrl = `data:${mimeType};base64,${res.data}`;
applyUploadResult(filePath, dataUrl);
console.log('[CreatePage] Base64转换成功 (小程序)');
console.log('[CreatePage] Base64长度:', dataUrl.length);
},
fail: (error) => {
pendingLenticularSlot.value = '';
console.error('[CreatePage] Base64转换失败 (小程序):', error);
uni.hideLoading();
uni.showToast({ title: '图片处理失败', icon: 'none', duration: 2000 });
isUploading.value = false;
}
});
// #endif
// #ifdef APP-PLUS
plus.io.resolveLocalFileSystemURL(filePath, (entry) => {
entry.file((file) => {
const reader = new plus.io.FileReader();
reader.onloadend = (e) => {
applyUploadResult(filePath, e.target.result);
console.log('[CreatePage] Base64转换成功 (App)');
console.log('[CreatePage] Base64长度:', (e.target.result && e.target.result.length) || 0);
};
reader.onerror = (error) => {
pendingLenticularSlot.value = '';
console.error('[CreatePage] Base64转换失败 (App):', error);
uni.hideLoading();
uni.showToast({ title: '图片处理失败', icon: 'none', duration: 2000 });
isUploading.value = false;
};
reader.readAsDataURL(file);
});
}, (error) => {
pendingLenticularSlot.value = '';
console.error('[CreatePage] 读取文件失败 (App):', error);
uni.hideLoading();
uni.showToast({ title: '图片处理失败', icon: 'none', duration: 2000 });
isUploading.value = false;
});
// #endif
};
// 切换素材类型选择器
const toggleMaterialTypePicker = () => {
showMaterialTypePicker.value = !showMaterialTypePicker.value;
};
const selectMaterialType = (index) => {
materialTypeIndex.value = index;
showMaterialTypePicker.value = false;
};
// 上传图片到OSS的辅助函数
const uploadImageToOss = async (base64Data, ossData) => {
return new Promise((resolve, reject) => {
const fileName = `${Date.now()}.jpg`;
// #ifdef H5
// H5仍走原有 OSS PostObject本地开发经 manifest devServer /dev-oss-proxy 转发,避免 CORS
fetch(base64Data)
.then((res) => res.blob())
.then((blob) => {
const formData = new FormData();
formData.append('key', ossData.dir + fileName);
formData.append('policy', ossData.policy);
formData.append('success_action_status', '200');
formData.append('x-oss-credential', ossData.x_oss_credential);
formData.append('x-oss-date', ossData.x_oss_date);
formData.append('x-oss-security-token', ossData.security_token);
formData.append('x-oss-signature', ossData.signature);
formData.append('x-oss-signature-version', ossData.x_oss_signature_version);
formData.append('file', blob, fileName);
return fetch(resolveH5OssPostUrl(ossData.host), {
method: 'POST',
body: formData
});
})
.then((response) => {
if (response.ok || response.status === 204) {
const imageUrl = `${ossData.host}/${ossData.dir}${fileName}`;
resolve(imageUrl);
} else {
reject(new Error('上传失败'));
}
})
.catch((error) => {
reject(error);
});
// #endif
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
// 小程序环境 - 使用文件系统管理器
const base64Content = base64Data.split(',')[1];
const filePath = `${wx.env.USER_DATA_PATH}/${fileName}`;
const fs = uni.getFileSystemManager();
fs.writeFile({
filePath: filePath,
data: base64Content,
encoding: 'base64',
success: () => {
uni.uploadFile({
url: ossData.host,
filePath: filePath,
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: (uploadRes) => {
if (uploadRes.statusCode === 200 || uploadRes.statusCode === 204) {
const imageUrl = `${ossData.host}/${ossData.dir}${fileName}`;
resolve(imageUrl);
} else {
reject(new Error(`上传失败,状态码: ${uploadRes.statusCode}`));
}
},
fail: (error) => {
reject(error);
}
});
},
fail: (error) => {
reject(error);
}
});
// #endif
// #ifdef APP-PLUS
// App环境 - 将base64转为临时文件后上传
console.log('[CreatePage] App环境上传');
const base64ContentApp = base64Data.split(',')[1];
const bitmap = new plus.nativeObj.Bitmap('temp');
bitmap.loadBase64Data(base64Data, () => {
const tempFilePath = `_doc/${fileName}`;
bitmap.save(tempFilePath, {}, () => {
console.log('[CreatePage] App临时文件保存成功:', tempFilePath);
bitmap.clear();
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: (uploadRes) => {
console.log('[CreatePage] App上传响应:', uploadRes);
if (uploadRes.statusCode === 200 || uploadRes.statusCode === 204) {
const imageUrl = `${ossData.host}/${ossData.dir}${fileName}`;
resolve(imageUrl);
} else {
reject(new Error(`上传失败,状态码: ${uploadRes.statusCode}`));
}
},
fail: (error) => {
console.error('[CreatePage] App上传失败:', error);
reject(error);
}
});
}, (error) => {
console.error('[CreatePage] App保存临时文件失败:', error);
bitmap.clear();
reject(new Error('保存临时文件失败'));
});
}, (error) => {
console.error('[CreatePage] App加载base64失败:', error);
bitmap.clear();
reject(new Error('加载图片数据失败'));
});
// #endif
});
};
// 返回按钮
const handleBack = async () => {
const hasLenticular =
isLenticularCraft.value &&
(uploadedBg.value || uploadedSubject.value || uploadedBgBase64.value || uploadedSubjectBase64.value);
const hasAny =
uploadedImage.value ||
hasLenticular ||
nftInfo.value ||
aiDescription.value;
if (hasAny) {
showConfirmModal({
title: '提示',
content: '确定要返回吗?未保存的数据将会丢失',
success: async (res) => {
if (res.confirm) {
resetForm();
try {
uni.hideToast();
} catch (e) {
/* noop */
}
uni.navigateBack();
}
}
});
} else {
try {
uni.hideToast();
} catch (e) {
/* noop */
}
uni.navigateBack();
}
};
/** 光栅卡:双图校验后进入工作室预览 */
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,
nftInfo: nftInfo.value,
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({
nftInfo: nftInfo.value,
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',
});
};
// 底部导航切换
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) {
if (!uploadedBg.value || !uploadedSubject.value) {
uni.showToast({ title: '请上传背景图与主体图', icon: 'none' });
return;
}
if (!uploadedBgBase64.value || !uploadedSubjectBase64.value) {
uni.showToast({ title: '图片尚未处理完成', icon: 'none' });
return;
}
} else {
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 formData = {
...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,
...(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);
};
// 发送数据到后端
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 resetForm = () => {
pendingLenticularSlot.value = '';
uploadedBg.value = '';
uploadedBgBase64.value = '';
uploadedSubject.value = '';
uploadedSubjectBase64.value = '';
uploadedImage.value = '';
uploadedImageBase64.value = '';
originalFileName.value = '';
isUploading.value = false;
materialTypeIndex.value = 0;
nftInfo.value = '';
aiDescription.value = '';
};
onLoad((options) => {
applyCreateRouteOptions(options || {});
});
onMounted(() => {
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
const options = (currentPage && currentPage.options) || {};
if (options.name || options.type) {
applyCreateRouteOptions(options);
}
});
</script>
<style scoped>
.page-container {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
}
.background-image {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 0;
object-fit: cover;
}
.background-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 0;
background: rgba(0, 0, 0, 0.3);
}
.content-wrapper {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
padding: 100rpx 32rpx 40rpx 32rpx;
box-sizing: border-box;
}
/* 隐藏滚动条 */
.content-wrapper::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
/* 页面标题 */
.page-title {
width: 100%;
text-align: center;
margin-bottom: 40rpx;
}
.title-text {
font-size: 48rpx;
color: #ffffff;
font-weight: 700;
font-family: 'yt', sans-serif;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.5);
background-clip: text;
}
/* 图片上传区域 */
.upload-section {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 40rpx;
}
.upload-box {
width: 300rpx;
height: 300rpx;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10rpx);
border-radius: 30rpx;
border: 4rpx dashed rgba(255, 255, 255, 0.5);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.2);
}
.upload-hint {
margin-top: 20rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
text-align: center;
}
.lenticular-upload-row {
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;
align-items: stretch;
gap: 20rpx;
padding: 0 4rpx;
box-sizing: border-box;
}
.upload-box--half {
flex: 1;
width: 0;
min-width: 0;
height: 280rpx;
}
.uploaded-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20rpx;
}
.upload-icon {
width: 80rpx;
height: 80rpx;
opacity: 0.8;
}
.upload-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
/* 表单区域 */
.form-section {
width: 100%;
display: flex;
flex-direction: column;
gap: 30rpx;
margin-bottom: 40rpx;
}
.form-item {
width: 100%;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.form-item-picker {
position: relative;
z-index: 10;
}
.form-label {
font-size: 36rpx;
color: #e6e6e6;
font-weight: 500;
font-family: 'yt', sans-serif;
padding: 18rpx 18rpx;
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
border-radius: 44rpx;
display: inline-block;
align-self: flex-start;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.4);
}
/* 自定义选择器 */
.custom-picker {
position: relative;
width: 100%;
z-index: 10;
}
.picker-display {
width: 100%;
height: 88rpx;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10rpx);
border-radius: 20rpx;
padding: 0 30rpx;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.picker-display:active {
background: rgba(255, 255, 255, 0.2);
}
.picker-text {
font-size: 32rpx;
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3);
}
.picker-arrow {
font-size: 48rpx;
color: rgba(255, 255, 255, 0.7);
font-weight: bold;
transform: rotate(90deg);
transition: transform 0.3s ease;
}
.picker-arrow-up {
transform: rotate(-90deg);
}
.picker-options {
position: absolute;
top: 100%;
left: 0;
right: 0;
width: 100%;
margin-top: 10rpx;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(15rpx);
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.2);
z-index: 10;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.picker-option {
width: 100%;
height: 88rpx;
padding: 0 30rpx;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
transition: background 0.2s ease;
border-bottom: 1rpx solid rgba(255, 255, 255, 0.1);
}
.picker-option:last-child {
border-bottom: none;
}
.picker-option:active {
background: rgba(255, 255, 255, 0.15);
}
.picker-option-active {
background: rgba(255, 255, 255, 0.1);
}
.picker-option-text {
font-size: 32rpx;
color: #e6e6e6;
}
.picker-option-check {
font-size: 36rpx;
color: #e6e6e6;
font-weight: bold;
}
.form-input {
width: 100%;
height: 88rpx;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10rpx);
border-radius: 20rpx;
padding: 0 30rpx;
font-size: 32rpx;
color: #e6e6e6;
box-sizing: border-box;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
}
.input-placeholder {
color: rgba(255, 255, 255, 0.85);
font-size: 32rpx;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3);
}
.form-textarea {
width: 100%;
min-height: 200rpx;
max-height: 400rpx;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10rpx);
border-radius: 20rpx;
padding: 20rpx 30rpx;
font-size: 32rpx;
color: #e6e6e6;
box-sizing: border-box;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
line-height: 1.5;
}
.textarea-placeholder {
color: rgba(255, 255, 255, 0.85);
font-size: 32rpx;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3);
}
/* AI描述区域 */
.ai-section {
width: 100%;
display: flex;
flex-direction: column;
gap: 24rpx;
margin-bottom: 40rpx;
}
.section-title {
font-size: 36rpx;
color: #e6e6e6;
font-weight: 500;
font-family: 'yt', sans-serif;
padding: 18rpx 18rpx;
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
border-radius: 44rpx;
display: inline-block;
align-self: flex-start;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.4);
}
/* AI输入框 */
.ai-input-wrapper {
width: 100%;
min-height: 200rpx;
background: rgba(255, 255, 255, 0.15);
border-radius: 24rpx;
backdrop-filter: blur(10rpx);
border: 2rpx solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.15);
display: flex;
align-items: flex-start;
padding: 20rpx 24rpx;
gap: 16rpx;
box-sizing: border-box;
}
.ai-input {
flex: 1;
min-height: 192rpx;
max-height: 400rpx;
font-size: 32rpx;
color: rgba(255, 255, 255, 0.95);
padding: 0;
background: transparent;
border: none;
line-height: 1.5;
box-sizing: border-box;
}
.ai-placeholder {
color: rgba(255, 255, 255, 0.6);
font-size: 32rpx;
}
.input-actions {
display: flex;
align-items: flex-end;
padding-top: 120rpx;
}
.action-icon-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
padding: 8rpx 12rpx;
transition: all 0.3s ease;
}
.action-icon {
width: 40rpx;
height: 40rpx;
}
.action-text {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.8);
white-space: nowrap;
}
.send-btn .action-text {
color: rgba(255, 255, 255, 0.95);
font-weight: 600;
}
.send-btn:active {
opacity: 0.8;
}
/* 底部按钮 */
.button-section {
width: 100%;
display: flex;
gap: 20rpx;
margin-top: 40rpx;
padding-bottom: calc(40rpx + constant(safe-area-inset-bottom)); /* iOS 11.0 */
padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); /* iOS 11.2+ */
}
.btn-secondary,
.btn-skip {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 36rpx;
font-family: 'yt', sans-serif;
font-weight: 600;
border: none;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10rpx);
color: #e6e6e6;
border: 2rpx solid rgba(255, 255, 255, 0.4);
}
.btn-secondary::after {
border: none;
}
.btn-skip {
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
color: #e6e6e6;
}
.btn-skip::after {
border: none;
}
.btn-secondary:active {
background: rgba(255, 255, 255, 0.3);
}
.btn-skip:active {
opacity: 0.9;
}
/* ========== 镭射卡设计稿专用 ========== */
.page-container--laser {
position: relative;
}
.laser-bg-veil {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
background:
radial-gradient(2rpx 2rpx at 10% 18%, rgba(255, 255, 255, 0.55), transparent 55%),
radial-gradient(1.5rpx 1.5rpx at 78% 12%, rgba(255, 255, 255, 0.45), transparent 55%),
radial-gradient(2rpx 2rpx at 42% 8%, rgba(255, 255, 255, 0.4), transparent 50%),
radial-gradient(1.5rpx 1.5rpx at 88% 46%, rgba(255, 255, 255, 0.5), transparent 50%),
radial-gradient(2rpx 2rpx at 18% 62%, rgba(255, 255, 255, 0.35), transparent 50%),
radial-gradient(1.5rpx 1.5rpx at 64% 72%, rgba(255, 255, 255, 0.45), transparent 50%),
radial-gradient(2rpx 2rpx at 36% 88%, rgba(255, 255, 255, 0.4), transparent 50%),
radial-gradient(1.5rpx 1.5rpx at 92% 84%, rgba(255, 255, 255, 0.35), transparent 50%),
linear-gradient(
165deg,
rgba(255, 182, 220, 0.16) 0%,
rgba(140, 100, 210, 0.22) 42%,
rgba(20, 40, 80, 0.2) 100%
);
}
.laser-close-hit {
position: fixed;
top: calc(16rpx + constant(safe-area-inset-top));
top: calc(16rpx + env(safe-area-inset-top));
left: 24rpx;
z-index: 20;
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: linear-gradient(
135deg,
#ffb7e8 0%,
#c9a6ff 28%,
#8fd4ff 55%,
#fff3b0 78%,
#ffa8d8 100%
);
background-size: 180% 180%;
border: 2rpx solid rgba(255, 255, 255, 0.55);
box-shadow: 0 6rpx 22rpx rgba(120, 60, 160, 0.35);
display: flex;
align-items: center;
justify-content: center;
}
.laser-close-x {
color: #ffffff;
font-size: 44rpx;
line-height: 1;
font-weight: 300;
}
.content-wrapper--laser {
padding-top: calc(112rpx + constant(safe-area-inset-top));
padding-top: calc(112rpx + env(safe-area-inset-top));
}
.upload-section--laser .upload-box-wrap {
position: relative;
width: 100%;
max-width: 640rpx;
margin: 0 auto;
}
.upload-section--laser .upload-hint {
color: rgba(255, 255, 255, 0.88);
font-size: 22rpx;
margin-top: 16rpx;
}
.upload-section--laser .upload-clear {
position: absolute;
top: 14rpx;
right: 14rpx;
z-index: 6;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.upload-clear--laser {
background: rgba(255, 255, 255, 0.96);
border: 2rpx solid rgba(255, 255, 255, 0.95);
box-shadow: 0 6rpx 18rpx rgba(60, 30, 100, 0.22);
}
.upload-clear-x--laser {
color: #3d6fd8;
font-size: 34rpx;
font-weight: 400;
}
.form-section--laser {
gap: 36rpx;
}
.form-label--laser,
.section-title--laser {
font-size: 28rpx;
padding: 10rpx 22rpx;
border-radius: 999rpx;
background: linear-gradient(95deg, #ff7ec8 0%, #ff9f7a 48%, #ffb35c 100%);
color: #ffffff;
text-shadow: 0 2rpx 6rpx rgba(160, 40, 80, 0.35);
box-shadow: 0 4rpx 14rpx rgba(255, 100, 140, 0.28);
}
.ai-section--laser {
gap: 20rpx;
}
.picker-display--laser {
background: rgba(255, 255, 255, 0.32);
backdrop-filter: blur(22rpx);
-webkit-backdrop-filter: blur(22rpx);
border: 2rpx solid rgba(255, 255, 255, 0.45);
box-shadow: 0 8rpx 28rpx rgba(50, 20, 90, 0.12);
}
.form-textarea--laser {
background: rgba(255, 255, 255, 0.32);
backdrop-filter: blur(22rpx);
-webkit-backdrop-filter: blur(22rpx);
border: 2rpx solid rgba(255, 255, 255, 0.42);
box-shadow: 0 8rpx 28rpx rgba(50, 20, 90, 0.1);
}
.picker-arrow.picker-arrow--laser {
transform: rotate(0deg);
font-size: 22rpx;
line-height: 1;
color: rgba(255, 255, 255, 0.88);
font-weight: 600;
}
.picker-arrow.picker-arrow--laser.picker-arrow-up {
transform: rotate(180deg);
}
.ai-input-wrapper--laser {
flex-direction: column;
min-height: 280rpx;
padding: 24rpx 28rpx 28rpx;
background: rgba(255, 255, 255, 0.32);
backdrop-filter: blur(22rpx);
-webkit-backdrop-filter: blur(22rpx);
border: 2rpx solid rgba(255, 255, 255, 0.45);
box-shadow: 0 8rpx 28rpx rgba(50, 20, 90, 0.1);
}
.ai-input-wrapper--laser .ai-input {
min-height: 220rpx;
width: 100%;
}
.button-section--laser {
gap: 24rpx;
margin-top: 48rpx;
}
.btn-secondary--laser {
background: rgba(255, 255, 255, 0.18);
border-color: rgba(255, 255, 255, 0.35);
}
.btn-confirm-laser {
flex: 1;
height: 96rpx;
line-height: 96rpx;
border-radius: 48rpx;
font-size: 36rpx;
font-family: 'yt', sans-serif;
font-weight: 600;
color: #ffffff;
border: none;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(95deg, #ff8ec5 0%, #ff5a8c 38%, #ff2d6b 72%, #ff4d64 100%);
box-shadow: 0 10rpx 32rpx rgba(255, 50, 110, 0.42);
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.25);
}
.btn-confirm-laser::after {
border: none;
}
.btn-confirm-laser:active {
opacity: 0.92;
}
.nav-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
z-index: 999;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>