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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
<script>
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
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",
|
||||
"style": {
|
||||
|
||||
@ -5,9 +5,12 @@
|
||||
<!-- 左侧图片卡片区域 - 半圆弧形布局 -->
|
||||
<view class="cards-container">
|
||||
<view v-for="(card, index) in cardList" :key="index" class="card-item"
|
||||
:class="{ 'card-selected': selectedIndex === index }" :style="getCardStyle(index)"
|
||||
@click="selectCard(index)">
|
||||
<view class="card-frame" :class="{ 'no-border': card.comingSoon }">
|
||||
:class="{ 'card-selected': selectedIndex === index }" :style="getCardStyle(index)">
|
||||
<view
|
||||
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 v-if="card.comingSoon" class="coming-soon-badge" src="/static/castlove/jinqingqidai.png" mode="aspectFit" />
|
||||
</view>
|
||||
@ -48,6 +51,18 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
onShow() {
|
||||
try {
|
||||
uni.hideToast();
|
||||
} catch (e) {
|
||||
/* noop */
|
||||
}
|
||||
try {
|
||||
uni.hideLoading();
|
||||
} catch (e) {
|
||||
/* noop */
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedIndex: 2, // 初始选中光栅卡
|
||||
@ -99,6 +114,35 @@ export default {
|
||||
selectCard(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() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
@ -115,13 +159,14 @@ export default {
|
||||
}
|
||||
},
|
||||
handleSkip() {
|
||||
// 跳过,选择默认卡片或第一张
|
||||
const defaultIndex = this.cardList.findIndex(card => !card.comingSoon);
|
||||
if (defaultIndex !== -1) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/castlove/create?name=${encodeURIComponent(this.cardList[defaultIndex].name)}`
|
||||
});
|
||||
const card = this.cardList[this.selectedIndex]
|
||||
if (!card || card.comingSoon) {
|
||||
uni.showToast({ title: '请选择已开放的工艺', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: `/pages/castlove/create?name=${encodeURIComponent(card.name)}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -156,8 +201,10 @@ export default {
|
||||
width: 344rpx;
|
||||
height: 344rpx;
|
||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-frame--tappable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: 'HorizontalScroll'
|
||||
});
|
||||
<script>
|
||||
export default {
|
||||
name: 'HorizontalScroll',
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -45,6 +45,7 @@ import DiscoverFeed from './discover-feed.vue';
|
||||
import CreateFeed from './create-feed.vue';
|
||||
// 导入API
|
||||
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js';
|
||||
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js';
|
||||
|
||||
const activeTab = ref(0);
|
||||
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('file', blob, fileName);
|
||||
|
||||
const response = await fetch(ossData.host, {
|
||||
const response = await fetch(resolveH5OssPostUrl(ossData.host), {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
@ -203,16 +204,16 @@ const handleSkip = async () => {
|
||||
// 更新加载提示
|
||||
uni.showLoading({ title: '创建订单中...', mask: true });
|
||||
|
||||
// 构建订单数据
|
||||
// 构建订单数据(对齐 CreateMintOrderRequestDTO)
|
||||
const orderData = {
|
||||
name: orderValue.name,
|
||||
event: orderValue.event,
|
||||
description: orderValue.remark || '',
|
||||
material_type: orderValue.materialType || 'image',
|
||||
order_id: orderId,
|
||||
name: orderValue.name || '',
|
||||
material_url: imageUrl,
|
||||
rarity: 0,
|
||||
tags: [],
|
||||
order_id: orderId
|
||||
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
|
||||
@ -229,11 +230,13 @@ const handleSkip = async () => {
|
||||
// 构建藏品数据,存储到temp_nft_data
|
||||
const nftData = {
|
||||
image: imageUrl,
|
||||
name: orderValue.name,
|
||||
event: orderValue.event,
|
||||
description: orderValue.remark || '',
|
||||
material_type: orderValue.materialType || 'image',
|
||||
order_id: orderId
|
||||
name: orderValue.name || '',
|
||||
description: orderValue.description || orderValue.remark || '',
|
||||
material_type: orderValue.material_type || orderValue.materialType || '',
|
||||
tags: Array.isArray(orderValue.tags) ? orderValue.tags : [],
|
||||
order_id: orderId,
|
||||
info: orderValue.info || '',
|
||||
event: orderValue.info || orderValue.event || '',
|
||||
};
|
||||
|
||||
// 存储到storage
|
||||
|
||||
@ -75,6 +75,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js';
|
||||
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js';
|
||||
|
||||
const generatedImages = ref([]);
|
||||
const selectedIndex = ref(-1);
|
||||
@ -422,12 +423,12 @@ const uploadToOssH5 = async (base64Image, fileName, ossData) => {
|
||||
formData.append('file', blob, fileName);
|
||||
|
||||
// 使用fetch上传
|
||||
fetch(ossData.host, {
|
||||
fetch(resolveH5OssPostUrl(ossData.host), {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
if (response.ok || response.status === 204) {
|
||||
const imageUrl = `${ossData.host}/${ossData.dir}${fileName}`;
|
||||
uni.hideLoading();
|
||||
uni.showToast({
|
||||
@ -656,17 +657,16 @@ const selectAsset = async () => {
|
||||
const formDataStr = uni.getStorageSync('castlove_form_data');
|
||||
const orderValue = formDataStr ? JSON.parse(formDataStr) : {};
|
||||
|
||||
// 构建订单数据
|
||||
// 构建订单数据(对齐 CreateMintOrderRequestDTO)
|
||||
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,
|
||||
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
|
||||
@ -679,12 +679,14 @@ const selectAsset = async () => {
|
||||
uni.removeStorageSync('castlove_form_data');
|
||||
// 构建藏品数据,存储到temp_nft_data
|
||||
const nftData = {
|
||||
image: imageUrl, // 使用OSS URL
|
||||
name: orderValue.name,
|
||||
event: orderValue.event,
|
||||
description: orderValue.description,
|
||||
material_type: orderValue.material_type,
|
||||
order_id: currentOrderId.value // 使用ref保存的order_id
|
||||
image: imageUrl,
|
||||
name: orderValue.name || '',
|
||||
description: orderValue.description || '',
|
||||
material_type: orderValue.material_type || orderValue.materialType || '',
|
||||
tags: Array.isArray(orderValue.tags) ? orderValue.tags : [],
|
||||
order_id: currentOrderId.value,
|
||||
info: orderValue.info || '',
|
||||
event: orderValue.info || orderValue.event || '',
|
||||
};
|
||||
|
||||
// 存储到storage
|
||||
|
||||
@ -4,23 +4,28 @@
|
||||
|
||||
// #ifdef 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
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
// 开发调试:手机和电脑同一WiFi时用这个(改成你电脑IP)
|
||||
// 上线后:改成实际服务器地址
|
||||
const baseURL = 'http://192.168.110.60:8080'
|
||||
// const baseURL = 'http://192.168.110.60:8080'
|
||||
// #endif
|
||||
|
||||
// 服务器地址(正式上线用)
|
||||
// #ifdef APP-PLUS
|
||||
// const baseURL = 'http://101.132.250.62:8080'
|
||||
const baseURL = 'http://101.132.250.62:8080'
|
||||
// #endif
|
||||
|
||||
// 是否使用模拟数据(开发调试时设为 true,后端API准备好后改为 false)
|
||||
const USE_MOCK_API = false
|
||||
|
||||
/** 网关根地址(供 uni.uploadFile 等无法走 request 封装的场景拼接完整 URL) */
|
||||
export function getApiBaseUrl() {
|
||||
return String(baseURL).replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// 模拟网络延迟
|
||||
function mockDelay(ms = 800) {
|
||||
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({
|
||||
url: `/api/v1/assets/oss/signature?type=${type}`,
|
||||
url,
|
||||
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') {
|
||||
return request({
|
||||
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) {
|
||||
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)====================
|
||||
|
||||
// 获取铸造活动列表
|
||||
|
||||
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