feat:在个人展示页显示光栅卡

This commit is contained in:
zerosaturation 2026-05-18 19:45:34 +08:00
parent 5f4a443a93
commit 3e6ff3c898
9 changed files with 250 additions and 45 deletions

View File

@ -616,6 +616,7 @@ func (ctrl *GalleryController) GetMyExhibitedAssets(c *gin.Context) {
ExpireAt: item.ExpireAt, ExpireAt: item.ExpireAt,
Earnings: item.Earnings, Earnings: item.Earnings,
SlotIndex: item.SlotIndex, SlotIndex: item.SlotIndex,
IsLenticular: item.IsLenticular,
}) })
} }
@ -805,6 +806,7 @@ func (ctrl *GalleryController) GetUserExhibitedAssets(c *gin.Context) {
ExpireAt: item.ExpireAt, ExpireAt: item.ExpireAt,
Earnings: item.Earnings, Earnings: item.Earnings,
SlotIndex: item.SlotIndex, SlotIndex: item.SlotIndex,
IsLenticular: item.IsLenticular,
}) })
} }

View File

@ -81,6 +81,7 @@ type ExhibitedAssetItemDTO struct {
ExpireAt int64 `json:"expire_at"` // 展出过期时间(毫秒时间戳) ExpireAt int64 `json:"expire_at"` // 展出过期时间(毫秒时间戳)
Earnings int64 `json:"earnings"` // 当前可领取收益 Earnings int64 `json:"earnings"` // 当前可领取收益
SlotIndex int32 `json:"slot_index"` // 展位序号 SlotIndex int32 `json:"slot_index"` // 展位序号
IsLenticular bool `json:"is_lenticular"` // 是否为光栅卡
} }
// GetMyExhibitedAssetsResponseDTO 获取我展出的作品列表响应 // GetMyExhibitedAssetsResponseDTO 获取我展出的作品列表响应

View File

@ -1223,6 +1223,7 @@ type ExhibitedAssetItem struct {
ExpireAt int64 `protobuf:"varint,6,opt,name=expire_at,json=expireAt,proto3" json:"expire_at,omitempty"` // 展出过期时间(毫秒时间戳) ExpireAt int64 `protobuf:"varint,6,opt,name=expire_at,json=expireAt,proto3" json:"expire_at,omitempty"` // 展出过期时间(毫秒时间戳)
Earnings int64 `protobuf:"varint,7,opt,name=earnings,proto3" json:"earnings,omitempty"` // 当前可领取收益 Earnings int64 `protobuf:"varint,7,opt,name=earnings,proto3" json:"earnings,omitempty"` // 当前可领取收益
SlotIndex int32 `protobuf:"varint,8,opt,name=slot_index,json=slotIndex,proto3" json:"slot_index,omitempty"` // 展位序号 SlotIndex int32 `protobuf:"varint,8,opt,name=slot_index,json=slotIndex,proto3" json:"slot_index,omitempty"` // 展位序号
IsLenticular bool `protobuf:"varint,9,opt,name=is_lenticular,json=isLenticular,proto3" json:"is_lenticular,omitempty"` // 是否为光栅卡(根据 tags 判断)
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -1313,6 +1314,13 @@ func (x *ExhibitedAssetItem) GetSlotIndex() int32 {
return 0 return 0
} }
func (x *ExhibitedAssetItem) GetIsLenticular() bool {
if x != nil {
return x.IsLenticular
}
return false
}
// 获取灵感瀑布藏品列表请求 // 获取灵感瀑布藏品列表请求
type GetInspirationFlowRequest struct { type GetInspirationFlowRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -1809,7 +1817,7 @@ const file_gallery_proto_rawDesc = "" +
"\x04page\x18\x02 \x01(\x05R\x04page\x12\x1b\n" + "\x04page\x18\x02 \x01(\x05R\x04page\x12\x1b\n" +
"\tpage_size\x18\x03 \x01(\x05R\bpageSize\x12\x14\n" + "\tpage_size\x18\x03 \x01(\x05R\bpageSize\x12\x14\n" +
"\x05total\x18\x04 \x01(\x03R\x05total\x12\x19\n" + "\x05total\x18\x04 \x01(\x03R\x05total\x12\x19\n" +
"\bhas_more\x18\x05 \x01(\bR\ahasMore\"\xfa\x01\n" + "\bhas_more\x18\x05 \x01(\bR\ahasMore\"\x9f\x02\n" +
"\x12ExhibitedAssetItem\x12\x19\n" + "\x12ExhibitedAssetItem\x12\x19\n" +
"\basset_id\x18\x01 \x01(\x03R\aassetId\x12\x12\n" + "\basset_id\x18\x01 \x01(\x03R\aassetId\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" +
@ -1820,7 +1828,8 @@ const file_gallery_proto_rawDesc = "" +
"\texpire_at\x18\x06 \x01(\x03R\bexpireAt\x12\x1a\n" + "\texpire_at\x18\x06 \x01(\x03R\bexpireAt\x12\x1a\n" +
"\bearnings\x18\a \x01(\x03R\bearnings\x12\x1d\n" + "\bearnings\x18\a \x01(\x03R\bearnings\x12\x1d\n" +
"\n" + "\n" +
"slot_index\x18\b \x01(\x05R\tslotIndex\"\x9a\x01\n" + "slot_index\x18\b \x01(\x05R\tslotIndex\x12#\n" +
"\ris_lenticular\x18\t \x01(\bR\fisLenticular\"\x9a\x01\n" +
"\x19GetInspirationFlowRequest\x12\x16\n" + "\x19GetInspirationFlowRequest\x12\x16\n" +
"\x06cursor\x18\x01 \x01(\tR\x06cursor\x12\x1c\n" + "\x06cursor\x18\x01 \x01(\tR\x06cursor\x12\x1c\n" +
"\tdirection\x18\x02 \x01(\tR\tdirection\x12\x14\n" + "\tdirection\x18\x02 \x01(\tR\tdirection\x12\x14\n" +

View File

@ -208,6 +208,7 @@ message ExhibitedAssetItem {
int64 expire_at = 6; // int64 expire_at = 6; //
int64 earnings = 7; // int64 earnings = 7; //
int32 slot_index = 8; // int32 slot_index = 8; //
bool is_lenticular = 9; // tags
} }
// ==================== ==================== // ==================== ====================

