feat: 重构展位选择
This commit is contained in:
parent
0d1631263e
commit
e4fb5ddeab
@ -50,32 +50,16 @@ type ServiceURLs struct {
|
||||
var (
|
||||
// GalleryRules 展馆规则配置(硬编码)
|
||||
GalleryRules = &GalleryRulesConfig{
|
||||
InitialSlotCount: 6, // 3 个公开 + 3 个私有
|
||||
InitialSlotCount: 2, // 2 个展位
|
||||
GrabSlotDuration: 14400, // 4小时
|
||||
|
||||
// 按等级解锁:第4个展位需要等级5,第5个展位需要等级6,以此类推
|
||||
UnlockLevelBySlot: map[int]int{
|
||||
4: 5, // 第4个展位需要等级5
|
||||
5: 6, // 第5个展位需要等级6
|
||||
6: 7, // 第6个展位需要等级7
|
||||
7: 8, // 第7个展位需要等级8
|
||||
8: 9, // 第8个展位需要等级9
|
||||
9: 10, // 第9个展位需要等级10
|
||||
10: 11, // 第10个展位需要等级11
|
||||
},
|
||||
// 按等级解锁:清空(不再解锁额外展位)
|
||||
UnlockLevelBySlot: map[int]int{},
|
||||
|
||||
// 按水晶解锁:第4个展位需要水晶100,第5个展位需要水晶200,以此类推
|
||||
UnlockCrystalBySlot: map[int]int{
|
||||
4: 100, // 第4个展位需要水晶100
|
||||
5: 200, // 第5个展位需要水晶200
|
||||
6: 300, // 第6个展位需要水晶300
|
||||
7: 400, // 第7个展位需要水晶400
|
||||
8: 500, // 第8个展位需要水晶500
|
||||
9: 600, // 第9个展位需要水晶600
|
||||
10: 700, // 第10个展位需要水晶700
|
||||
},
|
||||
// 按水晶解锁:清空
|
||||
UnlockCrystalBySlot: map[int]int{},
|
||||
|
||||
MaxSlotCount: 10,
|
||||
MaxSlotCount: 2, // 最多 2 个展位
|
||||
}
|
||||
|
||||
// DBConfig 数据库配置(由 main.go 的 flag 或环境变量注入)
|
||||
|
||||
@ -123,6 +123,7 @@ func (r *galleryRepository) GetSlotsByUser(userID, starID int64) ([]*models.Boot
|
||||
var slots []*models.BoothSlot
|
||||
err := r.db.Where("user_id = ? AND star_id = ?", userID, starID).
|
||||
Order("slot_index ASC").
|
||||
Limit(2).
|
||||
Find(&slots).Error
|
||||
return slots, err
|
||||
}
|
||||
@ -436,7 +437,7 @@ func (r *galleryRepository) GetMyExhibitedAssets(userID, starID int64, page, pag
|
||||
JOIN booth_slots bs ON bs.slot_id = exhibitions.slot_id
|
||||
WHERE exhibitions.occupier_uid = ? AND exhibitions.occupier_star_id = ?
|
||||
AND exhibitions.deleted_at IS NULL
|
||||
ORDER BY exhibitions.expire_at DESC, bs.slot_index ASC
|
||||
ORDER BY bs.slot_index ASC, exhibitions.expire_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, userID, starID, pageSize, offset).Scan(&items).Error
|
||||
|
||||
|
||||
@ -84,16 +84,9 @@ func (s *exhibitionService) PlaceAsset(userID, starID int64, req *pb.PlaceAssetR
|
||||
return nil, errors.New("展位已被占用")
|
||||
}
|
||||
|
||||
// 4. 校验权限与可见性(支持放置到他人展馆)
|
||||
isOwner := slot.UserID == userID
|
||||
if !isOwner {
|
||||
// 如果不是自己的展位,必须是 public 且在同一个明星下
|
||||
if slot.Visibility != "public" {
|
||||
return nil, errors.New("该展位是私有的,无法放置展品")
|
||||
}
|
||||
if slot.StarID != starID {
|
||||
return nil, errors.New("只能在同一明星的展馆中放置展品")
|
||||
}
|
||||
// 4. 校验权限:只能放到自己的展位
|
||||
if slot.UserID != userID {
|
||||
return nil, errors.New("只能在自己的展位放置藏品")
|
||||
}
|
||||
|
||||
// 5. 执行放置逻辑
|
||||
|
||||
@ -222,60 +222,20 @@ func (s *galleryService) buildSlotInfos(slots []*models.BoothSlot, viewerUID, vi
|
||||
// 返回 (canOperate bool, operation string)
|
||||
// operation 取值: "place" | "remove" | "none"
|
||||
func (s *galleryService) calculateOperation(slot *models.BoothSlot, exhibition *models.Exhibition, viewerUID int64, isOwner bool) (bool, string) {
|
||||
logger.Logger.Info("=== calculateOperation DEBUG ===",
|
||||
zap.Int64("slot_id", slot.SlotID),
|
||||
zap.String("visibility", slot.Visibility),
|
||||
zap.Bool("is_owner", isOwner),
|
||||
zap.Int64("viewer_uid", viewerUID),
|
||||
)
|
||||
if exhibition != nil {
|
||||
logger.Logger.Info("=== exhibition info ===",
|
||||
zap.Int64("exhibition_id", exhibition.ID),
|
||||
zap.Int64("occupier_uid", exhibition.OccupierUID),
|
||||
)
|
||||
}
|
||||
|
||||
// 未解锁的展位不能操作
|
||||
if !slot.IsEnabled {
|
||||
return false, "none"
|
||||
}
|
||||
|
||||
// 私有展位 (我的/他的展位)
|
||||
if slot.Visibility == "private" {
|
||||
// 所有者访问自己展馆
|
||||
// 自己是展位所有者
|
||||
if isOwner {
|
||||
if exhibition == nil {
|
||||
return true, "place" // 空展位,可以放置
|
||||
return true, "place" // 自己的空展位,可以放置
|
||||
}
|
||||
// 有藏品时,可以主动结束展览
|
||||
return true, "remove"
|
||||
}
|
||||
// 访问别人展馆 - 不能操作
|
||||
return false, "none"
|
||||
}
|
||||
|
||||
// 公共展位 (共享展位)
|
||||
if slot.Visibility == "public" {
|
||||
if exhibition == nil {
|
||||
// 空展位 - 只有访问别人展馆时可以放置
|
||||
if !isOwner {
|
||||
return true, "place"
|
||||
}
|
||||
return false, "none" // 自己的展馆空展位不能操作
|
||||
}
|
||||
|
||||
// 有藏品时
|
||||
if exhibition.OccupierUID == viewerUID {
|
||||
return true, "remove" // 有自己的藏品,可以主动结束展览
|
||||
}
|
||||
|
||||
// 有别人的藏品 - 只有所有者可以踢出
|
||||
if isOwner {
|
||||
return true, "remove"
|
||||
}
|
||||
return false, "none"
|
||||
return true, "remove" // 有藏品,可以移除
|
||||
}
|
||||
|
||||
// 不是展位所有者,不能操作
|
||||
return false, "none"
|
||||
}
|
||||
|
||||
|
||||
43
docs/migrations/002_reduce_slots_to_2.sql
Normal file
43
docs/migrations/002_reduce_slots_to_2.sql
Normal file
@ -0,0 +1,43 @@
|
||||
-- 展位重构迁移:从 6 槽位缩减为 2 槽位
|
||||
-- 执行时间:2026-05-20
|
||||
-- 执行人:待填写
|
||||
-- 风险等级:高(删除数据)
|
||||
|
||||
-- ========== 备份(建议执行前手动备份)==========
|
||||
-- 备份 booth_slots 表
|
||||
-- CREATE TABLE booth_slots_backup_20260520 AS SELECT * FROM booth_slots;
|
||||
|
||||
-- 备份 exhibitions 表
|
||||
-- CREATE TABLE exhibitions_backup_20260520 AS SELECT * FROM exhibitions;
|
||||
|
||||
-- ========== 迁移开始 ==========
|
||||
|
||||
-- 1. 先删除 slot_index > 2 的展览记录(引用完整性)
|
||||
DELETE FROM exhibitions
|
||||
WHERE slot_id IN (
|
||||
SELECT slot_id FROM booth_slots
|
||||
WHERE slot_index > 2
|
||||
);
|
||||
|
||||
-- 2. 删除 slot_index > 2 的槽位记录
|
||||
DELETE FROM booth_slots
|
||||
WHERE slot_index > 2;
|
||||
|
||||
-- 3. 验证清理结果
|
||||
SELECT user_id, star_id, COUNT(*) as slot_count
|
||||
FROM booth_slots
|
||||
GROUP BY user_id, star_id
|
||||
HAVING COUNT(*) > 2;
|
||||
|
||||
-- 应该返回空结果,表示每个用户的每个明星展馆最多只有 2 个槽位
|
||||
|
||||
-- 4. 验证 exhibitions 没有残留(检查是否有关联到已删除槽位的展览)
|
||||
SELECT COUNT(*) as orphaned_exhibitions
|
||||
FROM exhibitions e
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM booth_slots bs WHERE bs.slot_id = e.slot_id
|
||||
);
|
||||
|
||||
-- 应该返回 0
|
||||
|
||||
-- ========== 迁移完成 ==========
|
||||
@ -25,75 +25,58 @@
|
||||
</view>
|
||||
|
||||
<view class="exhibition-grid">
|
||||
<view v-for="(item, index) in exhibitionWorks" :key="item.id" class="exhibition-card"
|
||||
:class="index % 2 === 0 ? 'card-tilt-left' : 'card-tilt-right'"
|
||||
@tap="handleExhibitionCardTap(item, index)">
|
||||
<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>
|
||||
<!-- 领取收益按钮 -->
|
||||
<view class="claim-reward-btn" v-if="isRewardClaimable(item.id)">
|
||||
<image class="claim-crystal-icon" src="/static/square/shuijingtubiao.png" mode="aspectFit">
|
||||
</image>
|
||||
<view @tap.stop="handleClaimReward(item, index)" class="claim-btn-text">领取收益</view>
|
||||
</view>
|
||||
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFill">
|
||||
</image>
|
||||
<!-- 点赞数 -->
|
||||
<!-- 左边展位 (slot_index=1) -->
|
||||
<view v-if="exhibitionAtSlot[0]" class="exhibition-card card-tilt-left"
|
||||
@tap="handleExhibitionCardTap(exhibitionAtSlot[0], 0)">
|
||||
<image v-if="!exhibitionAtSlot[0].is_lenticular" class="card-image" :src="exhibitionAtSlot[0].cover_url || '/static/nft/placeholder.png'" mode="aspectFill"></image>
|
||||
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFill"></image>
|
||||
<view class="card-rate-badge">
|
||||
<image class="heart-icon" src="/static/icon/heart-icon.png" mode="aspectFit"></image>
|
||||
<view class="card-rate-text-wrap">
|
||||
<text class="card-rate-text">{{ item.like_count || 0 }}</text>
|
||||
<text class="card-rate-text">{{ exhibitionAtSlot[0].like_count || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 倒计时背景 -->
|
||||
<view class="countdown-background" v-if="!isRewardClaimable(item.id)"
|
||||
:style="getCountdownBackgroundStyle(index)">
|
||||
<!-- 倒计时文字 -->
|
||||
<text class="countdown-text">
|
||||
{{ formatCountdown(item.id) }}
|
||||
</text>
|
||||
<view class="countdown-background" v-if="!isRewardClaimable(exhibitionAtSlot[0].id)" :style="getCountdownBackgroundStyle()">
|
||||
<text class="countdown-text">{{ formatCountdown(exhibitionAtSlot[0].id) }}</text>
|
||||
</view>
|
||||
<!-- 图片下方收益 -->
|
||||
<view class="card-income-row"
|
||||
:class="index % 2 === 0 ? 'income-tilt-right' : 'income-tilt-left'">
|
||||
<view class="card-income-row income-tilt-right">
|
||||
<image class="topfans-icon" src="/static/icon/crystal.png" mode="aspectFit"></image>
|
||||
<view class="card-income-text-wrap">
|
||||
<text class="card-income-text">{{ item.hourly_earnings || 0 }}/时</text>
|
||||
<text class="card-income-text">{{ exhibitionAtSlot[0].hourly_earnings || 0 }}/时</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-card empty-card-left" @tap="openAssetSelector(1)">
|
||||
<image class="empty-cover" src="/static/nft/placeholder.png" mode="aspectFill"></image>
|
||||
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFill"></image>
|
||||
<view class="empty-add-btn"><text class="empty-add-icon">+</text></view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态占位:显示剩余空展位卡片 -->
|
||||
<view v-if="exhibitionWorks.length < 2" class="empty-exhibition">
|
||||
<!-- 根据已展出数量决定显示几个空卡片 -->
|
||||
<view v-if="exhibitionWorks.length === 0" class="empty-card empty-card-left"
|
||||
@tap="openAssetSelector(0)">
|
||||
<!-- 右边展位 (slot_index=2) -->
|
||||
<view v-if="exhibitionAtSlot[1]" class="exhibition-card card-tilt-right"
|
||||
@tap="handleExhibitionCardTap(exhibitionAtSlot[1], 1)">
|
||||
<image v-if="!exhibitionAtSlot[1].is_lenticular" class="card-image" :src="exhibitionAtSlot[1].cover_url || '/static/nft/placeholder.png'" mode="aspectFill"></image>
|
||||
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFill"></image>
|
||||
<view class="card-rate-badge">
|
||||
<image class="heart-icon" src="/static/icon/heart-icon.png" mode="aspectFit"></image>
|
||||
<view class="card-rate-text-wrap">
|
||||
<text class="card-rate-text">{{ exhibitionAtSlot[1].like_count || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="countdown-background" v-if="!isRewardClaimable(exhibitionAtSlot[1].id)" :style="getCountdownBackgroundStyle()">
|
||||
<text class="countdown-text">{{ formatCountdown(exhibitionAtSlot[1].id) }}</text>
|
||||
</view>
|
||||
<view class="card-income-row income-tilt-left">
|
||||
<image class="topfans-icon" src="/static/icon/crystal.png" mode="aspectFit"></image>
|
||||
<view class="card-income-text-wrap">
|
||||
<text class="card-income-text">{{ exhibitionAtSlot[1].hourly_earnings || 0 }}/时</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-card empty-card-right" @tap="openAssetSelector(2)">
|
||||
<image class="empty-cover" src="/static/nft/placeholder.png" mode="aspectFill"></image>
|
||||
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png"
|
||||
mode="aspectFill"></image>
|
||||
<view class="empty-add-btn">
|
||||
<text class="empty-add-icon">+</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty-card empty-card-right" @tap="openAssetSelector(1)">
|
||||
<image class="empty-cover" src="/static/nft/placeholder.png" mode="aspectFill"></image>
|
||||
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png"
|
||||
mode="aspectFill"></image>
|
||||
<view class="empty-add-btn">
|
||||
<text class="empty-add-icon">+</text>
|
||||
</view>
|
||||
</view>
|
||||
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFill"></image>
|
||||
<view class="empty-add-btn"><text class="empty-add-icon">+</text></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@ -227,9 +210,47 @@ const goToCastlove = () => {
|
||||
// 藏品选择器相关
|
||||
const showAssetSelector = ref(false);
|
||||
const assetToReplace = ref(null);
|
||||
const currentSlotIndex = ref(0);
|
||||
const currentSlotIndex = ref(0); // 现在存储的是 slot_index (1 或 2)
|
||||
|
||||
// 我的展馆槽位信息(用于确定空位)
|
||||
const mySlots = ref([]);
|
||||
|
||||
const loadGalleryInfo = async () => {
|
||||
try {
|
||||
const galleriesRes = await getMyGalleriesApi();
|
||||
console.log('[DEBUG] 展馆API返回:', galleriesRes);
|
||||
// 只取前2个可操作槽位,按 slot_index 排序
|
||||
mySlots.value = galleriesRes.data?.slots
|
||||
.filter(s => s.can_operate)
|
||||
.sort((a, b) => (a.slot_index ?? 0) - (b.slot_index ?? 0))
|
||||
.slice(0, 2) || [];
|
||||
console.log('[DEBUG] 加载展馆槽位 mySlots:', mySlots.value);
|
||||
} catch (err) {
|
||||
console.error('加载展馆信息失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 计算空位:哪些 slot_index 没有展出中
|
||||
const emptySlotIndices = computed(() => {
|
||||
const occupiedSlots = exhibitionWorks.value.map(w => w.slot_index).filter(idx => idx > 0);
|
||||
return [1, 2].filter(idx => !occupiedSlots.includes(idx));
|
||||
});
|
||||
|
||||
// 将作品映射到正确的左右位置(slot 1=左边, slot 2=右边)
|
||||
const exhibitionAtSlot = computed(() => {
|
||||
// 创建一个长度为 2 的数组,index 0=左边, index 1=右边
|
||||
const slots = [null, null];
|
||||
for (const item of exhibitionWorks.value) {
|
||||
const pos = (item.slot_index ?? 0) - 1; // slot_index 1→0, 2→1
|
||||
if (pos >= 0 && pos < 2) {
|
||||
slots[pos] = item;
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
});
|
||||
|
||||
const openAssetSelector = (slotIndex = 0) => {
|
||||
// slotIndex 现在是 slot_index 值(1 或 2)
|
||||
currentSlotIndex.value = slotIndex;
|
||||
showAssetSelector.value = true;
|
||||
};
|
||||
@ -252,11 +273,7 @@ const handleAssetSelect = async ({ asset, isReplace, oldAsset }) => {
|
||||
const ownerId = galleriesRes.data?.gallery_owner_id;
|
||||
console.log('槽位列表:', slots, 'ownerId:', ownerId);
|
||||
|
||||
// 过滤出可操作的槽位(can_operate: true)
|
||||
const operatableSlots = slots.filter(s => s.can_operate);
|
||||
console.log('可操作槽位:', operatableSlots);
|
||||
|
||||
if (operatableSlots.length === 0 || !ownerId) {
|
||||
if (slots.length === 0 || !ownerId) {
|
||||
uni.showToast({ title: '暂无可用展馆', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
@ -264,11 +281,12 @@ const handleAssetSelect = async ({ asset, isReplace, oldAsset }) => {
|
||||
let targetSlotId = null;
|
||||
|
||||
if (isReplace && oldAsset) {
|
||||
// 替换模式:根据旧藏品找到 slot_id
|
||||
const slot = slots.find(s => s.asset_id === oldAsset.asset_id);
|
||||
targetSlotId = slot?.slot_id;
|
||||
} else {
|
||||
// 使用 currentSlotIndex 对应可操作槽位列表中的槽位
|
||||
const targetSlot = operatableSlots[currentSlotIndex.value];
|
||||
// 新放置模式:用 currentSlotIndex(就是 slot_index)直接找
|
||||
const targetSlot = slots.find(s => s.slot_index === currentSlotIndex.value);
|
||||
targetSlotId = targetSlot?.slot_id;
|
||||
}
|
||||
|
||||
@ -769,6 +787,7 @@ const switchLikedTab = async (tab) => {
|
||||
const loadExhibitedAssets = async () => {
|
||||
try {
|
||||
const res = await getMyExhibitedAssetsApi(1, 20);
|
||||
console.log('[DEBUG] 展出作品API返回:', res);
|
||||
if (res.data && res.data.items) {
|
||||
exhibitionWorks.value = res.data.items
|
||||
.map(item => ({
|
||||
@ -784,6 +803,7 @@ const loadExhibitedAssets = async () => {
|
||||
is_lenticular: item.is_lenticular ?? false,
|
||||
}))
|
||||
.sort((a, b) => (a.slot_index ?? 0) - (b.slot_index ?? 0));
|
||||
console.log('[DEBUG] 整理后的 exhibitionWorks:', exhibitionWorks.value);
|
||||
|
||||
// 为每个光栅卡加载层级数据
|
||||
for (const item of exhibitionWorks.value) {
|
||||
@ -843,6 +863,7 @@ const loadLikedAssets = async () => {
|
||||
onMounted(() => {
|
||||
initLenticularEngine();
|
||||
startLenticularRenderLoop();
|
||||
loadGalleryInfo();
|
||||
loadExhibitedAssets();
|
||||
loadLikedAssets();
|
||||
|
||||
@ -1035,7 +1056,7 @@ onShow(() => {
|
||||
|
||||
.card-tilt-left {
|
||||
transform: rotate(-4deg) translateY(10rpx);
|
||||
margin-right: 32rpx;
|
||||
margin-right: 64rpx;
|
||||
border-radius: 32rpx;
|
||||
box-shadow: -16rpx 16rpx 16rpx rgba(229, 76, 93, 0.9);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user