chore: 归档当前现有代码,准备新建分支开发插件验证
This commit is contained in:
parent
6e09afe843
commit
fe7fbba1fc
53
docker/sql/migrations/V5__mint_cost_config.sql
Normal file
53
docker/sql/migrations/V5__mint_cost_config.sql
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
-- 铸造经济:按「第几次铸爱」阶梯扣水晶(见 docs/specs/2026-04-15-economic-system-design.md)
|
||||||
|
-- 未建表/未插数时,资产服务查配置会得到 record not found,网关表现为 code 17。
|
||||||
|
|
||||||
|
-- 用户在某 star 下的累计铸爱次数(CreateMintOrder 会读/写)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.user_mint_count (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
star_id BIGINT NOT NULL,
|
||||||
|
mint_count INT NOT NULL DEFAULT 0,
|
||||||
|
revenue_boost_bps INT NOT NULL DEFAULT 0,
|
||||||
|
updated_at BIGINT NOT NULL,
|
||||||
|
CONSTRAINT uk_user_mint_count_user_star UNIQUE (user_id, star_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_mint_count_user ON public.user_mint_count USING btree (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_mint_count_star ON public.user_mint_count USING btree (star_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.user_mint_count IS '用户在某偶像下的累计铸爱次数与收益加成(基点)';
|
||||||
|
|
||||||
|
-- 第 N 次铸爱对应扣费与保底概率(mint_count 唯一)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.mint_cost_config (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
mint_count INT NOT NULL,
|
||||||
|
cost_crystal BIGINT NOT NULL,
|
||||||
|
probability BIGINT NOT NULL DEFAULT 0,
|
||||||
|
reward_type VARCHAR(50) DEFAULT NULL,
|
||||||
|
reward_value BIGINT NOT NULL DEFAULT 0,
|
||||||
|
description VARCHAR(255),
|
||||||
|
updated_at BIGINT NOT NULL,
|
||||||
|
CONSTRAINT uk_mint_cost_config_mint_count UNIQUE (mint_count)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.mint_cost_config IS '铸爱次数阶梯消耗与保底配置';
|
||||||
|
|
||||||
|
INSERT INTO public.mint_cost_config (mint_count, cost_crystal, probability, reward_type, reward_value, description, updated_at)
|
||||||
|
VALUES
|
||||||
|
(1, 2, 0, NULL, 0, '第1次', 1773322573872),
|
||||||
|
(2, 4, 0, NULL, 0, '第2次', 1773322573872),
|
||||||
|
(3, 8, 0, NULL, 0, '第3次', 1773322573872),
|
||||||
|
(4, 16, 0, NULL, 0, '第4次', 1773322573872),
|
||||||
|
(5, 32, 0, NULL, 0, '第5次', 1773322573872),
|
||||||
|
(6, 64, 0, NULL, 0, '第6次', 1773322573872),
|
||||||
|
(7, 128, 0, NULL, 0, '第7次', 1773322573872),
|
||||||
|
(8, 256, 0, NULL, 0, '第8次', 1773322573872),
|
||||||
|
(9, 512, 20, '收益提升', 5, '小保底', 1773322573872),
|
||||||
|
(10, 1024, 100, '收益提升', 5, '大保底', 1773322573872)
|
||||||
|
ON CONFLICT (mint_count) DO UPDATE SET
|
||||||
|
cost_crystal = EXCLUDED.cost_crystal,
|
||||||
|
probability = EXCLUDED.probability,
|
||||||
|
reward_type = EXCLUDED.reward_type,
|
||||||
|
reward_value = EXCLUDED.reward_value,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
updated_at = EXCLUDED.updated_at;
|
||||||
@ -24,11 +24,13 @@
|
|||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script>
|
||||||
defineOptions({
|
export default {
|
||||||
inheritAttrs: false
|
inheritAttrs: false,
|
||||||
})
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: {
|
visible: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
|||||||
55
frontend/components/lenticular/CastloveLenticularPreview.vue
Normal file
55
frontend/components/lenticular/CastloveLenticularPreview.vue
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<view class="lc-wrap">
|
||||||
|
<view class="lc-frame">
|
||||||
|
<LenticularCard
|
||||||
|
:layers="lenticularLayers"
|
||||||
|
:transforms="layerTransforms"
|
||||||
|
gyro-source="simulation"
|
||||||
|
:approximate-preview="true"
|
||||||
|
tilt-hint-text="滑动卡片预览光栅效果"
|
||||||
|
@simulate="simulate"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import LenticularCard from './LenticularCard.vue'
|
||||||
|
import { useLenticularPreview } from '@/composables/useLenticularPreview.js'
|
||||||
|
import { buildLenticularLayers } from '@/utils/castloveMintForm.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** 用户上传图:作为光栅主体层 */
|
||||||
|
imageSrc: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const lenticularLayers = ref(buildLenticularLayers(props.imageSrc || ''))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.imageSrc,
|
||||||
|
(s) => {
|
||||||
|
lenticularLayers.value = buildLenticularLayers(s || '')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { layerTransforms, simulate } = useLenticularPreview(lenticularLayers)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lc-wrap {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16rpx 0 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 520rpx;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
352
frontend/components/lenticular/LenticularCard.vue
Normal file
352
frontend/components/lenticular/LenticularCard.vue
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
<template>
|
||||||
|
<view class="card-container">
|
||||||
|
<view
|
||||||
|
:id="cardId"
|
||||||
|
class="card-frame"
|
||||||
|
@touchstart.stop="onFrameTouchStart"
|
||||||
|
@touchmove.stop.prevent="onFrameTouchMove"
|
||||||
|
@touchend.stop="onFrameTouchEnd"
|
||||||
|
@touchcancel.stop="onFrameTouchEnd"
|
||||||
|
>
|
||||||
|
<view class="card-body" :style="cardRotateStyle">
|
||||||
|
<view
|
||||||
|
v-for="layer in layers"
|
||||||
|
:key="layer.id"
|
||||||
|
class="card-layer"
|
||||||
|
:style="getLayerStyle(layer)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="layer.src"
|
||||||
|
class="card-layer-img"
|
||||||
|
:src="layer.src"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<view
|
||||||
|
v-for="(dot, i) in layer.dots || []"
|
||||||
|
:key="i"
|
||||||
|
class="card-layer-dot"
|
||||||
|
:style="getDotStyle(dot)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="lenticular-shimmer" :style="shimmerStyle" />
|
||||||
|
<view class="lenticular-tint" />
|
||||||
|
<view class="glass-rim" />
|
||||||
|
<view class="vignette" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="showHint && tiltHintText" class="tilt-hint">
|
||||||
|
<text class="tilt-hint-icon">↻</text>
|
||||||
|
<text class="tilt-hint-text">{{ tiltHintText }}</text>
|
||||||
|
<text v-if="approximatePreview" class="tilt-hint-sub">叠化近似预览(倾斜或拖动体验光栅效果)</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, getCurrentInstance, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
layers: { type: Array, required: true },
|
||||||
|
transforms: { type: Object, required: true },
|
||||||
|
gyroSource: { type: String, default: 'simulation' },
|
||||||
|
approximatePreview: { type: Boolean, default: true },
|
||||||
|
skipBuiltInTouch: { type: Boolean, default: false },
|
||||||
|
tiltHintText: { type: String, default: '倾斜手机预览' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['simulate'])
|
||||||
|
|
||||||
|
const showHint = ref(true)
|
||||||
|
const cardId = `lcard-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
const cardRect = ref(null)
|
||||||
|
/** Vue3 + uni:createSelectorQuery().in() 必须传组件 public 实例(proxy),传 internal instance 会运行时报错导致整页白屏 */
|
||||||
|
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,
|
||||||
|
() => {
|
||||||
|
if (shouldHideTiltHint()) showHint.value = false
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const tiltVisualX = computed(() => {
|
||||||
|
let num = 0
|
||||||
|
let den = 0
|
||||||
|
for (const layer of props.layers) {
|
||||||
|
const t = props.transforms[layer.id]
|
||||||
|
if (!t) continue
|
||||||
|
const w = Math.max(0.06, t.opacity)
|
||||||
|
num += t.x * w
|
||||||
|
den += w
|
||||||
|
}
|
||||||
|
return den > 1e-6 ? num / den : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const cardRotateStyle = computed(() => {
|
||||||
|
const x = tiltVisualX.value
|
||||||
|
const rotateY = (x / 120) * 12
|
||||||
|
return {
|
||||||
|
transform: `perspective(1000px) rotateY(${rotateY}deg) rotateX(0deg)`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const shimmerStyle = computed(() => {
|
||||||
|
const x = tiltVisualX.value
|
||||||
|
const angle = 135 + (x / 120) * 60
|
||||||
|
return {
|
||||||
|
background: `linear-gradient(${angle}deg, transparent 30%, rgba(221,183,255,0.10) 50%, transparent 70%)`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function getLayerStyle(layer) {
|
||||||
|
const t = props.transforms[layer.id]
|
||||||
|
const baseOpacity = t ? t.opacity : layer.opacity
|
||||||
|
const x = t ? t.x : 0
|
||||||
|
// 不在行内样式写 mix-blend-mode:部分小程序 / WebView 对非 normal 支持差,可能引发渲染异常
|
||||||
|
return {
|
||||||
|
opacity: baseOpacity,
|
||||||
|
transform: `translate3d(${x}px, 0, 0)`,
|
||||||
|
background: layer.background || 'transparent',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDotStyle(dot) {
|
||||||
|
const color = dot.color || '#dae2fd'
|
||||||
|
return {
|
||||||
|
left: `${dot.x}%`,
|
||||||
|
top: `${dot.y}%`,
|
||||||
|
width: `${dot.r * 2}px`,
|
||||||
|
height: `${dot.r * 2}px`,
|
||||||
|
background: color,
|
||||||
|
opacity: dot.opacity != null ? dot.opacity : 1,
|
||||||
|
boxShadow: `0 0 ${dot.r * 3}px ${color}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshRect() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const query = pageProxy ? uni.createSelectorQuery().in(pageProxy) : uni.createSelectorQuery()
|
||||||
|
query
|
||||||
|
.select(`#${cardId}`)
|
||||||
|
.boundingClientRect((rect) => {
|
||||||
|
if (rect && rect.width) {
|
||||||
|
cardRect.value = {
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.top,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
.exec()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[LenticularCard] refreshRect failed', e)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatch(clientX, clientY) {
|
||||||
|
const rect = cardRect.value
|
||||||
|
if (!rect || !rect.width || !rect.height) {
|
||||||
|
void refreshRect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const cx = rect.left + rect.width / 2
|
||||||
|
const cy = rect.top + rect.height / 2
|
||||||
|
const nx = ((clientX - cx) / (rect.width / 2)) * 1.1
|
||||||
|
const ny = ((clientY - cy) / (rect.height / 2)) * 1.1
|
||||||
|
emit(
|
||||||
|
'simulate',
|
||||||
|
Math.max(-1, Math.min(1, nx)),
|
||||||
|
Math.max(-1, Math.min(1, ny))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e) {
|
||||||
|
if (props.skipBuiltInTouch) return
|
||||||
|
const touch = e.touches && e.touches[0] ? e.touches[0] : e.changedTouches && e.changedTouches[0] ? e.changedTouches[0] : null
|
||||||
|
if (!touch) return
|
||||||
|
dispatch(touch.clientX, touch.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFrameTouchStart(e) {
|
||||||
|
if (props.skipBuiltInTouch) return
|
||||||
|
onTouchMove(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFrameTouchMove(e) {
|
||||||
|
if (props.skipBuiltInTouch) return
|
||||||
|
onTouchMove(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFrameTouchEnd() {
|
||||||
|
if (props.skipBuiltInTouch) return
|
||||||
|
emit('simulate', 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
void refreshRect()
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-frame {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
width: auto;
|
||||||
|
max-width: min(420px, 94vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.55);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
background-color: #060e20;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-layer {
|
||||||
|
position: absolute;
|
||||||
|
top: -6%;
|
||||||
|
left: -6%;
|
||||||
|
width: 112%;
|
||||||
|
height: 112%;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-layer-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-layer-dot {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lenticular-shimmer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lenticular-tint {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(221, 183, 255, 0.1) 0%,
|
||||||
|
transparent 35%,
|
||||||
|
transparent 65%,
|
||||||
|
rgba(76, 215, 246, 0.1) 100%
|
||||||
|
);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-rim {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 24px;
|
||||||
|
border-top: 1px solid rgba(221, 183, 255, 0.42);
|
||||||
|
box-shadow: inset 0 0 40px rgba(255, 255, 255, 0.05);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vignette {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(to top, rgba(6, 14, 32, 0.82), transparent 50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tilt-hint {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(45, 52, 73, 0.6);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
padding: 14px 22px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tilt-hint-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #ddb7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tilt-hint-text {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #dae2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tilt-hint-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: rgba(207, 194, 214, 0.75);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 200px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
155
frontend/composables/useLenticularPreview.js
Normal file
155
frontend/composables/useLenticularPreview.js
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* 光栅卡预览:触摸模拟倾斜 + LenticularEngine(不启动硬件传感器)
|
||||||
|
* @param {import('vue').Ref<Array>} layersRef 图层配置(reactive/ref 均可被 .value 读取时用 ref)
|
||||||
|
*/
|
||||||
|
import { reactive, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { LenticularEngine, DEFAULT_PHYSICS } from '@/utils/lenticular-engine.js'
|
||||||
|
|
||||||
|
function clamp(v, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLenticularPreview(layersRef) {
|
||||||
|
const physics = reactive({ ...DEFAULT_PHYSICS })
|
||||||
|
physics.gyroSimEnabled = false
|
||||||
|
|
||||||
|
const sensorData = ref({ gamma: 0, beta: 0, timestamp: Date.now() })
|
||||||
|
const source = ref('simulation')
|
||||||
|
|
||||||
|
function simulate(x, _y) {
|
||||||
|
sensorData.value = {
|
||||||
|
gamma: clamp(x, -1, 1),
|
||||||
|
beta: 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function relax(factor = 0.85) {
|
||||||
|
sensorData.value = {
|
||||||
|
gamma: sensorData.value.gamma * factor,
|
||||||
|
beta: 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = new LenticularEngine(physics)
|
||||||
|
const initial = layersRef.value || layersRef
|
||||||
|
engine.setLayers(Array.isArray(initial) ? initial : [])
|
||||||
|
|
||||||
|
const layerTransforms = ref({})
|
||||||
|
|
||||||
|
function rebuildTransformsFromLayers(ls) {
|
||||||
|
const prev = layerTransforms.value
|
||||||
|
const next = {}
|
||||||
|
for (const l of ls) {
|
||||||
|
const p = prev[l.id]
|
||||||
|
next[l.id] =
|
||||||
|
p != null ? p : { x: 0, y: 0, opacity: l.opacity }
|
||||||
|
}
|
||||||
|
layerTransforms.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuildTransformsFromLayers(engine.layers.length ? engine.layers : layersRef.value || [])
|
||||||
|
|
||||||
|
const stripeRender = ref({
|
||||||
|
phaseShift: 0,
|
||||||
|
shares: [1 / 3, 1 / 3, 1 / 3],
|
||||||
|
pitchPx: DEFAULT_PHYSICS.lenticularPitchPx,
|
||||||
|
prevLayerGhost: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
let rafId = null
|
||||||
|
const nextFrame =
|
||||||
|
typeof requestAnimationFrame === 'function'
|
||||||
|
? (cb) => requestAnimationFrame(cb)
|
||||||
|
: (cb) => setTimeout(cb, 16)
|
||||||
|
const cancelFrame =
|
||||||
|
typeof cancelAnimationFrame === 'function'
|
||||||
|
? (id) => cancelAnimationFrame(id)
|
||||||
|
: (id) => clearTimeout(id)
|
||||||
|
|
||||||
|
function getLayersArray() {
|
||||||
|
const v = layersRef.value !== undefined ? layersRef.value : layersRef
|
||||||
|
return Array.isArray(v) ? v : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
try {
|
||||||
|
const ls = getLayersArray()
|
||||||
|
const renderState = engine.feedSimulatedTilt(sensorData.value.gamma, sensorData.value.beta)
|
||||||
|
const next = {}
|
||||||
|
for (const layer of ls) {
|
||||||
|
const offset = renderState.layerOffsets.get(layer.id)
|
||||||
|
const opacity = renderState.layerOpacities.get(layer.id)
|
||||||
|
next[layer.id] = {
|
||||||
|
x: offset != null ? offset.x : 0,
|
||||||
|
y: offset != null ? offset.y : 0,
|
||||||
|
opacity: opacity != null ? opacity : layer.opacity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layerTransforms.value = next
|
||||||
|
stripeRender.value = {
|
||||||
|
phaseShift: renderState.stripePhaseShift,
|
||||||
|
shares: [...renderState.stripShares],
|
||||||
|
pitchPx: renderState.lenticularPitchPx,
|
||||||
|
prevLayerGhost: renderState.prevLayerGhost,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[useLenticularPreview] tick failed', e)
|
||||||
|
}
|
||||||
|
rafId = nextFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRenderLoop() {
|
||||||
|
if (rafId != null) return
|
||||||
|
rafId = nextFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRenderLoop() {
|
||||||
|
if (rafId != null) {
|
||||||
|
cancelFrame(rafId)
|
||||||
|
rafId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
layersRef,
|
||||||
|
(ls) => {
|
||||||
|
const arr = Array.isArray(ls) ? ls : []
|
||||||
|
engine.setLayers(arr)
|
||||||
|
rebuildTransformsFromLayers(arr)
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
startRenderLoop()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopRenderLoop()
|
||||||
|
})
|
||||||
|
|
||||||
|
const gyro = {
|
||||||
|
sensorData,
|
||||||
|
source,
|
||||||
|
simulate,
|
||||||
|
relax,
|
||||||
|
start: () => {},
|
||||||
|
stop: () => {
|
||||||
|
source.value = 'simulation'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
physics,
|
||||||
|
layerTransforms,
|
||||||
|
stripeRender,
|
||||||
|
gyro,
|
||||||
|
simulate,
|
||||||
|
relax,
|
||||||
|
engine,
|
||||||
|
startRenderLoop,
|
||||||
|
stopRenderLoop,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -134,6 +134,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/castlove/lenticular-studio",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"app-plus": {
|
||||||
|
"bounce": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/castlove/laser-card-studio",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"backgroundColor": "#0a0a0a",
|
||||||
|
"app-plus": {
|
||||||
|
"bounce": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/castlove/craft-select",
|
"path": "pages/castlove/craft-select",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@ -5,9 +5,12 @@
|
|||||||
<!-- 左侧图片卡片区域 - 半圆弧形布局 -->
|
<!-- 左侧图片卡片区域 - 半圆弧形布局 -->
|
||||||
<view class="cards-container">
|
<view class="cards-container">
|
||||||
<view v-for="(card, index) in cardList" :key="index" class="card-item"
|
<view v-for="(card, index) in cardList" :key="index" class="card-item"
|
||||||
:class="{ 'card-selected': selectedIndex === index }" :style="getCardStyle(index)"
|
:class="{ 'card-selected': selectedIndex === index }" :style="getCardStyle(index)">
|
||||||
@click="selectCard(index)">
|
<view
|
||||||
<view class="card-frame" :class="{ 'no-border': card.comingSoon }">
|
class="card-frame"
|
||||||
|
:class="{ 'no-border': card.comingSoon, 'card-frame--tappable': !card.comingSoon }"
|
||||||
|
@tap.stop="onCardFrameTap(index)"
|
||||||
|
>
|
||||||
<image class="card-image" :src="card.image" mode="aspectFill" />
|
<image class="card-image" :src="card.image" mode="aspectFill" />
|
||||||
<image v-if="card.comingSoon" class="coming-soon-badge" src="/static/castlove/jinqingqidai.png" mode="aspectFit" />
|
<image v-if="card.comingSoon" class="coming-soon-badge" src="/static/castlove/jinqingqidai.png" mode="aspectFit" />
|
||||||
</view>
|
</view>
|
||||||
@ -48,6 +51,18 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
onShow() {
|
||||||
|
try {
|
||||||
|
uni.hideToast();
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
uni.hideLoading();
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
selectedIndex: 2, // 初始选中光栅卡
|
selectedIndex: 2, // 初始选中光栅卡
|
||||||
@ -99,6 +114,35 @@ export default {
|
|||||||
selectCard(index) {
|
selectCard(index) {
|
||||||
this.selectedIndex = index;
|
this.selectedIndex = index;
|
||||||
},
|
},
|
||||||
|
/** 当前叠卡在弧形布局中的槽位:2=正中主图(最大),1=中上「第二张」叠层,0最上… */
|
||||||
|
getCardStackPosition(index) {
|
||||||
|
return (index - this.selectedIndex + 2 + 5) % 5;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 点击卡图区域:
|
||||||
|
* - 正中主图(槽位 2)→ 进入对应工艺创建页(光栅卡即进入已接入预览的 create)
|
||||||
|
* - 中上叠层(槽位 1,常为拍立得示意)在选中光栅时 → 同样进入光栅卡创建(与设计稿「点第二张进光栅」一致)
|
||||||
|
* - 其余叠层 → 仅切换选中
|
||||||
|
*/
|
||||||
|
onCardFrameTap(index) {
|
||||||
|
const card = this.cardList[index];
|
||||||
|
if (!card || card.comingSoon) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pos = this.getCardStackPosition(index);
|
||||||
|
const LENTICULAR_INDEX = 2;
|
||||||
|
const goLenticular =
|
||||||
|
this.selectedIndex === LENTICULAR_INDEX &&
|
||||||
|
(pos === 2 || pos === 1);
|
||||||
|
const targetName = goLenticular ? '光栅卡' : card.name;
|
||||||
|
if (pos === 2 || goLenticular) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/castlove/create?name=${encodeURIComponent(targetName)}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.selectCard(index);
|
||||||
|
},
|
||||||
handleBack() {
|
handleBack() {
|
||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
},
|
},
|
||||||
@ -115,13 +159,14 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleSkip() {
|
handleSkip() {
|
||||||
// 跳过,选择默认卡片或第一张
|
const card = this.cardList[this.selectedIndex]
|
||||||
const defaultIndex = this.cardList.findIndex(card => !card.comingSoon);
|
if (!card || card.comingSoon) {
|
||||||
if (defaultIndex !== -1) {
|
uni.showToast({ title: '请选择已开放的工艺', icon: 'none' })
|
||||||
uni.navigateTo({
|
return
|
||||||
url: `/pages/castlove/create?name=${encodeURIComponent(this.cardList[defaultIndex].name)}`
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/castlove/create?name=${encodeURIComponent(card.name)}`
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -156,8 +201,10 @@ export default {
|
|||||||
width: 344rpx;
|
width: 344rpx;
|
||||||
height: 344rpx;
|
height: 344rpx;
|
||||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
cursor: pointer;
|
}
|
||||||
|
|
||||||
|
.card-frame--tappable {
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-frame {
|
.card-frame {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
3336
frontend/pages/castlove/laser-card-studio.vue
Normal file
3336
frontend/pages/castlove/laser-card-studio.vue
Normal file
File diff suppressed because it is too large
Load Diff
657
frontend/pages/castlove/lenticular-studio.vue
Normal file
657
frontend/pages/castlove/lenticular-studio.vue
Normal file
@ -0,0 +1,657 @@
|
|||||||
|
<template>
|
||||||
|
<view class="physics-page">
|
||||||
|
<view class="top-bar">
|
||||||
|
<view class="top-bar-btn" @tap="goBack">
|
||||||
|
<text class="nav-glyph">←</text>
|
||||||
|
</view>
|
||||||
|
<view class="top-bar-title">
|
||||||
|
<text class="top-bar-title-text">光栅卡工作室</text>
|
||||||
|
<text class="top-bar-subtitle">倾斜手机 · 重力感应预览</text>
|
||||||
|
</view>
|
||||||
|
<view class="top-bar-btn" @tap="onMore">
|
||||||
|
<text class="nav-glyph">⋮</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="canvas-wrap">
|
||||||
|
<view class="canvas-bg" />
|
||||||
|
<LenticularCard
|
||||||
|
class="preview-stack"
|
||||||
|
:layers="layers"
|
||||||
|
:transforms="layerTransforms"
|
||||||
|
:gyro-source="gyroSourceLabel"
|
||||||
|
:skip-built-in-touch="true"
|
||||||
|
:tilt-hint-text="tiltHintText"
|
||||||
|
@simulate="simulate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<view class="preview-controls" @tap.stop>
|
||||||
|
<view class="recalib-chip" @tap.stop="onRecalibrate">
|
||||||
|
<text class="recalib-chip-text">校零(水平对准)</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="gyroHintLine" class="preview-controls-hint">{{ gyroHintLine }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tool-dock">
|
||||||
|
<view class="dock-rim" />
|
||||||
|
<view class="layer-strip">
|
||||||
|
<view class="layer-strip-header">
|
||||||
|
<text class="layer-strip-title">图层素材</text>
|
||||||
|
<text class="layer-strip-hint">点击替换 · 长按重置</text>
|
||||||
|
</view>
|
||||||
|
<view class="layer-slots">
|
||||||
|
<view
|
||||||
|
v-for="layer in layers"
|
||||||
|
:key="layer.id"
|
||||||
|
class="layer-slot"
|
||||||
|
:class="{ 'layer-slot--filled': !!layer.src }"
|
||||||
|
@tap="pickImage(layer.id)"
|
||||||
|
@longpress="resetLayer(layer.id)"
|
||||||
|
>
|
||||||
|
<image v-if="layer.src" class="layer-slot-img" :src="layer.src" mode="aspectFill" />
|
||||||
|
<view v-else class="layer-slot-placeholder" :style="placeholderStyle(layer)">
|
||||||
|
<text class="layer-slot-icon">+</text>
|
||||||
|
</view>
|
||||||
|
<view class="layer-slot-meta">
|
||||||
|
<text class="layer-slot-name">{{ layer.label }}</text>
|
||||||
|
<text class="layer-slot-tag">{{ layer.src ? '已上传' : '点击上传' }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="layer.src" class="layer-slot-badge" @tap.stop="resetLayer(layer.id)">
|
||||||
|
<text class="layer-slot-badge-icon">×</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
inheritAttrs: false,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import LenticularCard from '@/components/lenticular/LenticularCard.vue'
|
||||||
|
import { useLenticularPreview } from '@/composables/useLenticularPreview.js'
|
||||||
|
import {
|
||||||
|
buildLenticularLayersTwo,
|
||||||
|
LENTICULAR_STUDIO_STORAGE_KEY,
|
||||||
|
} from '@/utils/castloveMintForm.js'
|
||||||
|
|
||||||
|
const layers = ref([])
|
||||||
|
const gyroSourceLabel = ref('simulation')
|
||||||
|
|
||||||
|
let accelHandler = null
|
||||||
|
let accelSmoothed = 0
|
||||||
|
/** 平面内重力方向角增量积分,用于持续同向倾斜时周期性叠化 */
|
||||||
|
let rollAccum = 0
|
||||||
|
let prevCu = null
|
||||||
|
let prevCv = null
|
||||||
|
|
||||||
|
/** 基线帧数:过长时用户若在采样期小幅晃动,会把「水平」拉进摆动中间,相对角变小、体感发木 */
|
||||||
|
const BASELINE_FRAMES = 10
|
||||||
|
/** 瞬时相对基线角 → [-1,1] 的弧度尺度(越小越灵敏) */
|
||||||
|
const INSTANT_FULL_RAD = 0.32
|
||||||
|
/** 相邻帧转角上限(弧度),抑制噪声尖峰 */
|
||||||
|
const MAX_STEP_RAD = 0.48
|
||||||
|
/** 角增量放大:越大同向拧动积分越快、周期切换越快 */
|
||||||
|
const STEP_GAIN = 2.15
|
||||||
|
/** sin 相位倍率:越大同样积分下周期越多 */
|
||||||
|
const SIN_PHASE_SCALE = 1.18
|
||||||
|
/** 输出 = 瞬时项 + 周期项 的混合 */
|
||||||
|
const BLEND_INSTANT = 0.38
|
||||||
|
|
||||||
|
let tiltCal = {
|
||||||
|
lockedMode: null,
|
||||||
|
planeVotes: [],
|
||||||
|
sumU: 0,
|
||||||
|
sumV: 0,
|
||||||
|
count: 0,
|
||||||
|
ready: false,
|
||||||
|
bu: 1,
|
||||||
|
bv: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTiltCalibration() {
|
||||||
|
tiltCal = {
|
||||||
|
lockedMode: null,
|
||||||
|
planeVotes: [],
|
||||||
|
sumU: 0,
|
||||||
|
sumV: 0,
|
||||||
|
count: 0,
|
||||||
|
ready: false,
|
||||||
|
bu: 1,
|
||||||
|
bv: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function majorityPlane(votes) {
|
||||||
|
const cnt = { xz: 0, xy: 0, yz: 0 }
|
||||||
|
for (const v of votes) cnt[v] = (cnt[v] || 0) + 1
|
||||||
|
if (cnt.xz >= cnt.xy && cnt.xz >= cnt.yz) return 'xz'
|
||||||
|
if (cnt.xy >= cnt.yz) return 'xy'
|
||||||
|
return 'yz'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重力主要沿哪条设备轴:在该轴上投影小,用另两轴构成平面测「绕该轴的滚转」 */
|
||||||
|
function detectGravityPlane(nx, ny, nz) {
|
||||||
|
const ax = Math.abs(nx)
|
||||||
|
const ay = Math.abs(ny)
|
||||||
|
const az = Math.abs(nz)
|
||||||
|
if (ay >= ax && ay >= az) return 'xz'
|
||||||
|
if (az >= ax && az >= ay) return 'xy'
|
||||||
|
return 'yz'
|
||||||
|
}
|
||||||
|
|
||||||
|
function uvForPlane(nx, ny, nz, mode) {
|
||||||
|
if (mode === 'xz') return { u: nx, v: nz }
|
||||||
|
if (mode === 'xy') return { u: nx, v: ny }
|
||||||
|
return { u: ny, v: nz }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {null | { warmup: boolean, cu?: number, cv?: number, deltaRad?: number }}
|
||||||
|
*/
|
||||||
|
function accelToRelativeTilt01(ax, ay, az) {
|
||||||
|
const gMag = Math.hypot(ax, ay, az)
|
||||||
|
if (gMag < 0.18) return null
|
||||||
|
const nx = ax / gMag
|
||||||
|
const ny = ay / gMag
|
||||||
|
const nz = az / gMag
|
||||||
|
|
||||||
|
if (!tiltCal.ready) {
|
||||||
|
const m = detectGravityPlane(nx, ny, nz)
|
||||||
|
if (tiltCal.planeVotes.length < 5) tiltCal.planeVotes.push(m)
|
||||||
|
if (tiltCal.planeVotes.length >= 5 && tiltCal.lockedMode == null) {
|
||||||
|
tiltCal.lockedMode = majorityPlane(tiltCal.planeVotes)
|
||||||
|
tiltCal.sumU = 0
|
||||||
|
tiltCal.sumV = 0
|
||||||
|
tiltCal.count = 0
|
||||||
|
}
|
||||||
|
if (tiltCal.lockedMode == null) return { warmup: true }
|
||||||
|
const { u, v } = uvForPlane(nx, ny, nz, tiltCal.lockedMode)
|
||||||
|
const h = Math.hypot(u, v)
|
||||||
|
if (h < 0.035) return { warmup: true }
|
||||||
|
tiltCal.sumU += u / h
|
||||||
|
tiltCal.sumV += v / h
|
||||||
|
tiltCal.count += 1
|
||||||
|
if (tiltCal.count >= BASELINE_FRAMES) {
|
||||||
|
const bh = Math.hypot(tiltCal.sumU, tiltCal.sumV) || 1
|
||||||
|
tiltCal.bu = tiltCal.sumU / bh
|
||||||
|
tiltCal.bv = tiltCal.sumV / bh
|
||||||
|
tiltCal.ready = true
|
||||||
|
}
|
||||||
|
return { warmup: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { u, v } = uvForPlane(nx, ny, nz, tiltCal.lockedMode)
|
||||||
|
const h = Math.hypot(u, v)
|
||||||
|
if (h < 0.028) return null
|
||||||
|
const cu = u / h
|
||||||
|
const cv = v / h
|
||||||
|
const sinD = cu * tiltCal.bv - cv * tiltCal.bu
|
||||||
|
const cosD = cu * tiltCal.bu + cv * tiltCal.bv
|
||||||
|
const deltaRad = Math.atan2(sinD, cosD)
|
||||||
|
return { warmup: false, cu, cv, deltaRad }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { physics, layerTransforms, simulate } = useLenticularPreview(layers)
|
||||||
|
|
||||||
|
const tiltHintText = computed(() => '缓慢扭动手机 · 「校零」回中')
|
||||||
|
|
||||||
|
const gyroHintLine = computed(() => {
|
||||||
|
if (gyroSourceLabel.value === 'accelerometer') {
|
||||||
|
return '重力计驱动 · 持续同向倾斜会反复叠化;可点「校零」'
|
||||||
|
}
|
||||||
|
return '倾斜驱动预览中;若无效请检查权限或换真机'
|
||||||
|
})
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack({
|
||||||
|
delta: 1,
|
||||||
|
fail: () => {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMore() {
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: ['恢复为进入工作室时的图片'],
|
||||||
|
success: (res) => {
|
||||||
|
if (res.tapIndex === 0) {
|
||||||
|
const raw = uni.getStorageSync(LENTICULAR_STUDIO_STORAGE_KEY)
|
||||||
|
if (!raw) return
|
||||||
|
try {
|
||||||
|
const p = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||||
|
layers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '')
|
||||||
|
uni.showToast({ title: '已恢复', icon: 'none' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeholderStyle(layer) {
|
||||||
|
if (layer.background) {
|
||||||
|
return { background: layer.background }
|
||||||
|
}
|
||||||
|
return { background: 'rgba(6,14,32,0.55)' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickImage(layerId) {
|
||||||
|
uni.chooseImage({
|
||||||
|
count: 1,
|
||||||
|
sizeType: ['compressed'],
|
||||||
|
sourceType: ['album', 'camera'],
|
||||||
|
success: (res) => {
|
||||||
|
const path = res.tempFilePaths && res.tempFilePaths[0]
|
||||||
|
if (!path) return
|
||||||
|
const idx = layers.value.findIndex((l) => l.id === layerId)
|
||||||
|
if (idx < 0) return
|
||||||
|
const cur = layers.value[idx]
|
||||||
|
const next = { ...cur, src: path, background: undefined }
|
||||||
|
if (layerId === 'base') next.dots = undefined
|
||||||
|
const copy = [...layers.value]
|
||||||
|
copy[idx] = next
|
||||||
|
layers.value = copy
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
const msg = (err && err.errMsg) || ''
|
||||||
|
if (!msg.includes('cancel')) {
|
||||||
|
uni.showToast({ title: '选择图片失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLayer(layerId) {
|
||||||
|
const fresh = buildLenticularLayersTwo('', '')
|
||||||
|
const defLayer = fresh.find((l) => l.id === layerId)
|
||||||
|
const idx = layers.value.findIndex((l) => l.id === layerId)
|
||||||
|
if (idx < 0 || !defLayer) return
|
||||||
|
const copy = [...layers.value]
|
||||||
|
copy[idx] = { ...defLayer }
|
||||||
|
layers.value = copy
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAccel() {
|
||||||
|
try {
|
||||||
|
if (accelHandler && typeof uni.offAccelerometerChange === 'function') {
|
||||||
|
uni.offAccelerometerChange(accelHandler)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
accelHandler = null
|
||||||
|
try {
|
||||||
|
uni.stopAccelerometer({})
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAccel() {
|
||||||
|
stopAccel()
|
||||||
|
resetTiltCalibration()
|
||||||
|
accelSmoothed = 0
|
||||||
|
rollAccum = 0
|
||||||
|
prevCu = null
|
||||||
|
prevCv = null
|
||||||
|
accelHandler = (res) => {
|
||||||
|
const ax = Number(res.x) || 0
|
||||||
|
const ay = Number(res.y) || 0
|
||||||
|
const az = Number(res.z) || 0
|
||||||
|
const out = accelToRelativeTilt01(ax, ay, az)
|
||||||
|
if (out == null) return
|
||||||
|
if (out.warmup) return
|
||||||
|
const { cu, cv, deltaRad } = out
|
||||||
|
if (prevCu != null && prevCv != null) {
|
||||||
|
let step = Math.atan2(cu * prevCv - cv * prevCu, cu * prevCu + cv * prevCv)
|
||||||
|
if (step > Math.PI * 0.92) step -= 2 * Math.PI
|
||||||
|
if (step < -Math.PI * 0.92) step += 2 * Math.PI
|
||||||
|
step = Math.max(-MAX_STEP_RAD, Math.min(MAX_STEP_RAD, step))
|
||||||
|
rollAccum += step * STEP_GAIN
|
||||||
|
}
|
||||||
|
prevCu = cu
|
||||||
|
prevCv = cv
|
||||||
|
|
||||||
|
const instant = Math.max(-1, Math.min(1, deltaRad / INSTANT_FULL_RAD))
|
||||||
|
const cyclic = Math.sin(rollAccum * SIN_PHASE_SCALE)
|
||||||
|
const target = BLEND_INSTANT * instant + (1 - BLEND_INSTANT) * cyclic
|
||||||
|
|
||||||
|
accelSmoothed += (target - accelSmoothed) * 0.62
|
||||||
|
simulate(Math.max(-1, Math.min(1, accelSmoothed)), 0)
|
||||||
|
gyroSourceLabel.value = 'accelerometer'
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
uni.onAccelerometerChange(accelHandler)
|
||||||
|
const applyStart = (interval) => {
|
||||||
|
uni.startAccelerometer({
|
||||||
|
interval,
|
||||||
|
success: () => {
|
||||||
|
gyroSourceLabel.value = 'accelerometer'
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
if (interval === 'game') {
|
||||||
|
applyStart('normal')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gyroSourceLabel.value = 'simulation'
|
||||||
|
uni.showToast({ title: '无法启动加速度计', icon: 'none' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
applyStart('game')
|
||||||
|
} catch (e) {
|
||||||
|
gyroSourceLabel.value = 'simulation'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRecalibrate() {
|
||||||
|
resetTiltCalibration()
|
||||||
|
accelSmoothed = 0
|
||||||
|
rollAccum = 0
|
||||||
|
prevCu = null
|
||||||
|
prevCv = null
|
||||||
|
simulate(0, 0)
|
||||||
|
uni.showToast({ title: '已重置水平基准', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
try {
|
||||||
|
const raw = uni.getStorageSync(LENTICULAR_STUDIO_STORAGE_KEY)
|
||||||
|
if (!raw) {
|
||||||
|
uni.showToast({ title: '缺少素材数据,请返回上一步', icon: 'none' })
|
||||||
|
setTimeout(() => goBack(), 1600)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const p = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||||
|
layers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[lenticular-studio] load payload', e)
|
||||||
|
uni.showToast({ title: '数据无效', icon: 'none' })
|
||||||
|
setTimeout(() => goBack(), 1600)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
physics.angleStability = 16
|
||||||
|
physics.transitionSmoothness = 62
|
||||||
|
physics.tiltSensitivity = 96
|
||||||
|
physics.sensorDeadzoneStrength = 0
|
||||||
|
startAccel()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopAccel()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.nav-glyph {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.physics-page {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #0b1326;
|
||||||
|
color: #dae2fd;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
height: 56px;
|
||||||
|
padding-top: calc(env(safe-area-inset-top) + 4px);
|
||||||
|
background: rgba(11, 19, 38, 0.88);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-btn {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #ddb7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-title-text {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #dae2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-subtitle {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: #4cd7f6;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 10px 4px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stack {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 22;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 92vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recalib-chip {
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(45, 52, 73, 0.82);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recalib-chip-text {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ddb7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-controls-hint {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(207, 194, 214, 0.78);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 260px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 50% 35%, rgba(132, 43, 210, 0.18), transparent 55%),
|
||||||
|
radial-gradient(ellipse at 50% 85%, rgba(76, 215, 246, 0.1), transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-dock {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
margin: 0 10px calc(10px + env(safe-area-inset-bottom));
|
||||||
|
max-width: 460px;
|
||||||
|
align-self: center;
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
background: rgba(34, 42, 61, 0.88);
|
||||||
|
border: 1px solid rgba(77, 67, 84, 0.45);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.4);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-rim {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(to right, transparent, rgba(221, 183, 255, 0.45), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-strip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-strip-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-strip-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #dae2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-strip-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(207, 194, 214, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-slots {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-slot {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(6, 14, 32, 0.55);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-slot--filled {
|
||||||
|
border-color: rgba(76, 215, 246, 0.5);
|
||||||
|
box-shadow: 0 0 14px rgba(76, 215, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-slot-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 56px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-slot-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-slot-icon {
|
||||||
|
font-size: 22px;
|
||||||
|
color: rgba(221, 183, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-slot-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 6px 8px 8px;
|
||||||
|
gap: 2px;
|
||||||
|
background: rgba(6, 14, 32, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-slot-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #dae2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-slot-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(207, 194, 214, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-slot--filled .layer-slot-tag {
|
||||||
|
color: #4cd7f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-slot-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-slot-badge-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #dae2fd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -12,10 +12,10 @@
|
|||||||
</scroll-view>
|
</scroll-view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script>
|
||||||
defineOptions({
|
export default {
|
||||||
name: 'HorizontalScroll'
|
name: 'HorizontalScroll',
|
||||||
});
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -45,6 +45,7 @@ import DiscoverFeed from './discover-feed.vue';
|
|||||||
import CreateFeed from './create-feed.vue';
|
import CreateFeed from './create-feed.vue';
|
||||||
// 导入API
|
// 导入API
|
||||||
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js';
|
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js';
|
||||||
|
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js';
|
||||||
|
|
||||||
const activeTab = ref(0);
|
const activeTab = ref(0);
|
||||||
const formData = ref(null);
|
const formData = ref(null);
|
||||||
@ -97,7 +98,7 @@ const uploadImageToOss = async (base64Image, ossData) => {
|
|||||||
formData.append('x-oss-signature-version', ossData.x_oss_signature_version);
|
formData.append('x-oss-signature-version', ossData.x_oss_signature_version);
|
||||||
formData.append('file', blob, fileName);
|
formData.append('file', blob, fileName);
|
||||||
|
|
||||||
const response = await fetch(ossData.host, {
|
const response = await fetch(resolveH5OssPostUrl(ossData.host), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
@ -203,16 +204,16 @@ const handleSkip = async () => {
|
|||||||
// 更新加载提示
|
// 更新加载提示
|
||||||
uni.showLoading({ title: '创建订单中...', mask: true });
|
uni.showLoading({ title: '创建订单中...', mask: true });
|
||||||
|
|
||||||
// 构建订单数据
|
// 构建订单数据(对齐 CreateMintOrderRequestDTO)
|
||||||
const orderData = {
|
const orderData = {
|
||||||
name: orderValue.name,
|
order_id: orderId,
|
||||||
event: orderValue.event,
|
name: orderValue.name || '',
|
||||||
description: orderValue.remark || '',
|
|
||||||
material_type: orderValue.materialType || 'image',
|
|
||||||
material_url: imageUrl,
|
material_url: imageUrl,
|
||||||
rarity: 0,
|
description: orderValue.description || '',
|
||||||
tags: [],
|
grade: orderValue.grade ?? 0,
|
||||||
order_id: orderId
|
tags: Array.isArray(orderValue.tags) ? orderValue.tags : [],
|
||||||
|
material_type: orderValue.material_type || orderValue.materialType || '',
|
||||||
|
info: orderValue.info || orderValue.event || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 调用创建铸造订单API
|
// 调用创建铸造订单API
|
||||||
@ -229,11 +230,13 @@ const handleSkip = async () => {
|
|||||||
// 构建藏品数据,存储到temp_nft_data
|
// 构建藏品数据,存储到temp_nft_data
|
||||||
const nftData = {
|
const nftData = {
|
||||||
image: imageUrl,
|
image: imageUrl,
|
||||||
name: orderValue.name,
|
name: orderValue.name || '',
|
||||||
event: orderValue.event,
|
description: orderValue.description || orderValue.remark || '',
|
||||||
description: orderValue.remark || '',
|
material_type: orderValue.material_type || orderValue.materialType || '',
|
||||||
material_type: orderValue.materialType || 'image',
|
tags: Array.isArray(orderValue.tags) ? orderValue.tags : [],
|
||||||
order_id: orderId
|
order_id: orderId,
|
||||||
|
info: orderValue.info || '',
|
||||||
|
event: orderValue.info || orderValue.event || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 存储到storage
|
// 存储到storage
|
||||||
|
|||||||
@ -75,6 +75,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js';
|
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js';
|
||||||
|
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js';
|
||||||
|
|
||||||
const generatedImages = ref([]);
|
const generatedImages = ref([]);
|
||||||
const selectedIndex = ref(-1);
|
const selectedIndex = ref(-1);
|
||||||
@ -422,12 +423,12 @@ const uploadToOssH5 = async (base64Image, fileName, ossData) => {
|
|||||||
formData.append('file', blob, fileName);
|
formData.append('file', blob, fileName);
|
||||||
|
|
||||||
// 使用fetch上传
|
// 使用fetch上传
|
||||||
fetch(ossData.host, {
|
fetch(resolveH5OssPostUrl(ossData.host), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (response.ok) {
|
if (response.ok || response.status === 204) {
|
||||||
const imageUrl = `${ossData.host}/${ossData.dir}${fileName}`;
|
const imageUrl = `${ossData.host}/${ossData.dir}${fileName}`;
|
||||||
uni.hideLoading();
|
uni.hideLoading();
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
@ -656,17 +657,16 @@ const selectAsset = async () => {
|
|||||||
const formDataStr = uni.getStorageSync('castlove_form_data');
|
const formDataStr = uni.getStorageSync('castlove_form_data');
|
||||||
const orderValue = formDataStr ? JSON.parse(formDataStr) : {};
|
const orderValue = formDataStr ? JSON.parse(formDataStr) : {};
|
||||||
|
|
||||||
// 构建订单数据
|
// 构建订单数据(对齐 CreateMintOrderRequestDTO)
|
||||||
const orderData = {
|
const orderData = {
|
||||||
name: orderValue.name,
|
|
||||||
event: orderValue.event,
|
|
||||||
description: orderValue.description,
|
|
||||||
material_type: orderValue.material_type,
|
|
||||||
material_url: imageUrl,
|
|
||||||
rarity: 0,
|
|
||||||
tags: [],
|
|
||||||
order_id: currentOrderId.value,
|
order_id: currentOrderId.value,
|
||||||
info: orderValue.info || '' // 添加info字段
|
name: orderValue.name || '',
|
||||||
|
material_url: imageUrl,
|
||||||
|
description: orderValue.description || '',
|
||||||
|
grade: orderValue.grade ?? 0,
|
||||||
|
tags: Array.isArray(orderValue.tags) ? orderValue.tags : [],
|
||||||
|
material_type: orderValue.material_type || orderValue.materialType || '',
|
||||||
|
info: orderValue.info || orderValue.event || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 调用创建铸造订单API
|
// 调用创建铸造订单API
|
||||||
@ -679,12 +679,14 @@ const selectAsset = async () => {
|
|||||||
uni.removeStorageSync('castlove_form_data');
|
uni.removeStorageSync('castlove_form_data');
|
||||||
// 构建藏品数据,存储到temp_nft_data
|
// 构建藏品数据,存储到temp_nft_data
|
||||||
const nftData = {
|
const nftData = {
|
||||||
image: imageUrl, // 使用OSS URL
|
image: imageUrl,
|
||||||
name: orderValue.name,
|
name: orderValue.name || '',
|
||||||
event: orderValue.event,
|
description: orderValue.description || '',
|
||||||
description: orderValue.description,
|
material_type: orderValue.material_type || orderValue.materialType || '',
|
||||||
material_type: orderValue.material_type,
|
tags: Array.isArray(orderValue.tags) ? orderValue.tags : [],
|
||||||
order_id: currentOrderId.value // 使用ref保存的order_id
|
order_id: currentOrderId.value,
|
||||||
|
info: orderValue.info || '',
|
||||||
|
event: orderValue.info || orderValue.event || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 存储到storage
|
// 存储到storage
|
||||||
|
|||||||
@ -4,23 +4,28 @@
|
|||||||
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
// const baseURL = 'http://localhost:8080' // H5 开发用本机
|
// const baseURL = 'http://localhost:8080' // H5 开发用本机
|
||||||
const baseURL = 'http://192.168.110.60:8080' // H5 开发用本机
|
const baseURL = 'http://101.132.250.62:8080' // H5 开发用本机
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef APP-PLUS
|
// #ifdef APP-PLUS
|
||||||
// 开发调试:手机和电脑同一WiFi时用这个(改成你电脑IP)
|
// 开发调试:手机和电脑同一WiFi时用这个(改成你电脑IP)
|
||||||
// 上线后:改成实际服务器地址
|
// 上线后:改成实际服务器地址
|
||||||
const baseURL = 'http://192.168.110.60:8080'
|
// const baseURL = 'http://192.168.110.60:8080'
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// 服务器地址(正式上线用)
|
// 服务器地址(正式上线用)
|
||||||
// #ifdef APP-PLUS
|
// #ifdef APP-PLUS
|
||||||
// const baseURL = 'http://101.132.250.62:8080'
|
const baseURL = 'http://101.132.250.62:8080'
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// 是否使用模拟数据(开发调试时设为 true,后端API准备好后改为 false)
|
// 是否使用模拟数据(开发调试时设为 true,后端API准备好后改为 false)
|
||||||
const USE_MOCK_API = false
|
const USE_MOCK_API = false
|
||||||
|
|
||||||
|
/** 网关根地址(供 uni.uploadFile 等无法走 request 封装的场景拼接完整 URL) */
|
||||||
|
export function getApiBaseUrl() {
|
||||||
|
return String(baseURL).replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
// 模拟网络延迟
|
// 模拟网络延迟
|
||||||
function mockDelay(ms = 800) {
|
function mockDelay(ms = 800) {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms))
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
@ -414,15 +419,30 @@ export function removeAssetFromGalleryApi(slotId) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取OSS上传签名
|
/**
|
||||||
export function getOssSignatureApi(type) {
|
* 获取 OSS 浏览器直传凭证(PostObject 表单字段)
|
||||||
|
* 对应网关:GET /api/v1/assets/oss/upload-signature(与 /oss/signature 同一处理逻辑,此处走阶段一推荐路径)
|
||||||
|
* @param {'avatar'|'asset'} [type='asset'] 上传目录类型
|
||||||
|
* @param {string} [orderId] 可选;传入则与后续 POST /api/v1/assets/mints 使用同一 order_id(后端 InitMintOrder)
|
||||||
|
*/
|
||||||
|
export function getOssSignatureApi(type = 'asset', orderId = '') {
|
||||||
|
let url = `/api/v1/assets/oss/upload-signature?type=${encodeURIComponent(type)}`
|
||||||
|
if (orderId) {
|
||||||
|
url += `&order_id=${encodeURIComponent(orderId)}`
|
||||||
|
}
|
||||||
return request({
|
return request({
|
||||||
url: `/api/v1/assets/oss/signature?type=${type}`,
|
url,
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取OSS预签名URL(用于读取图片)
|
/** 兼容旧调用名:与 getOssSignatureApi 相同 */
|
||||||
|
export const getOssUploadSignatureApi = getOssSignatureApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 OSS 预签名 GET URL(私有桶读图)
|
||||||
|
* 对应网关:GET /api/v1/assets/oss/presigned-url
|
||||||
|
*/
|
||||||
export function getOssPresignedUrlApi(fileName, expires = 3600, type = 'avatar') {
|
export function getOssPresignedUrlApi(fileName, expires = 3600, type = 'avatar') {
|
||||||
return request({
|
return request({
|
||||||
url: `/api/v1/assets/oss/presigned-url?file_name=${encodeURIComponent(fileName)}&expires=${expires}&type=${type}`,
|
url: `/api/v1/assets/oss/presigned-url?file_name=${encodeURIComponent(fileName)}&expires=${expires}&type=${type}`,
|
||||||
@ -430,6 +450,76 @@ export function getOssPresignedUrlApi(fileName, expires = 3600, type = 'avatar')
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量预签名读 URL
|
||||||
|
* 对应网关:GET /api/v1/assets/oss/batch-presigned-urls
|
||||||
|
*/
|
||||||
|
export function getBatchOssPresignedUrlsApi(files, expires = 3600, type = 'asset') {
|
||||||
|
return request({
|
||||||
|
url: `/api/v1/assets/oss/batch-presigned-urls?files=${encodeURIComponent(JSON.stringify(files))}&expires=${expires}&type=${type}`,
|
||||||
|
method: 'GET'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用网关下发的 Post 策略将本地临时文件直传 OSS(App / 小程序等)
|
||||||
|
* @param {string} filePath uni.chooseImage 等返回的 tempFilePaths 项
|
||||||
|
* @param {{ type?: 'avatar'|'asset', orderId?: string, fileName?: string }} [options]
|
||||||
|
* @returns {Promise<{ imageUrl: string, orderId?: string, data: object }>}
|
||||||
|
*/
|
||||||
|
export function uploadLocalFileToOss(filePath, options = {}) {
|
||||||
|
const { type = 'asset', orderId = '', fileName } = options
|
||||||
|
const objectName = fileName || `${Date.now()}.jpg`
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
getOssSignatureApi(type, orderId)
|
||||||
|
.then((signRes) => {
|
||||||
|
if (!signRes || signRes.code !== 200 || !signRes.data) {
|
||||||
|
reject(new Error((signRes && signRes.message) || '获取 OSS 签名失败'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const d = signRes.data
|
||||||
|
const host = d.host
|
||||||
|
const dir = d.dir || ''
|
||||||
|
if (!host) {
|
||||||
|
reject(new Error('签名数据缺少 host'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.uploadFile({
|
||||||
|
url: host,
|
||||||
|
filePath,
|
||||||
|
name: 'file',
|
||||||
|
formData: {
|
||||||
|
key: dir + objectName,
|
||||||
|
policy: d.policy,
|
||||||
|
success_action_status: '200',
|
||||||
|
'x-oss-credential': d.x_oss_credential,
|
||||||
|
'x-oss-date': d.x_oss_date,
|
||||||
|
'x-oss-security-token': d.security_token,
|
||||||
|
'x-oss-signature': d.signature,
|
||||||
|
'x-oss-signature-version': d.x_oss_signature_version
|
||||||
|
},
|
||||||
|
success: (uploadRes) => {
|
||||||
|
const ok = uploadRes.statusCode === 200 || uploadRes.statusCode === 204
|
||||||
|
if (!ok) {
|
||||||
|
reject(new Error(`OSS 上传失败 HTTP ${uploadRes.statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const imageUrl = `${host.replace(/\/+$/, '')}/${dir}${objectName}`
|
||||||
|
resolve({
|
||||||
|
imageUrl,
|
||||||
|
orderId: d.order_id,
|
||||||
|
data: d
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: (e) => {
|
||||||
|
reject(new Error(e.errMsg || 'OSS 上传失败'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 更新用户头像
|
// 更新用户头像
|
||||||
export function updateAvatarApi(avatarUrl) {
|
export function updateAvatarApi(avatarUrl) {
|
||||||
return request({
|
return request({
|
||||||
@ -646,14 +736,6 @@ export function getInspirationFlowApi(params) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量获取OSS预签名URL(用于读取目录下的图片)
|
|
||||||
export function getBatchOssPresignedUrlsApi(files, expires = 3600, type = 'asset') {
|
|
||||||
return request({
|
|
||||||
url: `/api/v1/assets/oss/batch-presigned-urls?files=${encodeURIComponent(JSON.stringify(files))}&expires=${expires}&type=${type}`,
|
|
||||||
method: 'GET'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 铸造活动相关接口(运营banner)====================
|
// ==================== 铸造活动相关接口(运营banner)====================
|
||||||
|
|
||||||
// 获取铸造活动列表
|
// 获取铸造活动列表
|
||||||
|
|||||||
179
frontend/utils/castloveAfterLaserMint.js
Normal file
179
frontend/utils/castloveAfterLaserMint.js
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* 镭射工坊导出图后:OSS 上传 + 创建铸造订单(与 create.vue 原「跳过」链路一致)
|
||||||
|
*/
|
||||||
|
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js'
|
||||||
|
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js'
|
||||||
|
import { buildCastloveFormSnapshot } from '@/utils/castloveMintForm.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} base64Data data:image/jpeg;base64,... 或 data:image/png;base64,...
|
||||||
|
* @param {object} ossData getOssSignatureApi 返回的 data
|
||||||
|
* @returns {Promise<string>} material_url
|
||||||
|
*/
|
||||||
|
function uploadDataUrlToOss(base64Data, ossData) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const fileName = `${Date.now()}.jpg`
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
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) {
|
||||||
|
resolve(`${ossData.host}/${ossData.dir}${fileName}`)
|
||||||
|
} else {
|
||||||
|
reject(new Error('上传失败'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(reject)
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
|
||||||
|
const base64Content = base64Data.split(',')[1]
|
||||||
|
const fp = `${wx.env.USER_DATA_PATH}/${fileName}`
|
||||||
|
uni.getFileSystemManager().writeFile({
|
||||||
|
filePath: fp,
|
||||||
|
data: base64Content,
|
||||||
|
encoding: 'base64',
|
||||||
|
success: () => {
|
||||||
|
uni.uploadFile({
|
||||||
|
url: ossData.host,
|
||||||
|
filePath: fp,
|
||||||
|
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) {
|
||||||
|
resolve(`${ossData.host}/${ossData.dir}${fileName}`)
|
||||||
|
} else {
|
||||||
|
reject(new Error(`上传失败 HTTP ${uploadRes.statusCode}`))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: reject
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: reject
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef APP-PLUS
|
||||||
|
const bitmap = new plus.nativeObj.Bitmap('castlove_laser_export')
|
||||||
|
bitmap.loadBase64Data(base64Data, () => {
|
||||||
|
const tempFilePath = `_doc/${fileName}`
|
||||||
|
bitmap.save(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) => {
|
||||||
|
if (uploadRes.statusCode === 200 || uploadRes.statusCode === 204) {
|
||||||
|
resolve(`${ossData.host}/${ossData.dir}${fileName}`)
|
||||||
|
} else {
|
||||||
|
reject(new Error(`上传失败 HTTP ${uploadRes.statusCode}`))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: reject
|
||||||
|
})
|
||||||
|
}, () => {
|
||||||
|
bitmap.clear()
|
||||||
|
reject(new Error('保存临时文件失败'))
|
||||||
|
})
|
||||||
|
}, () => {
|
||||||
|
bitmap.clear()
|
||||||
|
reject(new Error('加载导出图失败'))
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} imageDataUrl 导出后的 data URL
|
||||||
|
* @param {object} entry create 页写入的 CASTLOVE_LASER_ENTRY_KEY 解析结果
|
||||||
|
*/
|
||||||
|
export async function submitCastloveAfterLaserExport(imageDataUrl, entry) {
|
||||||
|
if (!entry || !imageDataUrl) {
|
||||||
|
throw new Error('缺少铸爱表单或导出图')
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
nftInfo,
|
||||||
|
materialTypes,
|
||||||
|
materialTypeIndex,
|
||||||
|
pageName,
|
||||||
|
uploadedImage,
|
||||||
|
uploadedImageBase64
|
||||||
|
} = entry
|
||||||
|
const signRes = await getOssSignatureApi('asset')
|
||||||
|
if (!signRes || signRes.code !== 200 || !signRes.data) {
|
||||||
|
throw new Error((signRes && signRes.message) || '获取签名失败')
|
||||||
|
}
|
||||||
|
const orderId = signRes.data.order_id || ''
|
||||||
|
const imageUrl = await uploadDataUrlToOss(imageDataUrl, signRes.data)
|
||||||
|
const snap = buildCastloveFormSnapshot({
|
||||||
|
nftInfo,
|
||||||
|
materialTypes,
|
||||||
|
materialTypeIndex,
|
||||||
|
pageName,
|
||||||
|
uploadedImage: uploadedImage || '',
|
||||||
|
uploadedImageBase64: uploadedImageBase64 || ''
|
||||||
|
})
|
||||||
|
const orderData = {
|
||||||
|
order_id: orderId,
|
||||||
|
name: snap.name,
|
||||||
|
material_url: imageUrl,
|
||||||
|
description: snap.description,
|
||||||
|
grade: snap.grade,
|
||||||
|
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 && response.message) || '创建订单失败')
|
||||||
|
}
|
||||||
|
const nftData = {
|
||||||
|
image: imageUrl,
|
||||||
|
name: snap.name,
|
||||||
|
description: snap.description,
|
||||||
|
material_type: snap.material_type,
|
||||||
|
tags: snap.tags,
|
||||||
|
order_id: orderId,
|
||||||
|
info: snap.info,
|
||||||
|
event: snap.info
|
||||||
|
}
|
||||||
|
uni.setStorageSync('temp_nft_data', JSON.stringify(nftData))
|
||||||
|
return { imageUrl, orderId }
|
||||||
|
}
|
||||||
138
frontend/utils/castloveMintForm.js
Normal file
138
frontend/utils/castloveMintForm.js
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
/** 铸爱 — 与网关 CreateMintOrderRequestDTO 对齐的本地表单快照(见 gateway/dto/asset_dto.go) */
|
||||||
|
|
||||||
|
export const CRAFT_LENTICULAR_CN = '光栅卡'
|
||||||
|
/** 工艺名:与 craft-select 卡片「镭射卡」一致 */
|
||||||
|
export const CRAFT_LASER_CARD_CN = '镭射卡'
|
||||||
|
/** 工艺标签:写入 tags[],便于检索与后端扩展 */
|
||||||
|
export const CRAFT_TAG_LENTICULAR = 'craft:lenticular'
|
||||||
|
|
||||||
|
const defaultBgBase =
|
||||||
|
'radial-gradient(ellipse at 50% 40%, #1a0a3b 0%, #0a0418 55%, #000 100%)'
|
||||||
|
const defaultBgMid =
|
||||||
|
'radial-gradient(ellipse 38% 28% at 50% 45%, rgba(255,212,255,0.9) 0%, rgba(183,109,255,0.55) 50%, rgba(26,10,59,0) 100%)'
|
||||||
|
|
||||||
|
export function defaultLenticularDots() {
|
||||||
|
return [
|
||||||
|
{ x: 18, y: 22, r: 1.4 },
|
||||||
|
{ x: 78, y: 30, r: 1.6 },
|
||||||
|
{ x: 28, y: 72, r: 1.3 },
|
||||||
|
{ x: 86, y: 78, r: 1.2 },
|
||||||
|
{ x: 58, y: 12, r: 1.1 },
|
||||||
|
{ x: 8, y: 50, r: 1.0 },
|
||||||
|
{ x: 92, y: 50, r: 1.2 },
|
||||||
|
{ x: 50, y: 88, r: 1.1 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进入光栅工作室前写入的临时数据(create → lenticular-studio) */
|
||||||
|
export const LENTICULAR_STUDIO_STORAGE_KEY = 'lenticular_studio_payload'
|
||||||
|
|
||||||
|
/** 单图工艺:create → 镭射工坊(Laser-Card 页)入口载荷 */
|
||||||
|
export const CASTLOVE_LASER_ENTRY_KEY = 'castlove_laser_entry_payload'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅背景 + 主体两图层(无高光层),与光栅卡工作室一致
|
||||||
|
* @param {string} bgSrc 背景图本地路径或 URL
|
||||||
|
* @param {string} subjectSrc 主体图本地路径或 URL
|
||||||
|
*/
|
||||||
|
export function buildLenticularLayersTwo(bgSrc, subjectSrc) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'base',
|
||||||
|
label: '背景',
|
||||||
|
depth: 0,
|
||||||
|
opacity: 1,
|
||||||
|
blendMode: 'normal',
|
||||||
|
parallaxFactor: 0.35,
|
||||||
|
background: bgSrc ? undefined : defaultBgBase,
|
||||||
|
dots: bgSrc ? undefined : defaultLenticularDots(),
|
||||||
|
src: bgSrc || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mid',
|
||||||
|
label: '主体',
|
||||||
|
depth: 0.55,
|
||||||
|
opacity: 0.92,
|
||||||
|
blendMode: 'screen',
|
||||||
|
parallaxFactor: 0.7,
|
||||||
|
background: subjectSrc ? undefined : defaultBgMid,
|
||||||
|
src: subjectSrc || undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兼容旧预览:单图仅贴在主体层,仍带高光层 */
|
||||||
|
export function buildLenticularLayers(uploadSrc) {
|
||||||
|
const mid = {
|
||||||
|
id: 'mid',
|
||||||
|
label: '主体',
|
||||||
|
depth: 0.55,
|
||||||
|
opacity: 0.92,
|
||||||
|
blendMode: 'screen',
|
||||||
|
parallaxFactor: 0.7,
|
||||||
|
background: uploadSrc ? undefined : defaultBgMid,
|
||||||
|
src: uploadSrc || undefined,
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'base',
|
||||||
|
label: '背景',
|
||||||
|
depth: 0.0,
|
||||||
|
opacity: 1,
|
||||||
|
blendMode: 'normal',
|
||||||
|
parallaxFactor: 0.35,
|
||||||
|
background: defaultBgBase,
|
||||||
|
dots: defaultLenticularDots(),
|
||||||
|
},
|
||||||
|
mid,
|
||||||
|
{
|
||||||
|
id: 'fx',
|
||||||
|
label: '高光',
|
||||||
|
depth: 0.9,
|
||||||
|
opacity: 0.85,
|
||||||
|
blendMode: 'lighten',
|
||||||
|
parallaxFactor: 1.15,
|
||||||
|
background: 'transparent',
|
||||||
|
dots: [
|
||||||
|
{ x: 20, y: 16, r: 3, color: '#ffffff' },
|
||||||
|
{ x: 82, y: 22, r: 2.4, color: '#ffffff' },
|
||||||
|
{ x: 14, y: 78, r: 3.2, color: '#4cd7f6' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建与 POST /api/v1/assets/mints 一致的字段子集 + 本地预览所需字段
|
||||||
|
* DTO: order_id, name, material_url, description, grade, tags, material_type, info
|
||||||
|
*/
|
||||||
|
export function buildCastloveFormSnapshot({
|
||||||
|
nftInfo,
|
||||||
|
materialTypes,
|
||||||
|
materialTypeIndex,
|
||||||
|
pageName,
|
||||||
|
uploadedImage,
|
||||||
|
uploadedImageBase64,
|
||||||
|
}) {
|
||||||
|
const info = (nftInfo || '').trim()
|
||||||
|
const material_type = materialTypes[materialTypeIndex] || '粉丝自制'
|
||||||
|
const lines = info.split(/\n/).map((l) => l.trim()).filter(Boolean)
|
||||||
|
const name =
|
||||||
|
lines.length > 0
|
||||||
|
? lines[0].slice(0, 80)
|
||||||
|
: pageName === CRAFT_LENTICULAR_CN
|
||||||
|
? '光栅卡作品'
|
||||||
|
: pageName || '藏品'
|
||||||
|
const tags = pageName === CRAFT_LENTICULAR_CN ? [CRAFT_TAG_LENTICULAR] : []
|
||||||
|
return {
|
||||||
|
image: uploadedImage,
|
||||||
|
imageBase64: uploadedImageBase64,
|
||||||
|
info,
|
||||||
|
material_type,
|
||||||
|
name,
|
||||||
|
description: '',
|
||||||
|
tags,
|
||||||
|
craft_name: pageName || '',
|
||||||
|
grade: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
26
frontend/utils/h5OssPostUrl.js
Normal file
26
frontend/utils/h5OssPostUrl.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* H5 本地开发:将 OSS Post 地址换为同源 /dev-oss-proxy,由 vite.config.js 中中间件转发到 OSS 桶根 POST /,
|
||||||
|
* 避免浏览器从 localhost / 局域网端口直连 *.aliyuncs.com 触发 CORS。
|
||||||
|
*
|
||||||
|
* 正式 H5 部署到业务域名时仍直连 ossData.host;若遇 CORS,请在 OSS 控制台配置 CORS 或使用与站点同域的反代。
|
||||||
|
*
|
||||||
|
* @param {string} ossHost 签名接口返回的 host,如 https://bucket.oss-cn-shanghai.aliyuncs.com
|
||||||
|
* @returns {string} 实际用于 fetch POST 的 URL(开发态为同源代理前缀)
|
||||||
|
*/
|
||||||
|
export function resolveH5OssPostUrl(ossHost) {
|
||||||
|
if (!ossHost || typeof location === 'undefined') {
|
||||||
|
return ossHost
|
||||||
|
}
|
||||||
|
const port = String(location.port || '')
|
||||||
|
const hn = location.hostname || ''
|
||||||
|
const devPortOk = ['5173', '5174', '8080', '9528'].includes(port)
|
||||||
|
const devHostOk =
|
||||||
|
hn === 'localhost' ||
|
||||||
|
hn === '127.0.0.1' ||
|
||||||
|
/^192\.168\./.test(hn) ||
|
||||||
|
/^10\./.test(hn)
|
||||||
|
if (devPortOk && devHostOk) {
|
||||||
|
return `${location.protocol}//${location.host}/dev-oss-proxy`
|
||||||
|
}
|
||||||
|
return ossHost
|
||||||
|
}
|
||||||
557
frontend/utils/laser-card/aliyunPortraitUni.js
Normal file
557
frontend/utils/laser-card/aliyunPortraitUni.js
Normal file
@ -0,0 +1,557 @@
|
|||||||
|
/**
|
||||||
|
* uni-app App 端可用:OSS Post/Put + 签名 GET URL;直连抠图默认 IVPD SegmentImage,另有 imageseg SegmentHDBody(POP,不依赖 ali-oss / Node http)
|
||||||
|
* 签名使用 Web Crypto(HMAC-SHA1),避免额外依赖 crypto-js;需安全上下文(localhost / HTTPS)且存在 globalThis.crypto.subtle。
|
||||||
|
*/
|
||||||
|
const _textEnc = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null
|
||||||
|
|
||||||
|
function utf8StringToBase64(str) {
|
||||||
|
if (_textEnc) {
|
||||||
|
const bytes = _textEnc.encode(str)
|
||||||
|
let bin = ''
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
bin += String.fromCharCode(bytes[i])
|
||||||
|
}
|
||||||
|
return btoa(bin)
|
||||||
|
}
|
||||||
|
return btoa(unescape(encodeURIComponent(str)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hmacSha1Base64(message, secretKey) {
|
||||||
|
const subtle = globalThis.crypto && globalThis.crypto.subtle
|
||||||
|
if (!subtle || !_textEnc) {
|
||||||
|
throw new Error(
|
||||||
|
'当前环境无法计算 OSS/阿里云签名:需要支持 Web Crypto(请在 localhost 或 HTTPS 下打开,并确保非禁用 crypto.subtle 的 WebView)'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const key = await subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
_textEnc.encode(secretKey),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-1' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
)
|
||||||
|
const sig = await subtle.sign('HMAC', key, _textEnc.encode(message))
|
||||||
|
const bytes = new Uint8Array(sig)
|
||||||
|
let bin = ''
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
bin += String.fromCharCode(bytes[i])
|
||||||
|
}
|
||||||
|
return btoa(bin)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RAM 密钥常见复制问题:首尾空格、BOM、零宽字符、Windows 多余 \\r */
|
||||||
|
function normalizeAccessKeyPart(s) {
|
||||||
|
return String(s || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/^\uFEFF/, '')
|
||||||
|
.replace(/[\u200B-\u200D\uFEFF]/g, '')
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.replace(/\r/g, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentEncode(str) {
|
||||||
|
if (str === undefined || str === null) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return encodeURIComponent(String(str))
|
||||||
|
.replace(/!/g, '%21')
|
||||||
|
.replace(/'/g, '%27')
|
||||||
|
.replace(/\(/g, '%28')
|
||||||
|
.replace(/\)/g, '%29')
|
||||||
|
.replace(/\*/g, '%2A')
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickHttpImageUrl(obj, depth = 0) {
|
||||||
|
if (!obj || depth > 10) return null
|
||||||
|
if (typeof obj === 'string' && /^https?:\/\//i.test(obj) && /\.(png|jpg|jpeg|webp)/i.test(obj)) {
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
if (typeof obj !== 'object') return null
|
||||||
|
for (const v of Object.values(obj)) {
|
||||||
|
const u = pickHttpImageUrl(v, depth + 1)
|
||||||
|
if (u) return u
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 递归查找 oss://bucket/object 形式 URI */
|
||||||
|
function pickOssUriString(obj, depth = 0) {
|
||||||
|
if (!obj || depth > 10) return null
|
||||||
|
if (typeof obj === 'string' && /^oss:\/\//i.test(obj)) {
|
||||||
|
if (parseOssUri(obj)) {
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof obj !== 'object') return null
|
||||||
|
for (const v of Object.values(obj)) {
|
||||||
|
const u = pickOssUriString(v, depth + 1)
|
||||||
|
if (u) return u
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOssUri(uri) {
|
||||||
|
const s = String(uri || '').trim()
|
||||||
|
const m = /^oss:\/\/([^/]+)\/(.+)$/i.exec(s)
|
||||||
|
if (!m) return null
|
||||||
|
return { bucket: m[1], objectKey: m[2] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PostObject 的目标地址(仅桶根、无 object 路径);失败 errMsg 常只有这一串 URL */
|
||||||
|
export function looksLikeBareOssEndpointUrl(s) {
|
||||||
|
const t = String(s || '').trim()
|
||||||
|
if (!/^https?:\/\/[^/\s]+\.oss-[a-z0-9-]+\.aliyuncs\.com\/?$/i.test(t)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const u = new URL(t)
|
||||||
|
if (!/^https?:$/i.test(u.protocol)) return false
|
||||||
|
const segs = u.pathname.split('/').filter(Boolean)
|
||||||
|
return segs.length === 0
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅一行 OSS 根域名错误时,转成可操作的说明(依赖 manifest domainWhiteList) */
|
||||||
|
export function humanizeIfBareOssUploadErr(msg) {
|
||||||
|
const t = String(msg || '').trim()
|
||||||
|
if (!looksLikeBareOssEndpointUrl(t)) {
|
||||||
|
return String(msg || '')
|
||||||
|
}
|
||||||
|
const hostOnly = t.replace(/^https?:\/\//i, '').replace(/\/$/, '')
|
||||||
|
return (
|
||||||
|
`OSS 上传失败(uni-app 常因未配置「域名白名单」拦截 uploadFile,控制台只显示请求 URL)。\n\n请在 manifest.json → app-plus → networkSecurity → domainWhiteList 中加入(注意 https):\n• https://${hostOnly}\n• IVPD:https://ivpd.<地域>.aliyuncs.com(与 IMM_REGION 一致,如 cn-shanghai)\n以及抠图结果下载域名(若与上面不同)。\n保存后必须重新制作自定义调试基座再运行。\n\n原文:${t}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排除仅桶根域名、无对象路径的「伪 URL」(IMM 偶发只回 Host) */
|
||||||
|
function isLikelyObjectHttpUrl(url) {
|
||||||
|
try {
|
||||||
|
const u = new URL(String(url).trim())
|
||||||
|
if (!/^https?:$/i.test(u.protocol)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const parts = u.pathname.split('/').filter(Boolean)
|
||||||
|
return parts.length >= 1
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ossHost(bucket, ossRegionId) {
|
||||||
|
const r = ossRegionId.replace(/^oss-/, '')
|
||||||
|
return `${bucket}.oss-${r}.aliyuncs.com`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** objectKey 含路径时用分段编码路径 */
|
||||||
|
function ossObjectPath(objectKey) {
|
||||||
|
return objectKey
|
||||||
|
.split('/')
|
||||||
|
.map((seg) => encodeURIComponent(seg))
|
||||||
|
.join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 私有读:签名 GET URL(与 Put 后给分割接口用的 URL 一致) */
|
||||||
|
export async function ossPresignedObjectUrl({
|
||||||
|
bucket,
|
||||||
|
ossRegionId,
|
||||||
|
accessKeyId,
|
||||||
|
accessKeySecret,
|
||||||
|
securityToken,
|
||||||
|
objectKey,
|
||||||
|
expiresSec = 3600
|
||||||
|
}) {
|
||||||
|
const ak = normalizeAccessKeyPart(accessKeyId)
|
||||||
|
const sk = normalizeAccessKeyPart(accessKeySecret)
|
||||||
|
const host = ossHost(bucket, ossRegionId)
|
||||||
|
const path = ossObjectPath(objectKey)
|
||||||
|
const resource = `/${bucket}/${objectKey}`
|
||||||
|
const expires = Math.floor(Date.now() / 1000) + expiresSec
|
||||||
|
const stringToSignGet = `GET\n\n\n${expires}\n${resource}`
|
||||||
|
const sigGet = await hmacSha1Base64(stringToSignGet, sk)
|
||||||
|
let qs = `OSSAccessKeyId=${percentEncode(ak)}&Expires=${expires}&Signature=${percentEncode(sigGet)}`
|
||||||
|
if (securityToken) {
|
||||||
|
qs += `&security-token=${percentEncode(securityToken)}`
|
||||||
|
}
|
||||||
|
return `https://${host}/${path}?${qs}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostObject + policy:用 uni.uploadFile 直传本地路径,不经 JS 读 ArrayBuffer(调试基座读图失败时的主路径)。
|
||||||
|
*/
|
||||||
|
export async function ossPostObjectFromLocalFilePath({
|
||||||
|
bucket,
|
||||||
|
ossRegionId,
|
||||||
|
accessKeyId,
|
||||||
|
accessKeySecret,
|
||||||
|
securityToken,
|
||||||
|
objectKey,
|
||||||
|
filePath
|
||||||
|
}) {
|
||||||
|
const ak = normalizeAccessKeyPart(accessKeyId)
|
||||||
|
const sk = normalizeAccessKeyPart(accessKeySecret)
|
||||||
|
if (!ak || !sk) {
|
||||||
|
return Promise.reject(new Error('OSS 缺少 AccessKeyId 或 AccessKeySecret'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiration = new Date(Date.now() + 55 * 60 * 1000).toISOString()
|
||||||
|
const conditions = [
|
||||||
|
['content-length-range', 0, 52428800],
|
||||||
|
{ bucket },
|
||||||
|
['eq', '$key', objectKey]
|
||||||
|
]
|
||||||
|
if (securityToken) {
|
||||||
|
conditions.push({ 'x-oss-security-token': securityToken })
|
||||||
|
}
|
||||||
|
|
||||||
|
const policyJson = JSON.stringify({ expiration, conditions })
|
||||||
|
const policyB64 = utf8StringToBase64(policyJson)
|
||||||
|
const signature = await hmacSha1Base64(policyB64, sk)
|
||||||
|
|
||||||
|
const host = ossHost(bucket, ossRegionId)
|
||||||
|
const url = `https://${host}/`
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
key: objectKey,
|
||||||
|
policy: policyB64,
|
||||||
|
OSSAccessKeyId: ak,
|
||||||
|
Signature: signature,
|
||||||
|
success_action_status: '200'
|
||||||
|
}
|
||||||
|
if (securityToken) {
|
||||||
|
formData['x-oss-security-token'] = securityToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.uploadFile({
|
||||||
|
url,
|
||||||
|
filePath,
|
||||||
|
name: 'file',
|
||||||
|
formData,
|
||||||
|
timeout: 180000,
|
||||||
|
success: (res) => {
|
||||||
|
const sc = res.statusCode
|
||||||
|
if (sc >= 200 && sc < 300) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data || '')
|
||||||
|
if (typeof body === 'string' && body.includes('SignatureDoesNotMatch')) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
'OSS SignatureDoesNotMatch:AccessKeySecret 与当前 OSSAccessKeyId 不匹配,或密钥含隐形字符/换行。请到 RAM 用户页「创建 AccessKey」重新复制 Secret 写入 config/segmentApi.js;若曾泄露请删除旧密钥并轮换。'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reject(new Error(body || `OSS PostObject 失败 HTTP ${sc}`))
|
||||||
|
},
|
||||||
|
fail: (e) => {
|
||||||
|
const raw = e.errMsg || 'uploadFile 失败'
|
||||||
|
if (
|
||||||
|
looksLikeBareOssEndpointUrl(raw) ||
|
||||||
|
(/request:fail/i.test(raw) && /\.oss-[a-z0-9-]+\.aliyuncs\.com/i.test(raw))
|
||||||
|
) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
humanizeIfBareOssUploadErr(raw)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reject(new Error(raw))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniRequestRaw(options) {
|
||||||
|
const timeout = options.timeout != null ? options.timeout : 120000
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.request({
|
||||||
|
...options,
|
||||||
|
timeout,
|
||||||
|
success: (r) => {
|
||||||
|
if (r.statusCode >= 200 && r.statusCode < 300) {
|
||||||
|
resolve(r)
|
||||||
|
} else {
|
||||||
|
const msg =
|
||||||
|
(typeof r.data === 'string' ? r.data : '') ||
|
||||||
|
(r.data && r.data.Message) ||
|
||||||
|
`HTTP ${r.statusCode}`
|
||||||
|
reject(new Error(msg))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (e) => reject(new Error(e.errMsg || '请求失败'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT 上传后生成 OSS 签名 GET URL(私有 Bucket 时分割接口需可访问该 URL)
|
||||||
|
*/
|
||||||
|
export async function ossPutAndSignedGetUrl({
|
||||||
|
bucket,
|
||||||
|
ossRegionId,
|
||||||
|
accessKeyId,
|
||||||
|
accessKeySecret,
|
||||||
|
securityToken,
|
||||||
|
objectKey,
|
||||||
|
bodyBuffer,
|
||||||
|
contentType = 'image/jpeg'
|
||||||
|
}) {
|
||||||
|
const ak = normalizeAccessKeyPart(accessKeyId)
|
||||||
|
const sk = normalizeAccessKeyPart(accessKeySecret)
|
||||||
|
if (!ak || !sk) {
|
||||||
|
throw new Error('OSS 签名缺少 AccessKeyId 或 AccessKeySecret')
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = ossHost(bucket, ossRegionId)
|
||||||
|
const path = ossObjectPath(objectKey)
|
||||||
|
const urlPut = `https://${host}/${path}`
|
||||||
|
|
||||||
|
const dateGmt = new Date().toUTCString()
|
||||||
|
/**
|
||||||
|
* uni.request 会带 Date;若再发 x-oss-date,OSS 的 StringToSign 为:Date 行 + 规范头里的 x-oss-date(与错误 XML 一致),不能签成 Date 为空。
|
||||||
|
*/
|
||||||
|
const ossHeaderPairs = []
|
||||||
|
ossHeaderPairs.push(['x-oss-date', dateGmt])
|
||||||
|
if (securityToken) {
|
||||||
|
ossHeaderPairs.push(['x-oss-security-token', securityToken])
|
||||||
|
}
|
||||||
|
ossHeaderPairs.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
const canonHeaders = ossHeaderPairs.map(([k, v]) => `${k}:${v}\n`).join('')
|
||||||
|
|
||||||
|
const resource = `/${bucket}/${objectKey}`
|
||||||
|
const stringToSignPut = `PUT\n\n${contentType}\n${dateGmt}\n${canonHeaders}${resource}`
|
||||||
|
const sigPut = await hmacSha1Base64(stringToSignPut, sk)
|
||||||
|
|
||||||
|
const headersPut = {
|
||||||
|
Date: dateGmt,
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'x-oss-date': dateGmt,
|
||||||
|
Authorization: `OSS ${ak}:${sigPut}`
|
||||||
|
}
|
||||||
|
if (securityToken) {
|
||||||
|
headersPut['x-oss-security-token'] = securityToken
|
||||||
|
}
|
||||||
|
|
||||||
|
await uniRequestRaw({
|
||||||
|
url: urlPut,
|
||||||
|
method: 'PUT',
|
||||||
|
header: headersPut,
|
||||||
|
data: bodyBuffer,
|
||||||
|
/** 大图上传(Bitmap 兜底常为 PNG)易慢;超时后 fail 才会结束 loading */
|
||||||
|
timeout: 180000
|
||||||
|
})
|
||||||
|
|
||||||
|
return await ossPresignedObjectUrl({
|
||||||
|
bucket,
|
||||||
|
ossRegionId,
|
||||||
|
accessKeyId: ak,
|
||||||
|
accessKeySecret: sk,
|
||||||
|
securityToken,
|
||||||
|
objectKey,
|
||||||
|
expiresSec: 3600
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IVPD 常见英文报错 → 中文说明(控制台需开通「智能视觉生产」等产品) */
|
||||||
|
function mapIvpdUserMessage(raw) {
|
||||||
|
const s = String(raw || '')
|
||||||
|
if (/please\s+open\s+service/i.test(s)) {
|
||||||
|
return (
|
||||||
|
'IVPD(智能视觉生产)服务尚未开通:请用阿里云主账号或有权限的子账号登录控制台,搜索「智能视觉生产」或「视觉智能开放平台」,进入产品页点击「立即开通」后再试抠图。' +
|
||||||
|
(s.length <= 60 ? `(接口原文:${s})` : '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能视觉生产 IVPD「通用分割 / 抠图」SegmentImage。
|
||||||
|
* Endpoint:ivpd.{region}.aliyuncs.com,Version:2019-06-25,参数 Url 为可访问的图片 HTTP(S) URL(如 OSS 签名 GET)。
|
||||||
|
*/
|
||||||
|
export async function rpcIvpdSegmentImage({
|
||||||
|
imageUrl,
|
||||||
|
accessKeyId,
|
||||||
|
accessKeySecret,
|
||||||
|
securityToken,
|
||||||
|
regionId
|
||||||
|
}) {
|
||||||
|
const ak = normalizeAccessKeyPart(accessKeyId)
|
||||||
|
const sk = normalizeAccessKeyPart(accessKeySecret)
|
||||||
|
const u = String(imageUrl || '').trim()
|
||||||
|
if (!/^https?:\/\//i.test(u)) {
|
||||||
|
throw new Error('IVPD SegmentImage 需要可访问的 HTTP(S) 图片 URL(一般为 OSS 私有桶签名 GET)')
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
Format: 'JSON',
|
||||||
|
Version: '2019-06-25',
|
||||||
|
AccessKeyId: ak,
|
||||||
|
SignatureMethod: 'HMAC-SHA1',
|
||||||
|
Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||||
|
SignatureVersion: '1.0',
|
||||||
|
SignatureNonce: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
Action: 'SegmentImage',
|
||||||
|
RegionId: regionId,
|
||||||
|
Url: u
|
||||||
|
}
|
||||||
|
if (securityToken) {
|
||||||
|
params.SecurityToken = securityToken
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedKeys = Object.keys(params).sort()
|
||||||
|
const canonicalized = sortedKeys.map((k) => `${percentEncode(k)}=${percentEncode(params[k])}`).join('&')
|
||||||
|
const stringToSign = `POST&${percentEncode('/')}&${percentEncode(canonicalized)}`
|
||||||
|
const signature = await hmacSha1Base64(stringToSign, `${sk}&`)
|
||||||
|
|
||||||
|
const body = `${canonicalized}&Signature=${percentEncode(signature)}`
|
||||||
|
const host = `ivpd.${regionId}.aliyuncs.com`
|
||||||
|
|
||||||
|
let res
|
||||||
|
try {
|
||||||
|
res = await uniRequestRaw({
|
||||||
|
url: `https://${host}/`,
|
||||||
|
method: 'POST',
|
||||||
|
header: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
timeout: 120000
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
const em = e && e.message ? String(e.message) : ''
|
||||||
|
if (/please\s+open\s+service/i.test(em)) {
|
||||||
|
throw new Error(mapIvpdUserMessage(em))
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(em)
|
||||||
|
const mm = j.Message || j.message || ''
|
||||||
|
if (/please\s+open\s+service/i.test(String(mm))) {
|
||||||
|
throw new Error(mapIvpdUserMessage(mm))
|
||||||
|
}
|
||||||
|
} catch (inner) {
|
||||||
|
if (inner && inner.message && /IVPD(/.test(inner.message)) {
|
||||||
|
throw inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = res.data
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(data)
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error((data && data.slice(0, 240)) || 'IVPD 返回非 JSON')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flat = data
|
||||||
|
const bizCode = flat.code ?? flat.Code
|
||||||
|
const bizMsg = flat.message ?? flat.Message
|
||||||
|
if (bizCode != null && String(bizCode) !== '0') {
|
||||||
|
throw new Error(mapIvpdUserMessage(bizMsg ? `${bizCode}: ${bizMsg}` : String(bizCode)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const aliPopMsg = flat.Message || flat.Code
|
||||||
|
if (typeof aliPopMsg === 'string' && /please\s+open\s+service/i.test(aliPopMsg)) {
|
||||||
|
throw new Error(mapIvpdUserMessage(aliPopMsg))
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = flat.result ?? flat.Result
|
||||||
|
const outUrl =
|
||||||
|
(result && (result.url || result.URL)) ||
|
||||||
|
flat.url ||
|
||||||
|
flat.URL ||
|
||||||
|
pickHttpImageUrl(flat)
|
||||||
|
|
||||||
|
if (outUrl && isLikelyObjectHttpUrl(outUrl)) {
|
||||||
|
return outUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
bizMsg
|
||||||
|
? mapIvpdUserMessage(bizMsg)
|
||||||
|
: `IVPD 未返回有效结果图 URL。摘录:${JSON.stringify(flat).slice(0, 600)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人像高清抠图(imageseg SegmentHDBody);自建代理 server/index.js 等可使用。 */
|
||||||
|
export async function rpcSegmentHDBody({
|
||||||
|
imageURL,
|
||||||
|
accessKeyId,
|
||||||
|
accessKeySecret,
|
||||||
|
securityToken,
|
||||||
|
regionId
|
||||||
|
}) {
|
||||||
|
const ak = normalizeAccessKeyPart(accessKeyId)
|
||||||
|
const sk = normalizeAccessKeyPart(accessKeySecret)
|
||||||
|
const params = {
|
||||||
|
Format: 'JSON',
|
||||||
|
Version: '2019-12-30',
|
||||||
|
AccessKeyId: ak,
|
||||||
|
SignatureMethod: 'HMAC-SHA1',
|
||||||
|
Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||||
|
SignatureVersion: '1.0',
|
||||||
|
SignatureNonce: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
Action: 'SegmentHDBody',
|
||||||
|
ImageURL: imageURL
|
||||||
|
}
|
||||||
|
if (securityToken) {
|
||||||
|
params.SecurityToken = securityToken
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedKeys = Object.keys(params).sort()
|
||||||
|
const canonicalized = sortedKeys.map((k) => `${percentEncode(k)}=${percentEncode(params[k])}`).join('&')
|
||||||
|
const stringToSign = `POST&${percentEncode('/')}&${percentEncode(canonicalized)}`
|
||||||
|
const signature = await hmacSha1Base64(stringToSign, `${sk}&`)
|
||||||
|
|
||||||
|
const body = `${canonicalized}&Signature=${percentEncode(signature)}`
|
||||||
|
const host = `imageseg.${regionId}.aliyuncs.com`
|
||||||
|
|
||||||
|
const res = await uniRequestRaw({
|
||||||
|
url: `https://${host}/`,
|
||||||
|
method: 'POST',
|
||||||
|
header: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
timeout: 90000
|
||||||
|
})
|
||||||
|
|
||||||
|
let data = res.data
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(data)
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error((data && data.slice(0, 240)) || '分割接口返回非 JSON')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let flat = data
|
||||||
|
if (flat && typeof flat.Data === 'string') {
|
||||||
|
try {
|
||||||
|
const inner = JSON.parse(flat.Data)
|
||||||
|
flat = { ...flat, ...inner }
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outUrl =
|
||||||
|
flat?.ImageURL ||
|
||||||
|
flat?.imageURL ||
|
||||||
|
flat?.Data?.ImageURL ||
|
||||||
|
pickHttpImageUrl(flat)
|
||||||
|
|
||||||
|
if (!outUrl) {
|
||||||
|
const msg = (flat && (flat.Message || flat.Code)) || JSON.stringify(flat).slice(0, 200)
|
||||||
|
throw new Error(msg || '分割接口未返回结果图 URL')
|
||||||
|
}
|
||||||
|
return outUrl
|
||||||
|
}
|
||||||
48
frontend/utils/laser-card/segmentApi.js
Normal file
48
frontend/utils/laser-card/segmentApi.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* 智能抠图:方案一(OSS + IVPD)与方案二(HTTP 后端,契约见 server/index.js)
|
||||||
|
*
|
||||||
|
* SEGMENT_TRANSPORT:auto | backend | direct(详见 segmentApi.example.js)
|
||||||
|
*
|
||||||
|
* 【方案一 · 推荐生产】ALIYUN_STS_URL:仅下发临时密钥的 HTTPS(FC 等),不写主账号 AK。
|
||||||
|
* 【方案一 · 仅开发】ALIYUN_ACCESS_KEY_ID / ALIYUN_ACCESS_KEY_SECRET:直配 RAM 子账号 AK(有泄露风险,上架前务必删除并改用 STS)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 仅开发:RAM 子账号 AccessKeyId(勿提交仓库、勿用于正式包) */
|
||||||
|
export const ALIYUN_ACCESS_KEY_ID = ''
|
||||||
|
|
||||||
|
/** 仅开发:RAM 子账号 AccessKeySecret */
|
||||||
|
export const ALIYUN_ACCESS_KEY_SECRET = ''
|
||||||
|
|
||||||
|
/** 可选:使用 STS 临时凭证时填写 SecurityToken;长期 AK 一般留空 */
|
||||||
|
export const ALIYUN_SECURITY_TOKEN = ''
|
||||||
|
|
||||||
|
/** 生产:STS 获取地址(GET,返回 JSON)。若上面已填 AK/SK,则不再请求此地址 */
|
||||||
|
export const ALIYUN_STS_URL = ''
|
||||||
|
|
||||||
|
/** OSS Bucket 名(不含域名) */
|
||||||
|
export const OSS_BUCKET = ''
|
||||||
|
|
||||||
|
/** OSS region,如 oss-cn-shanghai */
|
||||||
|
export const OSS_REGION = 'oss-cn-shanghai'
|
||||||
|
|
||||||
|
/** 上传目录前缀 */
|
||||||
|
export const OSS_KEY_PREFIX = 'laser-card-segment/tmp/'
|
||||||
|
|
||||||
|
/** imageseg 地域,如 cn-shanghai */
|
||||||
|
export const IMAGESEG_REGION = 'cn-shanghai'
|
||||||
|
|
||||||
|
/** 预留;当前直连抠图走 IVPD,不使用 IMM 项目名称 */
|
||||||
|
export const IMM_PROJECT_NAME = 'laser-card'
|
||||||
|
|
||||||
|
/** IVPD 与 OSS 同地域 id,如 cn-shanghai */
|
||||||
|
export const IMM_REGION = 'cn-shanghai'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* auto | backend | direct — 详见 segmentApi.example.js 注释;接后端时改为 backend 并填 SEGMENT_API_BASE。
|
||||||
|
*/
|
||||||
|
export const SEGMENT_TRANSPORT = 'auto'
|
||||||
|
|
||||||
|
/** 方案二:自建代理根地址(无末尾斜杠);与方案一互斥:有 OSS 且(AK 或 STS)时不会走代理 */
|
||||||
|
export const SEGMENT_API_BASE = ''
|
||||||
|
|
||||||
|
export const SEGMENT_API_TOKEN = ''
|
||||||
320
frontend/utils/laser-card/segmentationCloud.js
Normal file
320
frontend/utils/laser-card/segmentationCloud.js
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
import {
|
||||||
|
ALIYUN_ACCESS_KEY_ID,
|
||||||
|
ALIYUN_ACCESS_KEY_SECRET,
|
||||||
|
ALIYUN_SECURITY_TOKEN,
|
||||||
|
ALIYUN_STS_URL,
|
||||||
|
IMM_REGION,
|
||||||
|
OSS_BUCKET,
|
||||||
|
OSS_KEY_PREFIX,
|
||||||
|
OSS_REGION,
|
||||||
|
SEGMENT_API_BASE,
|
||||||
|
SEGMENT_API_TOKEN,
|
||||||
|
SEGMENT_TRANSPORT
|
||||||
|
} from './segmentApi.js'
|
||||||
|
import {
|
||||||
|
ossPostObjectFromLocalFilePath,
|
||||||
|
ossPresignedObjectUrl,
|
||||||
|
rpcIvpdSegmentImage,
|
||||||
|
looksLikeBareOssEndpointUrl
|
||||||
|
} from './aliyunPortraitUni.js'
|
||||||
|
|
||||||
|
function uniRequest(options) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.request({
|
||||||
|
...options,
|
||||||
|
success: (r) => {
|
||||||
|
if (r.statusCode >= 200 && r.statusCode < 300) {
|
||||||
|
resolve(r)
|
||||||
|
} else {
|
||||||
|
const msg =
|
||||||
|
(typeof r.data === 'object' && r.data && (r.data.Message || r.data.message)) ||
|
||||||
|
(typeof r.data === 'string' ? r.data : '') ||
|
||||||
|
`HTTP ${r.statusCode}`
|
||||||
|
reject(new Error(msg))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (e) => reject(new Error(e.errMsg || '网络请求失败'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内层 Promise 长期无回调时强制失败(避免一直「正在识别」) */
|
||||||
|
function raceWithTimeout(promise, ms, errMsg) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const t = setTimeout(() => reject(new Error(errMsg)), ms)
|
||||||
|
Promise.resolve(promise).then(
|
||||||
|
(v) => {
|
||||||
|
clearTimeout(t)
|
||||||
|
resolve(v)
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
clearTimeout(t)
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStsPayload(raw) {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (raw.Credentials) {
|
||||||
|
const c = raw.Credentials
|
||||||
|
return {
|
||||||
|
accessKeyId: c.AccessKeyId,
|
||||||
|
accessKeySecret: c.AccessKeySecret,
|
||||||
|
securityToken: c.SecurityToken || undefined,
|
||||||
|
expiration: c.Expiration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (raw.accessKeyId && raw.accessKeySecret) {
|
||||||
|
return {
|
||||||
|
accessKeyId: raw.accessKeyId,
|
||||||
|
accessKeySecret: raw.accessKeySecret,
|
||||||
|
securityToken: raw.securityToken || undefined,
|
||||||
|
expiration: raw.expiration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStsCredentials() {
|
||||||
|
const url = (ALIYUN_STS_URL || '').trim()
|
||||||
|
if (!url) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const res = await uniRequest({ url, method: 'GET', timeout: 30000 })
|
||||||
|
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||||
|
const cred = normalizeStsPayload(data)
|
||||||
|
if (!cred || !cred.accessKeyId) {
|
||||||
|
throw new Error('STS 地址返回格式不正确(需 Credentials 或 accessKeyId/accessKeySecret)')
|
||||||
|
}
|
||||||
|
return cred
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开发直配 AK,或 STS 拉取 */
|
||||||
|
function getCredentialsFromConfig() {
|
||||||
|
const id = (ALIYUN_ACCESS_KEY_ID || '').trim()
|
||||||
|
const sec = (ALIYUN_ACCESS_KEY_SECRET || '').trim()
|
||||||
|
const token = (ALIYUN_SECURITY_TOKEN || '').trim()
|
||||||
|
if (id && sec) {
|
||||||
|
return {
|
||||||
|
accessKeyId: id,
|
||||||
|
accessKeySecret: sec,
|
||||||
|
securityToken: token || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCredentials() {
|
||||||
|
const fromFile = getCredentialsFromConfig()
|
||||||
|
if (fromFile) {
|
||||||
|
return fromFile
|
||||||
|
}
|
||||||
|
const url = (ALIYUN_STS_URL || '').trim()
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('请配置 ALIYUN_ACCESS_KEY_ID/SECRET(开发)或 ALIYUN_STS_URL(生产)')
|
||||||
|
}
|
||||||
|
return fetchStsCredentials()
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUseDirectAliyunClient() {
|
||||||
|
const bucket = (OSS_BUCKET || '').trim()
|
||||||
|
const hasAk =
|
||||||
|
!!(ALIYUN_ACCESS_KEY_ID || '').trim() && !!(ALIYUN_ACCESS_KEY_SECRET || '').trim()
|
||||||
|
const hasStsUrl = !!(ALIYUN_STS_URL || '').trim()
|
||||||
|
return !!(bucket && (hasAk || hasStsUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {'direct' | 'backend'}
|
||||||
|
*/
|
||||||
|
function resolveSegmentTransport() {
|
||||||
|
const raw = String(SEGMENT_TRANSPORT == null ? 'auto' : SEGMENT_TRANSPORT)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
if (raw === 'backend' || raw === 'proxy' || raw === 'server') {
|
||||||
|
return 'backend'
|
||||||
|
}
|
||||||
|
if (raw === 'direct' || raw === 'aliyun' || raw === 'client' || raw === 'oss') {
|
||||||
|
if (!canUseDirectAliyunClient()) {
|
||||||
|
throw new Error(
|
||||||
|
'SEGMENT_TRANSPORT 为 direct/aliyun/client/oss 时需配置 OSS_BUCKET 以及(ALIYUN_ACCESS_KEY_ID+SECRET 或 ALIYUN_STS_URL)'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return 'direct'
|
||||||
|
}
|
||||||
|
return canUseDirectAliyunClient() ? 'direct' : 'backend'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 凭证 → OSS PostObject → 签名 GET URL → IVPD SegmentImage(Url)→ downloadFile
|
||||||
|
*/
|
||||||
|
async function segmentViaOssIvpd(filePath) {
|
||||||
|
const bucket = (OSS_BUCKET || '').trim()
|
||||||
|
if (!bucket) {
|
||||||
|
throw new Error('请在 config/segmentApi.js 配置 OSS_BUCKET')
|
||||||
|
}
|
||||||
|
|
||||||
|
const cred = await resolveCredentials()
|
||||||
|
|
||||||
|
const ext = /\.png$/i.test(String(filePath || '')) ? 'png' : 'jpg'
|
||||||
|
const base = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
const prefix = OSS_KEY_PREFIX.replace(/^\/+/, '')
|
||||||
|
const objectKeyIn = `${prefix}${base}.${ext}`
|
||||||
|
|
||||||
|
await ossPostObjectFromLocalFilePath({
|
||||||
|
bucket,
|
||||||
|
ossRegionId: OSS_REGION,
|
||||||
|
accessKeyId: cred.accessKeyId,
|
||||||
|
accessKeySecret: cred.accessKeySecret,
|
||||||
|
securityToken: cred.securityToken,
|
||||||
|
objectKey: objectKeyIn,
|
||||||
|
filePath
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageUrlForSeg = await ossPresignedObjectUrl({
|
||||||
|
bucket,
|
||||||
|
ossRegionId: OSS_REGION,
|
||||||
|
accessKeyId: cred.accessKeyId,
|
||||||
|
accessKeySecret: cred.accessKeySecret,
|
||||||
|
securityToken: cred.securityToken,
|
||||||
|
objectKey: objectKeyIn,
|
||||||
|
expiresSec: 3600
|
||||||
|
})
|
||||||
|
|
||||||
|
const ivpdRegion = (IMM_REGION || '').trim() || String(OSS_REGION || '').replace(/^oss-/i, '')
|
||||||
|
|
||||||
|
const outUrl = await rpcIvpdSegmentImage({
|
||||||
|
imageUrl: imageUrlForSeg,
|
||||||
|
accessKeyId: cred.accessKeyId,
|
||||||
|
accessKeySecret: cred.accessKeySecret,
|
||||||
|
securityToken: cred.securityToken,
|
||||||
|
regionId: ivpdRegion
|
||||||
|
})
|
||||||
|
|
||||||
|
return raceWithTimeout(
|
||||||
|
new Promise((res, rej) => {
|
||||||
|
uni.downloadFile({
|
||||||
|
url: outUrl,
|
||||||
|
timeout: 120000,
|
||||||
|
success: (dr) => {
|
||||||
|
if (dr.statusCode !== 200 || !dr.tempFilePath) {
|
||||||
|
rej(new Error('下载分割结果失败'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res({ kind: 'cutout', localPath: dr.tempFilePath })
|
||||||
|
},
|
||||||
|
fail: (e) => {
|
||||||
|
const raw = e.errMsg || '未知错误'
|
||||||
|
const needDomainHint =
|
||||||
|
looksLikeBareOssEndpointUrl(raw) ||
|
||||||
|
(/^https?:\/\//i.test(String(raw).trim()) && String(raw).includes('aliyuncs'))
|
||||||
|
const extra = needDomainHint
|
||||||
|
? '(若仅为 URL:请在 manifest → networkSecurity.domainWhiteList 中加入下载域名并重编译。)'
|
||||||
|
: ''
|
||||||
|
rej(new Error(`下载分割结果失败:${raw}${extra}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
125000,
|
||||||
|
'下载分割结果超时'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 方案二:自建 Node 代理(server/index.js)
|
||||||
|
*/
|
||||||
|
function segmentViaProxy(filePath) {
|
||||||
|
const base = (SEGMENT_API_BASE || '').trim().replace(/\/+$/, '')
|
||||||
|
if (!base) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error(
|
||||||
|
'缺少 SEGMENT_API_BASE:HTTP/后端抠图需要填写根地址(POST {BASE}/segment)。若使用客户端直连阿里云,请配置 OSS_BUCKET 与 AK 或 STS,并将 SEGMENT_TRANSPORT 设为 auto 或 direct。'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const url = `${base}/segment`
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.uploadFile({
|
||||||
|
url,
|
||||||
|
filePath,
|
||||||
|
name: 'image',
|
||||||
|
timeout: 120000,
|
||||||
|
header: SEGMENT_API_TOKEN ? { 'x-laser-token': SEGMENT_API_TOKEN } : {},
|
||||||
|
success: (res) => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
reject(new Error(typeof res.data === 'string' ? res.data : `HTTP ${res.statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let body
|
||||||
|
try {
|
||||||
|
body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error('代理返回非 JSON'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!body || body.ok !== true || !body.imageUrl) {
|
||||||
|
reject(new Error((body && body.error) || '抠图失败'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.downloadFile({
|
||||||
|
url: body.imageUrl,
|
||||||
|
timeout: 120000,
|
||||||
|
success: (dr) => {
|
||||||
|
if (dr.statusCode !== 200 || !dr.tempFilePath) {
|
||||||
|
reject(new Error('下载分割结果失败'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve({ kind: 'cutout', localPath: dr.tempFilePath })
|
||||||
|
},
|
||||||
|
fail: (e) => reject(new Error(e.errMsg || 'downloadFile 失败'))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: (e) => reject(new Error(e.errMsg || 'uploadFile 失败'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 防止某一步网络/原生无回调导致一直转圈 */
|
||||||
|
function withTimeout(promise, ms, message) {
|
||||||
|
let timer = null
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null
|
||||||
|
reject(new Error(message))
|
||||||
|
}, ms)
|
||||||
|
promise.then(
|
||||||
|
(v) => {
|
||||||
|
if (timer != null) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
resolve(v)
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
if (timer != null) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传本地图并获取抠图结果本地路径。
|
||||||
|
* 链路由 config/segmentApi.js 的 SEGMENT_TRANSPORT 决定:direct=客户端 OSS + IVPD SegmentImage;backend=HTTP 后端(契约同 server/index.js);auto=能直连则直连否则走后端。
|
||||||
|
*/
|
||||||
|
export function segmentPortraitToLocal(filePath) {
|
||||||
|
const transport = resolveSegmentTransport()
|
||||||
|
const inner =
|
||||||
|
transport === 'direct' ? segmentViaOssIvpd(filePath) : segmentViaProxy(filePath)
|
||||||
|
return withTimeout(
|
||||||
|
inner,
|
||||||
|
240000,
|
||||||
|
'智能抠图超时:请检查手机网络、OSS、IVPD 地域(IMM_REGION,如 cn-shanghai)、RAM 权限及 manifest 网络权限'
|
||||||
|
)
|
||||||
|
}
|
||||||
256
frontend/utils/lenticular-engine.js
Normal file
256
frontend/utils/lenticular-engine.js
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* 光栅叠化引擎(自 Raster-Card-L 移植为纯 JS,无 DOM 依赖)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PREV_LAYER_GHOST_MIN = 0.065
|
||||||
|
|
||||||
|
const NON_DOMINANT_RESIDUAL_MIN = 0.055
|
||||||
|
const PARALLAX_UNIFY_GAP_LO = 0.065
|
||||||
|
const PARALLAX_UNIFY_GAP_HI = 0.2
|
||||||
|
const OFFSET_X_EMA_BASE = 0.16
|
||||||
|
const OFFSET_X_EMA_STAB_SCALE = 0.26
|
||||||
|
const PARALLAX_BLEND_T_EMA_BASE = 0.14
|
||||||
|
const PARALLAX_BLEND_T_EMA_STAB_SCALE = 0.24
|
||||||
|
|
||||||
|
export const DEFAULT_PHYSICS = {
|
||||||
|
tiltSensitivity: 72,
|
||||||
|
transitionSmoothness: 66,
|
||||||
|
parallaxDepth: 0,
|
||||||
|
gyroSimEnabled: true,
|
||||||
|
backgroundAnglePercent: 30,
|
||||||
|
angleStability: 88,
|
||||||
|
lenticularPitchPx: 16,
|
||||||
|
/** 1 = 默认;0 = 关闭 displayGamma 近零软衰减(硬件倾斜起步更跟手) */
|
||||||
|
sensorDeadzoneStrength: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(a, b, t) {
|
||||||
|
return a + (b - a) * t
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(v, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothstep(edge0, edge1, x) {
|
||||||
|
const t = clamp((x - edge0) / (edge1 - edge0), 0, 1)
|
||||||
|
return t * t * (3 - 2 * t)
|
||||||
|
}
|
||||||
|
|
||||||
|
function softAttenuateNearZero(g, db) {
|
||||||
|
if (db <= 1e-9) return g
|
||||||
|
const a = Math.abs(g)
|
||||||
|
if (a >= db) return g
|
||||||
|
const k = smoothstep(0, db, a)
|
||||||
|
return (g >= 0 ? 1 : -1) * a * k
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LenticularEngine {
|
||||||
|
constructor(physics) {
|
||||||
|
this.layers = []
|
||||||
|
this.physics = { ...physics }
|
||||||
|
this.smoothedSensor = { gamma: 0, beta: 0, timestamp: 0 }
|
||||||
|
this.displayGamma = 0
|
||||||
|
this.layerOffsetXSmoothed = new Map()
|
||||||
|
this.parallaxBlendTSmoothed = 0
|
||||||
|
this.renderState = {
|
||||||
|
layerOffsets: new Map(),
|
||||||
|
layerOpacities: new Map(),
|
||||||
|
stripePhaseShift: 0,
|
||||||
|
stripShares: [1 / 3, 1 / 3, 1 / 3],
|
||||||
|
lenticularPitchPx: 14,
|
||||||
|
prevLayerGhost: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLayers(layers) {
|
||||||
|
this.layers = [...layers]
|
||||||
|
this.layerOffsetXSmoothed.clear()
|
||||||
|
this.parallaxBlendTSmoothed = 0
|
||||||
|
for (const layer of this.layers) {
|
||||||
|
this.renderState.layerOffsets.set(layer.id, { x: 0, y: 0 })
|
||||||
|
this.renderState.layerOpacities.set(layer.id, layer.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePhysics(config) {
|
||||||
|
Object.assign(this.physics, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
getPhysics() {
|
||||||
|
return { ...this.physics }
|
||||||
|
}
|
||||||
|
|
||||||
|
feedSensor(raw) {
|
||||||
|
const alpha = this.calcSmoothingAlpha()
|
||||||
|
this.smoothedSensor = {
|
||||||
|
gamma: lerp(this.smoothedSensor.gamma, raw.gamma, alpha),
|
||||||
|
beta: 0,
|
||||||
|
timestamp: raw.timestamp,
|
||||||
|
}
|
||||||
|
this.updateDisplayStable()
|
||||||
|
return this.computeRenderState()
|
||||||
|
}
|
||||||
|
|
||||||
|
feedSimulatedTilt(normalizedX, _normalizedY) {
|
||||||
|
return this.feedSensor({
|
||||||
|
gamma: clamp(normalizedX, -1, 1),
|
||||||
|
beta: 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
computeRenderState() {
|
||||||
|
const sensitivity = this.physics.tiltSensitivity / 100
|
||||||
|
const maxDisplacement = this.physics.parallaxDepth
|
||||||
|
const gamma = this.displayGamma
|
||||||
|
const N = this.layers.length
|
||||||
|
|
||||||
|
const gPick = clamp(gamma * (0.44 + sensitivity * 0.52), -1, 1)
|
||||||
|
const u = (gPick + 1) / 2
|
||||||
|
|
||||||
|
const stability = this.physics.angleStability / 100
|
||||||
|
const smooth = this.physics.transitionSmoothness / 100
|
||||||
|
const blendBase = 0.04 + smooth * 0.13 + stability * 0.15
|
||||||
|
|
||||||
|
const shares = this.computeStripShares(N)
|
||||||
|
const centers = []
|
||||||
|
let cum = 0
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
centers.push(cum + shares[i] / 2)
|
||||||
|
cum += shares[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawWeights = []
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const half = shares[i] / 2 + blendBase
|
||||||
|
const d = Math.abs(u - centers[i])
|
||||||
|
rawWeights.push(Math.max(0, 1 - d / half))
|
||||||
|
}
|
||||||
|
const sumW = rawWeights.reduce((a, b) => a + b, 0) || 1
|
||||||
|
|
||||||
|
const smoothedW = []
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
let weight = rawWeights[i] / sumW
|
||||||
|
weight = weight * weight * (3 - 2 * weight)
|
||||||
|
smoothedW.push(weight)
|
||||||
|
}
|
||||||
|
|
||||||
|
let dominant = 0
|
||||||
|
let bestScore = -1
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const layer = this.layers[i]
|
||||||
|
const score = layer.opacity * smoothedW[i]
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
dominant = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderState.prevLayerGhost = null
|
||||||
|
|
||||||
|
const parallaxScale = 0.42
|
||||||
|
const sortedW = [...smoothedW].sort((a, b) => b - a)
|
||||||
|
const weightGap = N >= 2 ? sortedW[0] - sortedW[1] : 1
|
||||||
|
const parallaxLayerBlendTRaw = smoothstep(PARALLAX_UNIFY_GAP_LO, PARALLAX_UNIFY_GAP_HI, weightGap)
|
||||||
|
const stabForEma = clamp(stability, 0, 1)
|
||||||
|
const blendTEmaMix = PARALLAX_BLEND_T_EMA_BASE + stabForEma * PARALLAX_BLEND_T_EMA_STAB_SCALE
|
||||||
|
this.parallaxBlendTSmoothed = lerp(this.parallaxBlendTSmoothed, parallaxLayerBlendTRaw, blendTEmaMix)
|
||||||
|
const parallaxLayerBlendT = this.parallaxBlendTSmoothed
|
||||||
|
|
||||||
|
const swSum = smoothedW.reduce((a, b) => a + b, 0) || 1
|
||||||
|
let unifiedParallaxFactor = 0
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
unifiedParallaxFactor += (smoothedW[i] / swSum) * this.layers[i].parallaxFactor
|
||||||
|
}
|
||||||
|
const unifiedEffective = maxDisplacement * sensitivity * unifiedParallaxFactor * parallaxScale
|
||||||
|
const unifiedOffsetX = gamma * unifiedEffective
|
||||||
|
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const layer = this.layers[i]
|
||||||
|
|
||||||
|
const specificEffective = maxDisplacement * sensitivity * layer.parallaxFactor * parallaxScale
|
||||||
|
const specificOffsetX = gamma * specificEffective
|
||||||
|
const offsetXRaw = lerp(unifiedOffsetX, specificOffsetX, parallaxLayerBlendT)
|
||||||
|
const offsetEmaMix = OFFSET_X_EMA_BASE + stabForEma * OFFSET_X_EMA_STAB_SCALE
|
||||||
|
const prevX = this.layerOffsetXSmoothed.get(layer.id)
|
||||||
|
const offsetX = prevX === undefined ? offsetXRaw : lerp(prevX, offsetXRaw, offsetEmaMix)
|
||||||
|
this.layerOffsetXSmoothed.set(layer.id, offsetX)
|
||||||
|
this.renderState.layerOffsets.set(layer.id, { x: offsetX, y: 0 })
|
||||||
|
|
||||||
|
const weight = smoothedW[i]
|
||||||
|
const isAnchor = i === 0
|
||||||
|
const floor = isAnchor && N > 1 ? 0.18 : 0
|
||||||
|
const finalOpacity = clamp(Math.max(floor, layer.opacity * weight), 0, 1)
|
||||||
|
this.renderState.layerOpacities.set(layer.id, finalOpacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dominant >= 1) {
|
||||||
|
const prev = this.layers[dominant - 1]
|
||||||
|
const minGhost = PREV_LAYER_GHOST_MIN * prev.opacity
|
||||||
|
const cur = this.renderState.layerOpacities.get(prev.id) != null ? this.renderState.layerOpacities.get(prev.id) : 0
|
||||||
|
const boosted = clamp(Math.max(cur, minGhost), 0, 1)
|
||||||
|
this.renderState.layerOpacities.set(prev.id, boosted)
|
||||||
|
this.renderState.prevLayerGhost = { layerId: prev.id, alpha: minGhost }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (N >= 2) {
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
if (i === dominant) continue
|
||||||
|
const layer = this.layers[i]
|
||||||
|
const minRes = NON_DOMINANT_RESIDUAL_MIN * layer.opacity
|
||||||
|
const cur = this.renderState.layerOpacities.get(layer.id) != null ? this.renderState.layerOpacities.get(layer.id) : 0
|
||||||
|
this.renderState.layerOpacities.set(layer.id, clamp(Math.max(cur, minRes), 0, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderState.stripShares = [...shares]
|
||||||
|
this.renderState.stripePhaseShift = gamma * (0.38 + 0.52 * sensitivity)
|
||||||
|
this.renderState.lenticularPitchPx = clamp(
|
||||||
|
Number.isFinite(this.physics.lenticularPitchPx) ? this.physics.lenticularPitchPx : 14,
|
||||||
|
8,
|
||||||
|
64
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.renderState
|
||||||
|
}
|
||||||
|
|
||||||
|
computeStripShares(N) {
|
||||||
|
if (N <= 0) return []
|
||||||
|
if (N === 1) return [1]
|
||||||
|
if (N !== 3) {
|
||||||
|
const w = 1 / N
|
||||||
|
return Array.from({ length: N }, () => w)
|
||||||
|
}
|
||||||
|
const bg = clamp(this.physics.backgroundAnglePercent / 100, 0.1, 0.55)
|
||||||
|
const rest = (1 - bg) / 2
|
||||||
|
return [bg, rest, rest]
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDisplayStable() {
|
||||||
|
const stab = clamp(this.physics.angleStability / 100, 0, 1)
|
||||||
|
const k = 0.006 + (1 - stab) * 0.095
|
||||||
|
let g = lerp(this.displayGamma, this.smoothedSensor.gamma, k)
|
||||||
|
const dead =
|
||||||
|
this.physics.sensorDeadzoneStrength != null ? Number(this.physics.sensorDeadzoneStrength) : 1
|
||||||
|
if (!Number.isFinite(dead) || dead <= 1e-6) {
|
||||||
|
this.displayGamma = clamp(g, -1, 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const db = (0.016 + (1 - stab) * 0.034) * dead
|
||||||
|
this.displayGamma = softAttenuateNearZero(g, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
calcSmoothingAlpha() {
|
||||||
|
const s = this.physics.transitionSmoothness / 100
|
||||||
|
return 1 - s * 0.9
|
||||||
|
}
|
||||||
|
|
||||||
|
getRenderState() {
|
||||||
|
return this.renderState
|
||||||
|
}
|
||||||
|
|
||||||
|
getSmoothedSensor() {
|
||||||
|
return { ...this.smoothedSensor }
|
||||||
|
}
|
||||||
|
}
|
||||||
83
frontend/vite.config.js
Normal file
83
frontend/vite.config.js
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import https from 'node:https'
|
||||||
|
import uni from '@dcloudio/vite-plugin-uni'
|
||||||
|
|
||||||
|
/** 与 upload-signature 返回的 OSS 虚拟域名一致;换桶时请同步 */
|
||||||
|
const OSS_DEV_HOST = 'top-fans-test.oss-cn-shanghai.aliyuncs.com'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部分环境下 Vite 内置 server.proxy 的 rewrite 对 POST 未生效,OSS 仍收到路径
|
||||||
|
* /dev-oss-proxy,从而 405(ResourceType: Object)。此处用中间件固定转发到桶根 POST /。
|
||||||
|
*/
|
||||||
|
function ossDevPostProxyPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'oss-dev-post-proxy',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
const raw = req.url || ''
|
||||||
|
if (!raw.startsWith('/dev-oss-proxy')) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.statusCode = 204
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS')
|
||||||
|
res.setHeader(
|
||||||
|
'Access-Control-Allow-Headers',
|
||||||
|
String(req.headers['access-control-request-headers'] || '*')
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
res.statusCode = 405
|
||||||
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||||
|
res.end('OSS dev proxy only allows POST')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const hop = new Set([
|
||||||
|
'connection',
|
||||||
|
'keep-alive',
|
||||||
|
'proxy-authenticate',
|
||||||
|
'proxy-authorization',
|
||||||
|
'te',
|
||||||
|
'trailers',
|
||||||
|
'transfer-encoding',
|
||||||
|
'upgrade'
|
||||||
|
])
|
||||||
|
/** @type {import('node:http').OutgoingHttpHeaders} */
|
||||||
|
const headers = {}
|
||||||
|
for (const [k, v] of Object.entries(req.headers)) {
|
||||||
|
if (v === undefined || hop.has(k.toLowerCase())) continue
|
||||||
|
headers[k] = v
|
||||||
|
}
|
||||||
|
headers.host = OSS_DEV_HOST
|
||||||
|
const proxyReq = https.request(
|
||||||
|
{
|
||||||
|
hostname: OSS_DEV_HOST,
|
||||||
|
port: 443,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/',
|
||||||
|
headers
|
||||||
|
},
|
||||||
|
(proxyRes) => {
|
||||||
|
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers)
|
||||||
|
proxyRes.pipe(res)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
proxyReq.on('error', (err) => {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.statusCode = 502
|
||||||
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||||
|
}
|
||||||
|
res.end(err.message)
|
||||||
|
})
|
||||||
|
req.pipe(proxyReq)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [ossDevPostProxyPlugin(), uni()]
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user