From fe7fbba1fc142a33f079bd660f9f9d6ef8c1b121 Mon Sep 17 00:00:00 2001 From: liulong <18539103286> Date: Thu, 14 May 2026 16:42:27 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=BD=92=E6=A1=A3=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E7=8E=B0=E6=9C=89=E4=BB=A3=E7=A0=81=EF=BC=8C=E5=87=86=E5=A4=87?= =?UTF-8?q?=E6=96=B0=E5=BB=BA=E5=88=86=E6=94=AF=E5=BC=80=E5=8F=91=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sql/migrations/V5__mint_cost_config.sql | 53 + frontend/components/ConfirmModal.vue | 10 +- .../lenticular/CastloveLenticularPreview.vue | 55 + .../components/lenticular/LenticularCard.vue | 352 ++ frontend/composables/useLenticularPreview.js | 155 + frontend/pages.json | 19 + frontend/pages/castlove/craft-select.vue | 67 +- frontend/pages/castlove/create.vue | 860 ++++- frontend/pages/castlove/laser-card-studio.vue | 3336 +++++++++++++++++ frontend/pages/castlove/lenticular-studio.vue | 657 ++++ .../pages/components/HorizontalScroll.vue | 8 +- frontend/pages/discover/discover.vue | 31 +- frontend/pages/discover/generation-result.vue | 36 +- frontend/utils/api.js | 112 +- frontend/utils/castloveAfterLaserMint.js | 179 + frontend/utils/castloveMintForm.js | 138 + frontend/utils/h5OssPostUrl.js | 26 + .../utils/laser-card/aliyunPortraitUni.js | 557 +++ frontend/utils/laser-card/segmentApi.js | 48 + .../utils/laser-card/segmentationCloud.js | 320 ++ frontend/utils/lenticular-engine.js | 256 ++ frontend/vite.config.js | 83 + 22 files changed, 7120 insertions(+), 238 deletions(-) create mode 100644 docker/sql/migrations/V5__mint_cost_config.sql create mode 100644 frontend/components/lenticular/CastloveLenticularPreview.vue create mode 100644 frontend/components/lenticular/LenticularCard.vue create mode 100644 frontend/composables/useLenticularPreview.js create mode 100644 frontend/pages/castlove/laser-card-studio.vue create mode 100644 frontend/pages/castlove/lenticular-studio.vue create mode 100644 frontend/utils/castloveAfterLaserMint.js create mode 100644 frontend/utils/castloveMintForm.js create mode 100644 frontend/utils/h5OssPostUrl.js create mode 100644 frontend/utils/laser-card/aliyunPortraitUni.js create mode 100644 frontend/utils/laser-card/segmentApi.js create mode 100644 frontend/utils/laser-card/segmentationCloud.js create mode 100644 frontend/utils/lenticular-engine.js create mode 100644 frontend/vite.config.js diff --git a/docker/sql/migrations/V5__mint_cost_config.sql b/docker/sql/migrations/V5__mint_cost_config.sql new file mode 100644 index 0000000..7c98ac5 --- /dev/null +++ b/docker/sql/migrations/V5__mint_cost_config.sql @@ -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; diff --git a/frontend/components/ConfirmModal.vue b/frontend/components/ConfirmModal.vue index da309a6..1fb90e5 100644 --- a/frontend/components/ConfirmModal.vue +++ b/frontend/components/ConfirmModal.vue @@ -24,11 +24,13 @@ - + + + diff --git a/frontend/components/lenticular/LenticularCard.vue b/frontend/components/lenticular/LenticularCard.vue new file mode 100644 index 0000000..9ee5c85 --- /dev/null +++ b/frontend/components/lenticular/LenticularCard.vue @@ -0,0 +1,352 @@ + + + + + diff --git a/frontend/composables/useLenticularPreview.js b/frontend/composables/useLenticularPreview.js new file mode 100644 index 0000000..69375a8 --- /dev/null +++ b/frontend/composables/useLenticularPreview.js @@ -0,0 +1,155 @@ +/** + * 光栅卡预览:触摸模拟倾斜 + LenticularEngine(不启动硬件传感器) + * @param {import('vue').Ref} 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, + } +} diff --git a/frontend/pages.json b/frontend/pages.json index a41389f..3600c4a 100644 --- a/frontend/pages.json +++ b/frontend/pages.json @@ -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": { diff --git a/frontend/pages/castlove/craft-select.vue b/frontend/pages/castlove/craft-select.vue index c62b9f8..a1948e3 100644 --- a/frontend/pages/castlove/craft-select.vue +++ b/frontend/pages/castlove/craft-select.vue @@ -5,9 +5,12 @@ - + :class="{ 'card-selected': selectedIndex === index }" :style="getCardStyle(index)"> + @@ -48,6 +51,18 @@ + @@ -802,6 +1001,24 @@ onMounted(() => { text-align: center; } +.lenticular-upload-row { + width: 100%; + display: flex; + flex-direction: row; + justify-content: center; + align-items: stretch; + gap: 20rpx; + padding: 0 4rpx; + box-sizing: border-box; +} + +.upload-box--half { + flex: 1; + width: 0; + min-width: 0; + height: 280rpx; +} + .uploaded-image { width: 100%; height: 100%; @@ -1156,6 +1373,301 @@ onMounted(() => { opacity: 0.9; } +/* ========== 镭射卡设计稿专用 ========== */ +.page-container--laser { + position: relative; +} + +.laser-bg-veil { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + z-index: 0; + pointer-events: none; + background: + radial-gradient(2rpx 2rpx at 10% 18%, rgba(255, 255, 255, 0.55), transparent 55%), + radial-gradient(1.5rpx 1.5rpx at 78% 12%, rgba(255, 255, 255, 0.45), transparent 55%), + radial-gradient(2rpx 2rpx at 42% 8%, rgba(255, 255, 255, 0.4), transparent 50%), + radial-gradient(1.5rpx 1.5rpx at 88% 46%, rgba(255, 255, 255, 0.5), transparent 50%), + radial-gradient(2rpx 2rpx at 18% 62%, rgba(255, 255, 255, 0.35), transparent 50%), + radial-gradient(1.5rpx 1.5rpx at 64% 72%, rgba(255, 255, 255, 0.45), transparent 50%), + radial-gradient(2rpx 2rpx at 36% 88%, rgba(255, 255, 255, 0.4), transparent 50%), + radial-gradient(1.5rpx 1.5rpx at 92% 84%, rgba(255, 255, 255, 0.35), transparent 50%), + linear-gradient( + 165deg, + rgba(255, 182, 220, 0.16) 0%, + rgba(140, 100, 210, 0.22) 42%, + rgba(20, 40, 80, 0.2) 100% + ); +} + +.laser-close-hit { + position: fixed; + top: calc(16rpx + constant(safe-area-inset-top)); + top: calc(16rpx + env(safe-area-inset-top)); + left: 24rpx; + z-index: 20; + width: 72rpx; + height: 72rpx; + border-radius: 50%; + background: linear-gradient( + 135deg, + #ffb7e8 0%, + #c9a6ff 28%, + #8fd4ff 55%, + #fff3b0 78%, + #ffa8d8 100% + ); + background-size: 180% 180%; + border: 2rpx solid rgba(255, 255, 255, 0.55); + box-shadow: 0 6rpx 22rpx rgba(120, 60, 160, 0.35); + display: flex; + align-items: center; + justify-content: center; +} + +.laser-close-x { + color: #ffffff; + font-size: 44rpx; + line-height: 1; + font-weight: 300; +} + +.content-wrapper--laser { + padding-top: calc(112rpx + constant(safe-area-inset-top)); + padding-top: calc(112rpx + env(safe-area-inset-top)); +} + +.upload-section--laser .upload-box-wrap { + position: relative; + width: 100%; + max-width: 640rpx; + margin: 0 auto; +} + +.upload-section--laser .upload-box--laser { + position: relative; + width: 100%; + height: 460rpx; + border-radius: 36rpx; + border: 2rpx solid rgba(255, 255, 255, 0.32); + box-shadow: 0 18rpx 56rpx rgba(60, 30, 100, 0.28); + background: rgba(255, 255, 255, 0.08); + backdrop-filter: blur(18rpx); + -webkit-backdrop-filter: blur(18rpx); + /* 外框实线;内层虚线框见 .upload-laser-dashed-inner */ + border-style: solid; +} + +.upload-section--laser .upload-box--laser .uploaded-image { + border-radius: 32rpx; +} + +.upload-laser-empty { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding: 28rpx; +} + +.upload-laser-watermark { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + z-index: 0; + opacity: 0.35; + pointer-events: none; + background: + radial-gradient(ellipse 52% 78% at 50% 100%, rgba(60, 35, 90, 0.55), transparent 58%), + radial-gradient(ellipse 42% 55% at 50% 36%, rgba(140, 110, 180, 0.45), transparent 58%), + radial-gradient(ellipse 28% 22% at 50% 16%, rgba(200, 180, 230, 0.35), transparent 70%), + linear-gradient(185deg, rgba(255, 210, 240, 0.15) 0%, rgba(80, 50, 120, 0.18) 100%); +} + +.upload-laser-dashed-inner { + position: relative; + z-index: 1; + width: 82%; + height: 78%; + max-width: 520rpx; + max-height: 360rpx; + border: 3rpx dashed rgba(255, 255, 255, 0.92); + border-radius: 28rpx; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20rpx; + background: rgba(255, 255, 255, 0.04); +} + +.upload-plus-ring { + width: 100rpx; + height: 100rpx; + border-radius: 50%; + border: 3rpx solid rgba(255, 255, 255, 0.95); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8rpx 28rpx rgba(40, 20, 80, 0.2); +} + +.upload-plus--laser { + font-size: 64rpx; + line-height: 1; + color: rgba(255, 255, 255, 0.98); + font-weight: 200; +} + +.upload-text--laser { + font-size: 26rpx; + color: rgba(255, 255, 255, 0.95); + font-weight: 500; +} + +.upload-section--laser .upload-hint { + color: rgba(255, 255, 255, 0.88); + font-size: 22rpx; + margin-top: 16rpx; +} + +.upload-section--laser .upload-clear { + position: absolute; + top: 14rpx; + right: 14rpx; + z-index: 6; + width: 56rpx; + height: 56rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.upload-clear--laser { + background: rgba(255, 255, 255, 0.96); + border: 2rpx solid rgba(255, 255, 255, 0.95); + box-shadow: 0 6rpx 18rpx rgba(60, 30, 100, 0.22); +} + +.upload-clear-x--laser { + color: rgba(90, 70, 120, 0.85); + font-size: 36rpx; + font-weight: 300; +} + +.form-section--laser { + gap: 36rpx; +} + +.form-label--laser, +.section-title--laser { + font-size: 28rpx; + padding: 10rpx 22rpx; + border-radius: 999rpx; + background: linear-gradient(95deg, #ff7ec8 0%, #ff9f7a 48%, #ffb35c 100%); + color: #ffffff; + text-shadow: 0 2rpx 6rpx rgba(160, 40, 80, 0.35); + box-shadow: 0 4rpx 14rpx rgba(255, 100, 140, 0.28); +} + +.ai-section--laser { + gap: 20rpx; +} + +.picker-display--laser { + background: rgba(255, 255, 255, 0.32); + backdrop-filter: blur(22rpx); + -webkit-backdrop-filter: blur(22rpx); + border: 2rpx solid rgba(255, 255, 255, 0.45); + box-shadow: 0 8rpx 28rpx rgba(50, 20, 90, 0.12); +} + +.form-textarea--laser { + background: rgba(255, 255, 255, 0.32); + backdrop-filter: blur(22rpx); + -webkit-backdrop-filter: blur(22rpx); + border: 2rpx solid rgba(255, 255, 255, 0.42); + box-shadow: 0 8rpx 28rpx rgba(50, 20, 90, 0.1); +} + +.picker-arrow.picker-arrow--laser { + transform: rotate(0deg); + font-size: 22rpx; + line-height: 1; + color: rgba(255, 255, 255, 0.88); + font-weight: 600; +} + +.picker-arrow.picker-arrow--laser.picker-arrow-up { + transform: rotate(180deg); +} + +.ai-input-wrapper--laser { + flex-direction: column; + min-height: 280rpx; + padding: 24rpx 28rpx 28rpx; + background: rgba(255, 255, 255, 0.32); + backdrop-filter: blur(22rpx); + -webkit-backdrop-filter: blur(22rpx); + border: 2rpx solid rgba(255, 255, 255, 0.45); + box-shadow: 0 8rpx 28rpx rgba(50, 20, 90, 0.1); +} + +.ai-input-wrapper--laser .ai-input { + min-height: 220rpx; + width: 100%; +} + +.button-section--laser { + gap: 24rpx; + margin-top: 48rpx; +} + +.btn-secondary--laser { + background: rgba(255, 255, 255, 0.18); + border-color: rgba(255, 255, 255, 0.35); +} + +.btn-confirm-laser { + flex: 1; + height: 96rpx; + line-height: 96rpx; + border-radius: 48rpx; + font-size: 36rpx; + font-family: 'yt', sans-serif; + font-weight: 600; + color: #ffffff; + border: none; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(95deg, #ff8ec5 0%, #ff5a8c 38%, #ff2d6b 72%, #ff4d64 100%); + box-shadow: 0 10rpx 32rpx rgba(255, 50, 110, 0.42); + text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.25); +} + +.btn-confirm-laser::after { + border: none; +} + +.btn-confirm-laser:active { + opacity: 0.92; +} + .nav-mask { position: fixed; top: 0; diff --git a/frontend/pages/castlove/laser-card-studio.vue b/frontend/pages/castlove/laser-card-studio.vue new file mode 100644 index 0000000..8ecfcc5 --- /dev/null +++ b/frontend/pages/castlove/laser-card-studio.vue @@ -0,0 +1,3336 @@ + + + + + + diff --git a/frontend/pages/castlove/lenticular-studio.vue b/frontend/pages/castlove/lenticular-studio.vue new file mode 100644 index 0000000..eabfb80 --- /dev/null +++ b/frontend/pages/castlove/lenticular-studio.vue @@ -0,0 +1,657 @@ + + + + + + + + + diff --git a/frontend/pages/components/HorizontalScroll.vue b/frontend/pages/components/HorizontalScroll.vue index d60eed8..369d696 100644 --- a/frontend/pages/components/HorizontalScroll.vue +++ b/frontend/pages/components/HorizontalScroll.vue @@ -12,10 +12,10 @@ -