chore: 归档当前现有代码,准备新建分支开发插件验证

This commit is contained in:
liulong 2026-05-14 16:42:27 +08:00
parent 6e09afe843
commit fe7fbba1fc
22 changed files with 7120 additions and 238 deletions

View 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;

View File

@ -24,11 +24,13 @@
</view> </view>
</template> </template>
<script setup> <script>
defineOptions({ export default {
inheritAttrs: false inheritAttrs: false,
}) };
</script>
<script setup>
const props = defineProps({ const props = defineProps({
visible: { visible: {
type: Boolean, type: Boolean,

View 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>

View 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 + unicreateSelectorQuery().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>

View 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,
}
}

View File

@ -134,6 +134,25 @@
} }
} }
}, },
{
"path": "pages/castlove/lenticular-studio",
"style": {
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
}
}
},
{
"path": "pages/castlove/laser-card-studio",
"style": {
"navigationStyle": "custom",
"backgroundColor": "#0a0a0a",
"app-plus": {
"bounce": "none"
}
}
},
{ {
"path": "pages/castlove/craft-select", "path": "pages/castlove/craft-select",
"style": { "style": {

View File

@ -5,9 +5,12 @@
<!-- 左侧图片卡片区域 - 半圆弧形布局 --> <!-- 左侧图片卡片区域 - 半圆弧形布局 -->
<view class="cards-container"> <view class="cards-container">
<view v-for="(card, index) in cardList" :key="index" class="card-item" <view v-for="(card, index) in cardList" :key="index" class="card-item"
:class="{ 'card-selected': selectedIndex === index }" :style="getCardStyle(index)" :class="{ 'card-selected': selectedIndex === index }" :style="getCardStyle(index)">
@click="selectCard(index)"> <view
<view class="card-frame" :class="{ 'no-border': card.comingSoon }"> class="card-frame"
:class="{ 'no-border': card.comingSoon, 'card-frame--tappable': !card.comingSoon }"
@tap.stop="onCardFrameTap(index)"
>
<image class="card-image" :src="card.image" mode="aspectFill" /> <image class="card-image" :src="card.image" mode="aspectFill" />
<image v-if="card.comingSoon" class="coming-soon-badge" src="/static/castlove/jinqingqidai.png" mode="aspectFit" /> <image v-if="card.comingSoon" class="coming-soon-badge" src="/static/castlove/jinqingqidai.png" mode="aspectFit" />
</view> </view>
@ -48,6 +51,18 @@
<script> <script>
export default { export default {
onShow() {
try {
uni.hideToast();
} catch (e) {
/* noop */
}
try {
uni.hideLoading();
} catch (e) {
/* noop */
}
},
data() { data() {
return { return {
selectedIndex: 2, // selectedIndex: 2, //
@ -99,6 +114,35 @@ export default {
selectCard(index) { selectCard(index) {
this.selectedIndex = index; this.selectedIndex = index;
}, },
/** 当前叠卡在弧形布局中的槽位2=正中主图最大1=中上「第二张」叠层0最上… */
getCardStackPosition(index) {
return (index - this.selectedIndex + 2 + 5) % 5;
},
/**
* 点击卡图区域
* - 正中主图槽位 2 进入对应工艺创建页光栅卡即进入已接入预览的 create
* - 中上叠层槽位 1常为拍立得示意在选中光栅时 同样进入光栅卡创建与设计稿点第二张进光栅一致
* - 其余叠层 仅切换选中
*/
onCardFrameTap(index) {
const card = this.cardList[index];
if (!card || card.comingSoon) {
return;
}
const pos = this.getCardStackPosition(index);
const LENTICULAR_INDEX = 2;
const goLenticular =
this.selectedIndex === LENTICULAR_INDEX &&
(pos === 2 || pos === 1);
const targetName = goLenticular ? '光栅卡' : card.name;
if (pos === 2 || goLenticular) {
uni.navigateTo({
url: `/pages/castlove/create?name=${encodeURIComponent(targetName)}`,
});
return;
}
this.selectCard(index);
},
handleBack() { handleBack() {
uni.navigateBack() uni.navigateBack()
}, },
@ -115,13 +159,14 @@ export default {
} }
}, },
handleSkip() { handleSkip() {
// const card = this.cardList[this.selectedIndex]
const defaultIndex = this.cardList.findIndex(card => !card.comingSoon); if (!card || card.comingSoon) {
if (defaultIndex !== -1) { uni.showToast({ title: '请选择已开放的工艺', icon: 'none' })
uni.navigateTo({ return
url: `/pages/castlove/create?name=${encodeURIComponent(this.cardList[defaultIndex].name)}`
});
} }
uni.navigateTo({
url: `/pages/castlove/create?name=${encodeURIComponent(card.name)}`
})
} }
} }
} }
@ -156,8 +201,10 @@ export default {
width: 344rpx; width: 344rpx;
height: 344rpx; height: 344rpx;
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer; }
.card-frame--tappable {
cursor: pointer;
} }
.card-frame { .card-frame {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

View File

@ -12,10 +12,10 @@
</scroll-view> </scroll-view>
</template> </template>
<script setup> <script>
defineOptions({ export default {
name: 'HorizontalScroll' name: 'HorizontalScroll',
}); };
</script> </script>
<style scoped> <style scoped>

View File

@ -45,6 +45,7 @@ import DiscoverFeed from './discover-feed.vue';
import CreateFeed from './create-feed.vue'; import CreateFeed from './create-feed.vue';
// API // API
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js'; import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js';
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js';
const activeTab = ref(0); const activeTab = ref(0);
const formData = ref(null); const formData = ref(null);
@ -97,7 +98,7 @@ const uploadImageToOss = async (base64Image, ossData) => {
formData.append('x-oss-signature-version', ossData.x_oss_signature_version); formData.append('x-oss-signature-version', ossData.x_oss_signature_version);
formData.append('file', blob, fileName); formData.append('file', blob, fileName);
const response = await fetch(ossData.host, { const response = await fetch(resolveH5OssPostUrl(ossData.host), {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
@ -203,16 +204,16 @@ const handleSkip = async () => {
// //
uni.showLoading({ title: '创建订单中...', mask: true }); uni.showLoading({ title: '创建订单中...', mask: true });
// // CreateMintOrderRequestDTO
const orderData = { const orderData = {
name: orderValue.name, order_id: orderId,
event: orderValue.event, name: orderValue.name || '',
description: orderValue.remark || '',
material_type: orderValue.materialType || 'image',
material_url: imageUrl, material_url: imageUrl,
rarity: 0, description: orderValue.description || '',
tags: [], grade: orderValue.grade ?? 0,
order_id: orderId tags: Array.isArray(orderValue.tags) ? orderValue.tags : [],
material_type: orderValue.material_type || orderValue.materialType || '',
info: orderValue.info || orderValue.event || '',
}; };
// API // API
@ -229,11 +230,13 @@ const handleSkip = async () => {
// temp_nft_data // temp_nft_data
const nftData = { const nftData = {
image: imageUrl, image: imageUrl,
name: orderValue.name, name: orderValue.name || '',
event: orderValue.event, description: orderValue.description || orderValue.remark || '',
description: orderValue.remark || '', material_type: orderValue.material_type || orderValue.materialType || '',
material_type: orderValue.materialType || 'image', tags: Array.isArray(orderValue.tags) ? orderValue.tags : [],
order_id: orderId order_id: orderId,
info: orderValue.info || '',
event: orderValue.info || orderValue.event || '',
}; };
// storage // storage

View File

@ -75,6 +75,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js'; import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js';
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js';
const generatedImages = ref([]); const generatedImages = ref([]);
const selectedIndex = ref(-1); const selectedIndex = ref(-1);
@ -422,12 +423,12 @@ const uploadToOssH5 = async (base64Image, fileName, ossData) => {
formData.append('file', blob, fileName); formData.append('file', blob, fileName);
// 使fetch // 使fetch
fetch(ossData.host, { fetch(resolveH5OssPostUrl(ossData.host), {
method: 'POST', method: 'POST',
body: formData body: formData
}) })
.then(response => { .then(response => {
if (response.ok) { if (response.ok || response.status === 204) {
const imageUrl = `${ossData.host}/${ossData.dir}${fileName}`; const imageUrl = `${ossData.host}/${ossData.dir}${fileName}`;
uni.hideLoading(); uni.hideLoading();
uni.showToast({ uni.showToast({
@ -656,17 +657,16 @@ const selectAsset = async () => {
const formDataStr = uni.getStorageSync('castlove_form_data'); const formDataStr = uni.getStorageSync('castlove_form_data');
const orderValue = formDataStr ? JSON.parse(formDataStr) : {}; const orderValue = formDataStr ? JSON.parse(formDataStr) : {};
// // CreateMintOrderRequestDTO
const orderData = { const orderData = {
name: orderValue.name,
event: orderValue.event,
description: orderValue.description,
material_type: orderValue.material_type,
material_url: imageUrl,
rarity: 0,
tags: [],
order_id: currentOrderId.value, order_id: currentOrderId.value,
info: orderValue.info || '' // info name: orderValue.name || '',
material_url: imageUrl,
description: orderValue.description || '',
grade: orderValue.grade ?? 0,
tags: Array.isArray(orderValue.tags) ? orderValue.tags : [],
material_type: orderValue.material_type || orderValue.materialType || '',
info: orderValue.info || orderValue.event || '',
}; };
// API // API
@ -679,12 +679,14 @@ const selectAsset = async () => {
uni.removeStorageSync('castlove_form_data'); uni.removeStorageSync('castlove_form_data');
// temp_nft_data // temp_nft_data
const nftData = { const nftData = {
image: imageUrl, // 使OSS URL image: imageUrl,
name: orderValue.name, name: orderValue.name || '',
event: orderValue.event, description: orderValue.description || '',
description: orderValue.description, material_type: orderValue.material_type || orderValue.materialType || '',
material_type: orderValue.material_type, tags: Array.isArray(orderValue.tags) ? orderValue.tags : [],
order_id: currentOrderId.value // 使reforder_id order_id: currentOrderId.value,
info: orderValue.info || '',
event: orderValue.info || orderValue.event || '',
}; };
// storage // storage

View File

@ -4,23 +4,28 @@
// #ifdef H5 // #ifdef H5
// const baseURL = 'http://localhost:8080' // H5 开发用本机 // const baseURL = 'http://localhost:8080' // H5 开发用本机
const baseURL = 'http://192.168.110.60:8080' // H5 开发用本机 const baseURL = 'http://101.132.250.62:8080' // H5 开发用本机
// #endif // #endif
// #ifdef APP-PLUS // #ifdef APP-PLUS
// 开发调试手机和电脑同一WiFi时用这个改成你电脑IP // 开发调试手机和电脑同一WiFi时用这个改成你电脑IP
// 上线后:改成实际服务器地址 // 上线后:改成实际服务器地址
const baseURL = 'http://192.168.110.60:8080' // const baseURL = 'http://192.168.110.60:8080'
// #endif // #endif
// 服务器地址(正式上线用) // 服务器地址(正式上线用)
// #ifdef APP-PLUS // #ifdef APP-PLUS
// const baseURL = 'http://101.132.250.62:8080' const baseURL = 'http://101.132.250.62:8080'
// #endif // #endif
// 是否使用模拟数据(开发调试时设为 true后端API准备好后改为 false // 是否使用模拟数据(开发调试时设为 true后端API准备好后改为 false
const USE_MOCK_API = false const USE_MOCK_API = false
/** 网关根地址(供 uni.uploadFile 等无法走 request 封装的场景拼接完整 URL */
export function getApiBaseUrl() {
return String(baseURL).replace(/\/+$/, '')
}
// 模拟网络延迟 // 模拟网络延迟
function mockDelay(ms = 800) { function mockDelay(ms = 800) {
return new Promise(resolve => setTimeout(resolve, ms)) return new Promise(resolve => setTimeout(resolve, ms))
@ -414,15 +419,30 @@ export function removeAssetFromGalleryApi(slotId) {
}) })
} }
// 获取OSS上传签名 /**
export function getOssSignatureApi(type) { * 获取 OSS 浏览器直传凭证PostObject 表单字段
* 对应网关GET /api/v1/assets/oss/upload-signature /oss/signature 同一处理逻辑此处走阶段一推荐路径
* @param {'avatar'|'asset'} [type='asset'] 上传目录类型
* @param {string} [orderId] 可选传入则与后续 POST /api/v1/assets/mints 使用同一 order_id后端 InitMintOrder
*/
export function getOssSignatureApi(type = 'asset', orderId = '') {
let url = `/api/v1/assets/oss/upload-signature?type=${encodeURIComponent(type)}`
if (orderId) {
url += `&order_id=${encodeURIComponent(orderId)}`
}
return request({ return request({
url: `/api/v1/assets/oss/signature?type=${type}`, url,
method: 'GET' method: 'GET'
}) })
} }
// 获取OSS预签名URL(用于读取图片) /** 兼容旧调用名:与 getOssSignatureApi 相同 */
export const getOssUploadSignatureApi = getOssSignatureApi
/**
* 获取 OSS 预签名 GET URL私有桶读图
* 对应网关GET /api/v1/assets/oss/presigned-url
*/
export function getOssPresignedUrlApi(fileName, expires = 3600, type = 'avatar') { export function getOssPresignedUrlApi(fileName, expires = 3600, type = 'avatar') {
return request({ return request({
url: `/api/v1/assets/oss/presigned-url?file_name=${encodeURIComponent(fileName)}&expires=${expires}&type=${type}`, url: `/api/v1/assets/oss/presigned-url?file_name=${encodeURIComponent(fileName)}&expires=${expires}&type=${type}`,
@ -430,6 +450,76 @@ export function getOssPresignedUrlApi(fileName, expires = 3600, type = 'avatar')
}) })
} }
/**
* 批量预签名读 URL
* 对应网关GET /api/v1/assets/oss/batch-presigned-urls
*/
export function getBatchOssPresignedUrlsApi(files, expires = 3600, type = 'asset') {
return request({
url: `/api/v1/assets/oss/batch-presigned-urls?files=${encodeURIComponent(JSON.stringify(files))}&expires=${expires}&type=${type}`,
method: 'GET'
})
}
/**
* 使用网关下发的 Post 策略将本地临时文件直传 OSSApp / 小程序等
* @param {string} filePath uni.chooseImage 等返回的 tempFilePaths
* @param {{ type?: 'avatar'|'asset', orderId?: string, fileName?: string }} [options]
* @returns {Promise<{ imageUrl: string, orderId?: string, data: object }>}
*/
export function uploadLocalFileToOss(filePath, options = {}) {
const { type = 'asset', orderId = '', fileName } = options
const objectName = fileName || `${Date.now()}.jpg`
return new Promise((resolve, reject) => {
getOssSignatureApi(type, orderId)
.then((signRes) => {
if (!signRes || signRes.code !== 200 || !signRes.data) {
reject(new Error((signRes && signRes.message) || '获取 OSS 签名失败'))
return
}
const d = signRes.data
const host = d.host
const dir = d.dir || ''
if (!host) {
reject(new Error('签名数据缺少 host'))
return
}
uni.uploadFile({
url: host,
filePath,
name: 'file',
formData: {
key: dir + objectName,
policy: d.policy,
success_action_status: '200',
'x-oss-credential': d.x_oss_credential,
'x-oss-date': d.x_oss_date,
'x-oss-security-token': d.security_token,
'x-oss-signature': d.signature,
'x-oss-signature-version': d.x_oss_signature_version
},
success: (uploadRes) => {
const ok = uploadRes.statusCode === 200 || uploadRes.statusCode === 204
if (!ok) {
reject(new Error(`OSS 上传失败 HTTP ${uploadRes.statusCode}`))
return
}
const imageUrl = `${host.replace(/\/+$/, '')}/${dir}${objectName}`
resolve({
imageUrl,
orderId: d.order_id,
data: d
})
},
fail: (e) => {
reject(new Error(e.errMsg || 'OSS 上传失败'))
}
})
})
.catch(reject)
})
}
// 更新用户头像 // 更新用户头像
export function updateAvatarApi(avatarUrl) { export function updateAvatarApi(avatarUrl) {
return request({ return request({
@ -646,14 +736,6 @@ export function getInspirationFlowApi(params) {
}) })
} }
// 批量获取OSS预签名URL用于读取目录下的图片
export function getBatchOssPresignedUrlsApi(files, expires = 3600, type = 'asset') {
return request({
url: `/api/v1/assets/oss/batch-presigned-urls?files=${encodeURIComponent(JSON.stringify(files))}&expires=${expires}&type=${type}`,
method: 'GET'
})
}
// ==================== 铸造活动相关接口运营banner==================== // ==================== 铸造活动相关接口运营banner====================
// 获取铸造活动列表 // 获取铸造活动列表

View 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 }
}

View 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,
}
}

View 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
}

View File

@ -0,0 +1,557 @@
/**
* uni-app App 端可用OSS Post/Put + 签名 GET URL直连抠图默认 IVPD SegmentImage另有 imageseg SegmentHDBodyPOP不依赖 ali-oss / Node http
* 签名使用 Web CryptoHMAC-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• IVPDhttps://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 SignatureDoesNotMatchAccessKeySecret 与当前 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-dateOSS 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
* Endpointivpd.{region}.aliyuncs.comVersion2019-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
}

View File

@ -0,0 +1,48 @@
/**
* 智能抠图方案一OSS + IVPD与方案二HTTP 后端契约见 server/index.js
*
* SEGMENT_TRANSPORTauto | backend | direct详见 segmentApi.example.js
*
* 方案一 · 推荐生产ALIYUN_STS_URL仅下发临时密钥的 HTTPSFC 不写主账号 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 = ''

View 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 SegmentImageUrl 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_BASEHTTP/后端抠图需要填写根地址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 SegmentImagebackend=HTTP 后端契约同 server/index.jsauto=能直连则直连否则走后端
*/
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 网络权限'
)
}

View 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
View 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从而 405ResourceType: 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()]
})