View File

@ -100,6 +100,7 @@ type ExhibitedAssetInfo struct {
ExpireAt int64 ExpireAt int64
Earnings int64 Earnings int64
SlotIndex int32 SlotIndex int32
IsLenticular bool
} }
// galleryRepository Repository实现 // galleryRepository Repository实现
@ -418,7 +419,8 @@ func (r *galleryRepository) GetMyExhibitedAssets(userID, starID int64, page, pag
err = r.db.Model(&models.Exhibition{}). err = r.db.Model(&models.Exhibition{}).
Raw(` Raw(`
SELECT exhibitions.asset_id, a.name, a.cover_url, a.like_count, SELECT exhibitions.asset_id, a.name, a.cover_url, a.like_count,
exhibitions.start_time as exhibited_at, exhibitions.expire_at, bs.slot_index exhibitions.start_time as exhibited_at, exhibitions.expire_at, bs.slot_index,
(a.tags @> '["craft:lenticular"]') as is_lenticular
FROM exhibitions FROM exhibitions
JOIN assets a ON a.id = exhibitions.asset_id JOIN assets a ON a.id = exhibitions.asset_id
JOIN booth_slots bs ON bs.slot_id = exhibitions.slot_id JOIN booth_slots bs ON bs.slot_id = exhibitions.slot_id
@ -462,7 +464,8 @@ func (r *galleryRepository) GetUserExhibitedAssets(userID, starID int64, page, p
err = r.db.Model(&models.Exhibition{}). err = r.db.Model(&models.Exhibition{}).
Raw(` Raw(`
SELECT exhibitions.asset_id, a.name, a.cover_url, a.like_count, SELECT exhibitions.asset_id, a.name, a.cover_url, a.like_count,
exhibitions.start_time as exhibited_at, exhibitions.expire_at, bs.slot_index exhibitions.start_time as exhibited_at, exhibitions.expire_at, bs.slot_index,
(a.tags @> '["craft:lenticular"]') as is_lenticular
FROM exhibitions FROM exhibitions
JOIN assets a ON a.id = exhibitions.asset_id JOIN assets a ON a.id = exhibitions.asset_id
JOIN booth_slots bs ON bs.slot_id = exhibitions.slot_id JOIN booth_slots bs ON bs.slot_id = exhibitions.slot_id

View File

@ -217,6 +217,7 @@ func (s *exhibitionService) GetMyExhibitedAssets(ctx context.Context, userID, st
ExpireAt: item.ExpireAt, ExpireAt: item.ExpireAt,
Earnings: item.Earnings, Earnings: item.Earnings,
SlotIndex: item.SlotIndex, SlotIndex: item.SlotIndex,
IsLenticular: item.IsLenticular,
}) })
} }

