topfans/frontend/pages/castlove/craft-select.vue
2026-06-09 12:38:12 +08:00

711 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page">
<image
class="bg-wrapper"
src="/static/castlove/beijingban.png"
mode="aspectFill"
></image>
<view class="main-container">
<view
class="cards-container"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<!-- 1. 加载中:背景图照常显示,卡片区显示半透明 loading,不要白屏 -->
<view v-if="loading" class="loading-state">
<image class="loading-spinner" src="/static/common/loading.png" />
</view>
<!-- 2. 加载失败:点击重试 -->
<view v-else-if="loadError" class="error-state" @tap="loadConfig">
<text class="error-text">{{ loadError }}</text>
<text class="error-hint">点击重试</text>
</view>
<!-- 3. 全空:后台把所有分类都软删了 -->
<view v-else-if="categories.length === 0" class="empty-state">
<image class="empty-state-icon" src="/static/castlove/jinqingqidai.png" mode="aspectFit" />
<text class="empty-state-title">暂无内容</text>
</view>
<!-- 4. 某分类下卡片为空 -->
<view v-else-if="currentCardList.length === 0" class="empty-state">
<image class="empty-state-icon" src="/static/castlove/jinqingqidai.png" mode="aspectFit" />
<text class="empty-state-title">{{ currentCategoryName }}敬请期待</text>
</view>
<!-- 5. 正常:渲染卡片 -->
<view v-else>
<view
v-for="(card, index) in currentCardList"
:key="card.id"
class="card-item"
:class="{ 'no-transition': disableTransition }"
:style="getCardStyle(index)"
>
<view
class="card-frame"
:class="{
'no-border': !card.route_path,
'card-frame--tappable': !!card.route_path,
}"
@tap.stop="onCardFrameTap(card)"
>
<image
class="card-image"
:src="card.image_url"
mode="aspectFill"
@error="onImageError"
/>
<!-- "开发中"角标:仅当卡片名就是"开发中"时才显示(避免对其他未配置路由的卡片误显示) -->
<image
v-if="card.name === '开发中'"
class="coming-soon-badge"
src="/static/castlove/jinqingqidai.png"
/>
</view>
<text class="card-name">{{ card.name }}</text>
</view>
</view>
</view>
<!-- 右侧分类菜单:仅在数据正常时显示 -->
<view
v-if="!loading && !loadError && categories.length"
class="text-panel"
>
<view
class="arrow-btn arrow-up"
:class="{ 'arrow-btn--disabled': selectedCategoryIndex === 0 }"
@click="scrollUp"
>
<image
class="arrow-icon"
src="/static/castlove/jiantou.png"
style="transform: rotate(180deg)"
/>
</view>
<view class="text-list">
<view
v-for="(category, index) in categories"
:key="category.id"
class="text-item"
:class="{ active: selectedCategoryIndex === index }"
@click="selectCategory(index)"
>
<text>{{ category.name }}</text>
</view>
</view>
<view
class="arrow-btn arrow-down"
:class="{
'arrow-btn--disabled':
selectedCategoryIndex === categories.length - 1,
}"
@click="scrollDown"
>
<image class="arrow-icon" src="/static/castlove/jiantou.png" />
</view>
</view>
</view>
</view>
</template>
<script>
import { getCastloveConfigApi } from "@/utils/api.js";
export default {
// 作为组件被 mall.vue 引入时的入参
// (作为页面被 navigateTo 直接打开时不传值,走 onLoad 读取 URL 参数)
props: {
// 初始分类:star_card | badge | poster
type: {
type: String,
default: "",
},
},
watch: {
// 关键入口:组件模式下(被 mall.vue 引用),onLoad 不会触发
// 必须在这里同时拉配置 + 应用 type。immediate: true 保证 mount 时就跑一次
type: {
immediate: true,
async handler(val) {
// 先等数据回来,再定位分类(findIndex 需要 categories 已加载)
await this.loadConfig();
this.applyType(val);
},
},
},
onShow() {
try {
uni.hideToast();
uni.hideLoading();
} catch (e) {}
},
// 作为页面被直接打开(不走 mall.vue)的兜底入口
// 作为组件被 mall.vue 引入时,onLoad 不会触发,由 watch.type 接管
async onLoad(options) {
await this.loadConfig();
if (options && options.type) {
this.applyType(options.type);
}
},
data() {
return {
// 是否显示右侧分类菜单栏
// - 作为组件(mall.vue 中)使用时,默认 true
// - 作为页面被直接打开时,onLoad 会把它置为 false
showMenu: true,
selectedCategoryIndex: 0,
// ↓ 全部来自后端,含 route_path / route_params
// [{ id, name, type_key, sort_order, crafts: [{ id, name, image_url, route_path, route_params, sort_order }] }]
categories: [],
loading: true,
loadError: "",
// 原有交互/动效状态保留
selectedIndex: 1, // 默认第 2 张(切换分类时由 defaultSelectedIndex 动态重算)
touchStartY: 0,
dragOffset: 0,
isDragging: false,
disableTransition: false,
SWIPE_STEP: 100,
};
},
computed: {
currentCategoryName() {
return this.categories[this.selectedCategoryIndex]?.name || "";
},
currentCardList() {
return this.categories[this.selectedCategoryIndex]?.crafts || [];
},
currentCardCount() {
return this.currentCardList.length;
},
currentCenterIndex() {
return Math.floor(this.currentCardCount / 2);
},
// 切换分类时的默认选中卡片
// 原本固定 = 1(默认第 2 张),但卡片数 < 2 时会越界
// 卡片 0 张 → 0;卡片 1 张 → 0;卡片 ≥ 2 张 → 1
// 后台动态删卡也安全:卡片数变化后重新计算
defaultSelectedIndex() {
return Math.min(1, Math.max(0, this.currentCardCount - 1));
},
},
methods: {
/**
* 拉取后端配置(进入页面 / 点击重试时调用)
* 强制鉴权(token 由 utils/api.js 统一注入)
*/
async loadConfig() {
try {
this.loading = true;
this.loadError = "";
const res = await getCastloveConfigApi();
this.categories = (res && res.data && res.data.categories) || [];
} catch (e) {
this.loadError = e?.message || "配置加载失败";
this.categories = [];
} finally {
this.loading = false;
}
},
/**
* 根据 type 字符串定位到对应分类索引
* 在线 findIndex(不再依赖前端硬编码的 categoryTypeMap)
*/
applyType(type) {
if (!type) return;
const idx = this.categories.findIndex((c) => c.type_key === type);
if (idx >= 0) this.selectedCategoryIndex = idx;
// 找不到则保持 0,不报错
},
/** 切换大分类 */
selectCategory(index) {
this.selectedCategoryIndex = index;
// 用计算属性而不是写死 1(防止卡片数 < 2 越界)
this.selectedIndex = this.defaultSelectedIndex;
this.dragOffset = 0;
this.isDragging = false;
},
/** 触摸开始 */
onTouchStart(e) {
this.touchStartY = e.touches[0].clientY;
this.isDragging = true;
this.disableTransition = true;
},
/** 触摸移动 */
onTouchMove(e) {
if (!this.isDragging) return;
const moveY = e.touches[0].clientY;
this.dragOffset = moveY - this.touchStartY;
},
/** 触摸结束 - 切换卡片 */
onTouchEnd() {
if (!this.isDragging) return;
this.isDragging = false;
this.disableTransition = false;
const N = this.currentCardCount;
if (N === 0) return;
const moveCount = Math.round(-this.dragOffset / this.SWIPE_STEP);
let newIdx = this.selectedIndex + moveCount;
newIdx = ((newIdx % N) + N) % N;
this.selectedIndex = newIdx;
this.dragOffset = 0;
},
// ====================== 核心修复:z-index + 层级 + 循环 ======================
// 返回当前分类对应的扇形位置
// 规则:基础 5 个位置保持原样不动
// - N <= 5:取前 N 个
// - N > 5:基础 5 个 + (N-5) 个在末尾沿用底部样式追加
// 这样新增/删减小类型卡片时,既有的位置不会被打乱重组
getStackPositions(count) {
if (count <= 0) return [];
// 基础 5 张卡片的扇形布局(保持原状)
const basePositions = [
{ left: 288, top: 64, rotate: 25, scale: 0.75 },
{ left: 120, top: 288, rotate: 12, scale: 0.95 },
{ left: 60, top: 580, rotate: 0, scale: 1 },
{ left: 120, top: 888, rotate: -12, scale: 0.95 },
{ left: 224, top: 1096, rotate: -25, scale: 0.75 },
];
if (count <= basePositions.length) {
// 卡片少于 5 张:直接取前 N 个,不动基础数组
return basePositions.slice(0, count);
}
// 卡片多于 5 张:在末尾追加,沿用第 5 张的样式继续向下延伸
const positions = [...basePositions];
const tail = basePositions[basePositions.length - 1];
for (let i = basePositions.length; i < count; i++) {
const extra = i - basePositions.length + 1;
positions.push({
left: tail.left,
top: tail.top + extra * 208, // 沿用 888→1096 的间距
rotate: tail.rotate - extra * 12, // 沿用 -12°→-25° 的旋转步长
scale: tail.scale,
});
}
return positions;
},
getCardStyle(index) {
const N = this.currentCardCount;
if (N === 0) return {};
const positions = this.getStackPositions(N);
const centerInt = this.currentCenterIndex; // 扇形中心索引(N=5→2;N=4→2;N=6→3)
const centerPos = positions[centerInt] ?? positions[0];
const progress = -this.dragOffset / this.SWIPE_STEP;
const centerIdx = this.selectedIndex + progress;
// 循环最短差值(关键):以 N/2 作为环绕阈值,适配任意 N
let diff = index - centerIdx;
const half = N / 2;
if (diff > half) diff -= N;
if (diff < -half) diff += N;
const cardPos = diff + centerInt;
// 插值计算(支持首尾环绕)
let pos;
const lowerRaw = Math.floor(cardPos);
const t = cardPos - lowerRaw;
const lowerIdx = ((lowerRaw % N) + N) % N;
const upperIdx = (lowerIdx + 1) % N;
if (t === 0 && lowerIdx >= 0 && lowerIdx < N) {
pos = positions[lowerIdx] ?? centerPos;
} else {
const p = positions[lowerIdx] ?? centerPos;
const n = positions[upperIdx] ?? centerPos;
pos = {
left: p.left + (n.left - p.left) * t,
top: p.top + (n.top - p.top) * t,
rotate: p.rotate + (n.rotate - p.rotate) * t,
scale: p.scale + (n.scale - p.scale) * t,
};
}
// ====================== ZINDEX 终极修复 ======================
const distance = Math.abs(cardPos - centerInt);
const zIndex = Math.round(100 - distance * 30); // 越靠近中间层级越高
const isCenter = distance < 0.02;
if (isCenter) {
return {
left: pos.left + "rpx",
top: pos.top + "rpx",
transform: `scale(${pos.scale * 1.15}) rotate(${pos.rotate}deg)`,
zIndex: 999, // 中心永远最高
};
}
return {
left: pos.left + "rpx",
top: pos.top + "rpx",
transform: `scale(${pos.scale}) rotate(${pos.rotate}deg)`,
zIndex,
};
},
/** 当前叠卡在弧形布局中的槽位(2=正中,1=中上,0=最上...) */
getCardStackPosition(index) {
const N = this.currentCardCount;
if (N === 0) return 0;
const centerInt = this.currentCenterIndex;
const centerIdx = this.selectedIndex + -this.dragOffset / this.SWIPE_STEP;
let diff = index - centerIdx;
const half = N / 2;
if (diff > half) diff -= N;
if (diff < -half) diff += N;
return diff + centerInt;
},
/**
* 点击卡图区域
* - 正中主图(槽位 ≈ 中心)→ 读 card.route_path / route_params 跳转
* - 其余叠层 → 仅切换选中(把卡片移到中间)
*
* 跳转规则:
* 1. route_path 为空/null → 弹 "激情开发中" toast,不跳转
* 2. route_path 非空 → 拼上 route_params 作为 query
* 目标页面必须在 pages.json 中已注册,否则 uni.navigateTo 失败 → 弹 "页面不存在" toast
*/
onCardFrameTap(card) {
if (!card) return;
const pos = this.getCardStackPosition(this.currentCardList.indexOf(card));
// 只处理正中位置
if (Math.abs(pos - this.currentCenterIndex) >= 0.2) {
this.selectedIndex = this.currentCardList.indexOf(card);
return;
}
// 1. route_path 为空/null → toast
if (!card.route_path) {
uni.showToast({ title: "激情开发中", icon: "none" });
return;
}
// 2. 拼 query
// const query = this.buildQueryString(card.route_params);
// const url = query ? `${card.route_path}?name=${encodeURIComponent(card.name)}` : card.route_path;
const url = `${card.route_path}?name=${encodeURIComponent(card.name)}`;
console.log(url)
uni.navigateTo({
url,
fail: (err) => {
// 路径写错 / pages.json 未注册 → 兜底 toast,不闪退
console.error("[castlove] navigateTo fail", card.route_path, err);
uni.showToast({ title: "页面不存在", icon: "none" });
},
});
},
/**
* 把 route_params(JSON 字符串或对象)转成 query string
* - null/undefined/空对象 → ""
* - 嵌套对象/数组已被后端兜底清空,这里再做一次防御性过滤
*/
buildQueryString(params) {
if (!params) return "";
// 后端统一返回 JSON 字符串;同时兼容前端 mock 或老代码传对象的情况
let obj = params;
if (typeof params === "string") {
try {
obj = JSON.parse(params);
} catch (e) {
return "";
}
}
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return "";
return Object.keys(obj)
.filter(
(k) =>
obj[k] !== undefined &&
obj[k] !== null &&
typeof obj[k] !== "object"
)
.map(
(k) =>
`${encodeURIComponent(k)}=${encodeURIComponent(String(obj[k]))}`
)
.join("&");
},
/** 图片加载失败兜底 */
onImageError(e) {
console.warn("[craft-select] image load failed", e);
// 这里如果以后想换占位图,直接换 src
},
/** 右侧菜单上箭头 */
scrollUp() {
if (this.selectedCategoryIndex > 0) {
this.selectCategory(this.selectedCategoryIndex - 1);
}
},
/** 右侧菜单下箭头 */
scrollDown() {
if (this.selectedCategoryIndex < this.categories.length - 1) {
this.selectCategory(this.selectedCategoryIndex + 1);
}
},
},
};
</script>
<style lang="scss" scoped>
.page {
height: 100vh;
position: relative;
overflow: hidden;
}
.bg-wrapper {
position: fixed;
top: -16rpx;
left: 0;
width: 100%;
height: 110%;
z-index: 0;
}
.main-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
z-index: 10;
}
.cards-container {
flex: 1;
position: relative;
}
.card-item {
position: absolute;
width: 312rpx;
height: 312rpx;
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform, z-index;
}
.card-item.no-transition {
transition: none !important;
}
.card-frame {
position: relative;
width: 100%;
height: 100%;
border-radius: 24rpx;
padding: 24rpx;
background-image: url("/static/square/cangpinkuang1.png");
background-size: cover;
}
.card-frame.no-border {
background-image: none;
}
.card-frame--tappable {
cursor: pointer;
}
.card-image {
width: 100%;
height: 100%;
border-radius: 24rpx;
object-fit: cover;
}
.coming-soon-badge {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 160rpx;
height: 160rpx;
}
.card-name {
position: absolute;
bottom: -16rpx;
left: 32rpx;
background: linear-gradient(
93.1deg,
rgba(224, 180, 247, 0.71) -12.06%,
rgba(178, 246, 204, 0.71) 52.09%,
rgba(98, 178, 244, 0.71) 163.5%
);
backdrop-filter: blur(11.699999809265137px);
color: #fffabd;
text-shadow: -1px 1px 4px #ce0909d6;
font-size: 28rpx;
font-weight: 400;
padding: 0 16rpx;
border-radius: 16rpx;
}
// ===== 5 个状态分支(loading / error / empty / 正常)=====
.loading-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.loading-spinner {
width: 96rpx;
height: 96rpx;
opacity: 0.7;
}
.error-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 80%;
pointer-events: auto;
}
.error-text {
color: #fff;
font-size: 32rpx;
margin-bottom: 16rpx;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.35);
}
.error-hint {
color: rgba(255, 255, 255, 0.7);
font-size: 26rpx;
}
// ===== 右侧分类菜单(自适应高度)=====
.text-panel {
position: absolute;
right: 30rpx;
top: 50%;
transform: translateY(-50%);
width: 200rpx;
min-height: 392rpx; // 保留 3 项时的视觉高度
max-height: 80vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: url("/static/castlove/xiahualan.png") no-repeat center;
background-size: 130%;
border-radius: 20rpx;
padding: 16rpx 0;
}
.arrow-btn {
width: 60rpx;
height: 32rpx;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.8;
}
.arrow-btn--disabled {
opacity: 0.25;
pointer-events: none; // 已禁用时不再触发点击
}
.arrow-icon {
width: 48rpx;
height: 48rpx;
}
.text-list {
display: flex;
flex-direction: column;
padding: 0 20rpx;
// 超过 5 项时启用滚动(spec §6.7)
max-height: 60vh;
overflow-y: auto;
}
.text-item {
color: #fff;
font-size: 28rpx; // 统一字号,不再写 font-large / font-mid
font-weight: 500;
padding: 14rpx 20rpx;
border-radius: 14rpx;
display: flex;
justify-content: center;
transition: font-size 0.2s;
}
.text-item.active {
font-weight: bold;
font-size: 34rpx; // 选中项放大(spec §6.7)
background: url("/static/nft/dingbutubiao_liang.png") no-repeat center;
background-size: 100% 100%;
}
.empty-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 80%;
padding: 40rpx 0;
pointer-events: none;
}
.empty-state-icon {
width: 200rpx;
height: 200rpx;
opacity: 0.85;
margin-bottom: 32rpx;
}
.empty-state-title {
color: #fff;
font-size: 36rpx;
font-weight: 600;
margin-bottom: 16rpx;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.35);
}
.empty-state-desc {
color: rgba(255, 255, 255, 0.7);
font-size: 26rpx;
text-align: center;
}
</style>