View File

@ -39,11 +39,11 @@
<view class="vignette" /> <view class="vignette" />
</view> </view>
<view v-if="showHint && tiltHintText" class="tilt-hint"> <!-- <view v-if="showHint && tiltHintText" class="tilt-hint">
<text class="tilt-hint-icon"></text> <text class="tilt-hint-icon"></text>
<text class="tilt-hint-text">{{ tiltHintText }}</text> <text class="tilt-hint-text">{{ tiltHintText }}</text>
<text v-if="approximatePreview" class="tilt-hint-sub">叠化近似预览倾斜或拖动体验光栅效果</text> <text v-if="approximatePreview" class="tilt-hint-sub">叠化近似预览倾斜或拖动体验光栅效果</text>
</view> </view> -->
</view> </view>
</view> </view>
</template> </template>

View File

@ -28,7 +28,17 @@
<view v-for="(item, index) in exhibitionWorks" :key="item.id" class="exhibition-card" <view v-for="(item, index) in exhibitionWorks" :key="item.id" class="exhibition-card"
:class="index % 2 === 0 ? 'card-tilt-left' : 'card-tilt-right'" :class="index % 2 === 0 ? 'card-tilt-left' : 'card-tilt-right'"
@tap="handleExhibitionCardTap(item, index)"> @tap="handleExhibitionCardTap(item, index)">
<image class="card-image" :src="item.cover_url || '/static/nft/placeholder.png'" <LenticularCard
v-if="item.is_lenticular"
class="card-lenticular"
:layers="getLenticularLayers(item.id)"
:transforms="getLenticularTransforms(item.id)"
:gyro-source="gyroSourceLabel"
:skip-built-in-touch="false"
:shimmer-mid-opacity="0.16"
@simulate="(x, y) => onLenticularSimulate(item.id, x, y)"
/>
<image v-else class="card-image" :src="item.cover_url || '/static/nft/placeholder.png'"
mode="aspectFill"></image> mode="aspectFill"></image>
<!-- 领取收益按钮 --> <!-- 领取收益按钮 -->
<view class="claim-reward-btn" v-if="isRewardClaimable(item.id)"> <view class="claim-reward-btn" v-if="isRewardClaimable(item.id)">
@ -167,12 +177,15 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue'; import { ref, onMounted, onUnmounted, computed } from 'vue';
import { getMyExhibitedAssetsApi, getMyLikedAssetsApi, getMyTodayLikedAssetsApi, getMyWeekLikedAssetsApi, getMyGalleriesApi, placeAssetToGalleryApi } from '@/utils/api.js'; import { getMyExhibitedAssetsApi, getMyLikedAssetsApi, getMyTodayLikedAssetsApi, getMyWeekLikedAssetsApi, getMyGalleriesApi, placeAssetToGalleryApi, getAssetMaterialsApi } from '@/utils/api.js';
import { getExhibitionRevenue, claimExhibitionRevenue } from '@/utils/task-api.js'; import { getExhibitionRevenue, claimExhibitionRevenue } from '@/utils/task-api.js';
import AssetSelector from '../components/AssetSelector.vue'; import AssetSelector from '../components/AssetSelector.vue';
import { onShow } from '@dcloudio/uni-app'; import { onShow } from '@dcloudio/uni-app';
import { doubleTapLike } from '@/utils/likeHelper.js'; import { doubleTapLike } from '@/utils/likeHelper.js';
import LenticularCard from '@/components/lenticular/LenticularCard.vue';
import { useLenticularCraftTiltPreview } from '@/composables/useLenticularCraftTiltPreview.js';
import { buildLenticularLayers } from '@/utils/castloveMintForm.js';
const goBack = () => { const goBack = () => {
// //
@ -496,6 +509,157 @@ const likedWorks = ref([]);
// : current-, today-, week- // : current-, today-, week-
const likedTab = ref('current'); const likedTab = ref('current');
//
// transforms asset id
const lenticularTransformsMap = ref({});
const lenticularLayersByAsset = ref({});
const activeLenticularId = ref(null);
const gyroSourceLabel = ref('device');
// 使
const lenticularPhysics = ref(null);
const lenticularEngine = ref(null);
let lenticularRafId = null;
// 使 useLenticularPreview
const lenticularLayersRef = ref([]);
function getLenticularLayers(assetId) {
return lenticularLayersByAsset.value[assetId] || [];
}
function getLenticularTransforms(assetId) {
return lenticularTransformsMap.value[assetId] || {};
}
async function loadLenticularLayersForAsset(assetId) {
// bg + subject layers
// 使 buildLenticularLayers(coverUrl)
const item = exhibitionWorks.value.find(w => w.id === assetId);
if (!item) return;
// asset-detail.vue
try {
const materialsRes = await getAssetMaterialsApi(assetId);
if (materialsRes.code === 200 && materialsRes.data) {
const materialsList = Array.isArray(materialsRes.data) ? materialsRes.data : materialsRes.data.materials || [];
let subjectUrl = item.cover_url;
let bgUrl = '';
for (const mat of materialsList) {
if (mat.material_type === 'main' && mat.material_url_signed) {
subjectUrl = mat.material_url_signed;
}
if (mat.material_type === 'bg' && mat.material_url_signed) {
bgUrl = mat.material_url_signed;
}
}
// 使 buildLenticularLayersTwo buildLenticularLayers
if (bgUrl) {
const { buildLenticularLayersTwo } = await import('@/utils/castloveMintForm.js');
lenticularLayersByAsset.value[assetId] = buildLenticularLayersTwo(bgUrl, subjectUrl);
} else {
lenticularLayersByAsset.value[assetId] = buildLenticularLayers(subjectUrl);
}
// transforms
initTransformsForAsset(assetId);
} else {
lenticularLayersByAsset.value[assetId] = buildLenticularLayers(item.cover_url);
initTransformsForAsset(assetId);
}
} catch (e) {
console.error('[myWorks] 获取素材列表失败:', e);
lenticularLayersByAsset.value[assetId] = buildLenticularLayers(item.cover_url);
}
}
//
function onLenticularSimulate(assetId, x, y) {
simulateLenticularTilt(assetId, x, y);
}
//
function simulateLenticularTilt(assetId, x, y) {
if (!lenticularEngine.value) return;
const layers = lenticularLayersByAsset.value[assetId];
if (!layers || layers.length === 0) return;
//
lenticularEngine.value.setLayers(layers);
//
const renderState = lenticularEngine.value.feedSimulatedTilt(x, y ? y : 0);
const transforms = {};
for (const l of layers) {
const rs = renderState.layerOffsets.get(l.id);
const op = renderState.layerOpacities.get(l.id);
transforms[l.id] = {
x: rs ? rs.x : 0,
y: rs ? rs.y : 0,
opacity: op != null ? op : l.opacity,
};
}
// Vue
lenticularTransformsMap.value = { ...lenticularTransformsMap.value, [assetId]: transforms };
}
//
function initLenticularEngine() {
if (lenticularEngine.value) return;
import('@/utils/lenticular-engine.js').then(({ LenticularEngine, DEFAULT_PHYSICS }) => {
const physics = { ...DEFAULT_PHYSICS, parallaxDepth: 18 };
physics.gyroSimEnabled = false;
lenticularPhysics.value = physics;
lenticularEngine.value = new LenticularEngine(physics);
});
}
// transforms
function initTransformsForAsset(assetId) {
const layers = lenticularLayersByAsset.value[assetId];
if (!layers || layers.length === 0) return;
const transforms = {};
for (const l of layers) {
transforms[l.id] = { x: 0, y: 0, opacity: l.opacity || 1 };
}
lenticularTransformsMap.value = { ...lenticularTransformsMap.value, [assetId]: transforms };
}
// transforms
function startLenticularRenderLoop() {
if (lenticularRafId !== null) return;
const tick = () => {
// transforms
for (const assetId of Object.keys(lenticularLayersByAsset.value)) {
const layers = lenticularLayersByAsset.value[assetId];
if (!layers || layers.length === 0) continue;
// 使 sensorData gamma=0
lenticularEngine.value.setLayers(layers);
const renderState = lenticularEngine.value.feedSimulatedTilt(0, 0);
const transforms = {};
for (const l of layers) {
const rs = renderState.layerOffsets.get(l.id);
const op = renderState.layerOpacities.get(l.id);
transforms[l.id] = {
x: rs ? rs.x : 0,
y: rs ? rs.y : 0,
opacity: op != null ? op : l.opacity,
};
}
lenticularTransformsMap.value = { ...lenticularTransformsMap.value, [assetId]: transforms };
}
lenticularRafId = requestAnimationFrame(tick);
};
lenticularRafId = requestAnimationFrame(tick);
}
function stopLenticularRenderLoop() {
if (lenticularRafId !== null) {
cancelAnimationFrame(lenticularRafId);
lenticularRafId = null;
}
}
// //
const switchLikedTab = async (tab) => { const switchLikedTab = async (tab) => {
if (likedTab.value === tab) return; if (likedTab.value === tab) return;
@ -519,8 +683,17 @@ const loadExhibitedAssets = async () => {
expire_at: item.expire_at, expire_at: item.expire_at,
name: item.name, name: item.name,
slot_index: item.slot_index ?? 0, slot_index: item.slot_index ?? 0,
is_lenticular: item.is_lenticular ?? false,
})) }))
.sort((a, b) => (a.slot_index ?? 0) - (b.slot_index ?? 0)); .sort((a, b) => (a.slot_index ?? 0) - (b.slot_index ?? 0));
//
for (const item of exhibitionWorks.value) {
if (item.is_lenticular) {
loadLenticularLayersForAsset(item.id);
}
}
console.log('展出作品:', exhibitionWorks.value); console.log('展出作品:', exhibitionWorks.value);
} }
} catch (err) { } catch (err) {
@ -562,6 +735,8 @@ const loadLikedAssets = async () => {
}; };
onMounted(() => { onMounted(() => {
initLenticularEngine();
startLenticularRenderLoop();
loadExhibitedAssets(); loadExhibitedAssets();
loadLikedAssets(); loadLikedAssets();
@ -583,6 +758,7 @@ onUnmounted(() => {
if (countdownTimer) { if (countdownTimer) {
clearInterval(countdownTimer); clearInterval(countdownTimer);
} }
stopLenticularRenderLoop();
uni.$off('userInfoUpdated'); uni.$off('userInfoUpdated');
uni.$off('assetLiked'); uni.$off('assetLiked');
}); });
@ -792,6 +968,18 @@ onShow(() => {
z-index: 2; z-index: 2;
} }
.card-lenticular {
width: 88%;
height: 93%;
left: 5%;
top: 4%;
border-radius: 24rpx;
transform-origin: center center;
position: relative;
z-index: 3;
overflow: hidden;
}
/* 领取收益按钮 */ /* 领取收益按钮 */
.claim-reward-btn { .claim-reward-btn {
position: absolute; position: absolute;

View File

@ -3,8 +3,8 @@
// 不需要手动注释! // 不需要手动注释!
// #ifdef H5 // #ifdef H5
const baseURL = 'http://192.168.110.60:8080' // H5 开发用本机 // const baseURL = 'http://192.168.110.60:8080' // H5 开发用本机
// const baseURL = 'http://101.132.250.62:8080' // H5 开发用本机 const baseURL = 'http://101.132.250.62:8080' // H5 开发用本机
// #endif // #endif
// #ifdef APP-PLUS // #ifdef APP-PLUS