Files
----/前端源码/uni-app/components/screens/MyNamingPlansScreen.vue

917 lines
26 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="plans-screen">
<view class="plans-bg"></view>
<view class="status-bar-placeholder"></view>
<!-- 固定导航栏 -->
<view class="plans-header">
<view class="plans-back-btn" @click="handleBack">
<text class="plans-back-icon"></text>
</view>
<text class="plans-title">我的方案</text>
<view class="plans-refresh-btn" @click="handleRefresh">
<text class="plans-refresh-icon" :class="{ rotating: loading }"></text>
</view>
</view>
<!-- 固定Tab切换 -->
<view class="plans-tabs">
<view v-for="tab in tabs" :key="tab.value"
:class="['plans-tab', { 'plans-tab-active': currentTab === tab.value }]"
@click="switchTab(tab.value as 'naming' | 'affinity' | 'zeji')">
<text class="plans-tab-text">{{ tab.label }}</text>
</view>
</view>
<!-- 滚动列表内容 -->
<scroll-view class="plans-scroll" scroll-y="true">
<view class="plans-content">
<!-- 初始加载状态 -->
<view v-if="loading && list.length === 0" class="initial-loading">
<text class="loading-text">加载中...</text>
</view>
<!-- 列表 -->
<template v-else-if="list.length > 0">
<!-- 名字方案列表 -->
<template v-if="currentTab === 'naming'">
<view v-for="item in list" :key="item.id" class="list-item" @click="viewNamingDetail(item)">
<view :class="['item-icon', item.category === 'company' ? 'icon-gold' : 'icon-red']">
<text class="icon-text">{{ item.category === 'company' ? '企' : '名' }}</text>
</view>
<view class="item-content">
<view class="item-header">
<text class="item-title">{{ item.title || '未命名方案' }}</text>
<text class="item-date">{{ item.relative_time }}</text>
</view>
<text class="item-subtitle">{{ item.subtitle }}</text>
<view class="item-tags">
<text :class="['item-tag', item.category === 'company' ? 'tag-gold' : 'tag-red']">
{{ item.category === 'company' ? '公司' : '个人' }}
</text>
<text class="item-status">
<text class="status-icon">{{ item.has_solutions ? '✓' : '⏱' }}</text>
{{ item.has_solutions ? '已生成方案' : '生成中' }}
</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-icon"></text>
</view>
</view>
</template>
<!-- 缘分合盘列表 -->
<template v-else-if="currentTab === 'affinity'">
<view v-for="item in list" :key="item.id" class="list-item" @click="viewAffinityDetail(item)">
<view class="item-icon icon-pink">
<text class="icon-text"></text>
</view>
<view class="item-content">
<view class="item-header">
<text class="item-title">{{ item.person1_name }} & {{ item.person2_name }}</text>
<text class="item-date">{{ formatDate(item.created_time) }}</text>
</view>
<text class="item-subtitle">
{{ getRelationshipLabel(item.relationship) }}
<template v-if="item.score">· 缘分指数 {{ item.score }}</template>
</text>
<view class="item-tags">
<text v-if="item.score_badge" class="item-tag tag-pink">{{ item.score_badge }}</text>
<text class="item-status">
<text class="status-icon">{{ getTaskStatusIcon(item.task_status) }}</text>
{{ getTaskStatusText(item.task_status) }}
</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-icon"></text>
</view>
</view>
</template>
<!-- 八字择吉列表 -->
<template v-else-if="currentTab === 'zeji'">
<view v-for="item in list" :key="item.id" class="list-item" @click="viewZejiDetail(item)">
<view class="item-icon icon-purple">
<text class="icon-text"></text>
</view>
<view class="item-content">
<view class="item-header">
<text class="item-title">{{ item.event_name || '择吉事项' }}</text>
<text class="item-date">{{ formatDate(item.created_time) }}</text>
</view>
<text class="item-subtitle">
{{ item.event_type_label }}
<template v-if="item.selected_date">· {{ item.selected_date }}</template>
</text>
<view class="item-tags">
<text v-if="item.score_badge" class="item-tag tag-purple">{{ item.score_badge }}</text>
<text class="item-status">
<text class="status-icon">{{ getTaskStatusIcon(item.task_status) }}</text>
{{ getTaskStatusText(item.task_status) }}
</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-icon"></text>
</view>
</view>
</template>
<!-- 加载更多按钮 -->
<view class="load-more-wrapper">
<view v-if="loading" class="loading-more">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="hasMore" class="load-more-btn" @click="loadNextPage">
<text class="load-more-text">点击加载更多</text>
</view>
<view v-else class="no-more">
<text class="no-more-text"> 已加载全部 </text>
</view>
</view>
</template>
<!-- 空状态 -->
<template v-else>
<view class="empty-state">
<text class="empty-icon">📋</text>
<text class="empty-text">{{ getEmptyText() }}</text>
<view class="empty-btn" @click="handleEmptyAction">
<text class="empty-btn-text">{{ getEmptyButtonText() }}</text>
</view>
</view>
</template>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { userApi, namingApi, affinityApi, baziZejiApi } from "@/api";
import type { MyReportItem } from "@/api/types";
import type { AffinityListItem } from "@/api";
import type { BaziZejiListItem } from "@/api";
declare const uni: any;
const props = defineProps<{
/** 从详情页返回时父级传入,用于选中「名字 / 缘分合盘 / 八字择吉」子 tab */
focusListTab?: 'naming' | 'affinity' | 'zeji' | null;
}>();
const emit = defineEmits<{
back: [];
navigate: [screen: string];
showDetail: [data: any, category?: string, serviceType?: string];
showNamingSolutionsList: [payload: { reportId: number; solutions: any[]; category?: string; serviceType?: string }];
showAffinityResult: [data: any, fromMyPlans?: boolean];
showAuspiciousResult: [data: any, fromMyPlans?: boolean];
focusListTabConsumed: [];
}>();
// Tab配置
const tabs = [
{ label: '名字', value: 'naming' },
{ label: '缘分合盘', value: 'affinity' },
{ label: '八字择吉', value: 'zeji' },
];
// 响应式数据
const currentTab = ref<'naming' | 'affinity' | 'zeji'>('naming');
const loading = ref(false);
const list = ref<any[]>([]);
const pageNo = ref(1);
const pageSize = 10;
const total = ref(0);
const hasMore = ref(true);
// 重置分页状态(须在 switchTab / watch 之前声明,避免暂时性死区)
const resetPagination = () => {
pageNo.value = 1;
list.value = [];
hasMore.value = true;
total.value = 0;
};
// 加载列表数据
const loadList = async (refresh = false) => {
if (loading.value) return;
if (!refresh && !hasMore.value) return;
loading.value = true;
try {
let res: any;
if (currentTab.value === 'naming') {
res = await userApi.getMyReports({
page_no: pageNo.value,
page_size: pageSize,
});
const items = res?.items || (Array.isArray(res) ? res : []);
if (refresh) {
list.value = items;
} else {
list.value = [...list.value, ...items];
}
total.value = res?.total || items.length;
hasMore.value = list.value.length < total.value;
} else if (currentTab.value === 'affinity') {
res = await affinityApi.getAffinityList({
page_no: pageNo.value,
page_size: pageSize,
});
if (res && res.data) {
const items = Array.isArray(res.data.data) ? res.data.data : [];
if (refresh) {
list.value = items;
} else {
list.value = [...list.value, ...items];
}
total.value = res.data.total || 0;
hasMore.value = res.data.has_next || false;
}
} else if (currentTab.value === 'zeji') {
res = await baziZejiApi.getBaziZejiList({
page_no: pageNo.value,
page_size: pageSize,
});
if (res && res.data) {
const items = Array.isArray(res.data.data) ? res.data.data : [];
if (refresh) {
list.value = items;
} else {
list.value = [...list.value, ...items];
}
total.value = res.data.total || 0;
hasMore.value = res.data.has_next || false;
}
}
pageNo.value++;
} catch (error) {
console.error('加载失败:', error);
uni.showToast({ title: '加载失败,请重试', icon: 'none' });
} finally {
loading.value = false;
}
};
// 点击加载下一页
const loadNextPage = () => {
if (!loading.value && hasMore.value) {
loadList();
}
};
// 刷新
const handleRefresh = async () => {
if (loading.value) return;
resetPagination();
await loadList(true);
uni.showToast({ title: '刷新成功', icon: 'success', duration: 1000 });
};
// 切换Tab依赖 resetPagination、loadList须放在二者之后
const switchTab = (tab: 'naming' | 'affinity' | 'zeji') => {
if (currentTab.value === tab) return;
currentTab.value = tab;
resetPagination();
loadList(true);
};
watch(
() => props.focusListTab,
(v) => {
if (!v) return;
if (v === currentTab.value) {
emit('focusListTabConsumed');
return;
}
switchTab(v);
emit('focusListTabConsumed');
},
{ immediate: true }
);
// 查看名字方案详情
const viewNamingDetail = async (item: MyReportItem) => {
if (!item.has_solutions) {
uni.showToast({ title: "方案生成中,请稍后查看", icon: "none" });
return;
}
const parseMaybeJson = (value: any) => {
if (value == null) return value;
if (typeof value === "object") return value;
if (typeof value !== "string") return value;
const raw = value.trim();
if (!raw) return value;
try {
return JSON.parse(raw);
} catch {
return value;
}
};
try {
uni.showLoading({ title: "加载中..." });
const solutionsResult = await namingApi.getSolutionsByReportId(item.id);
const solutions = solutionsResult?.solutions || solutionsResult?.items || solutionsResult;
// 新规则:接口返回 solutions 数组且有数据 -> 跳转到新的方案列表页
if (Array.isArray(solutions) && solutions.length > 0) {
uni.hideLoading();
emit("showNamingSolutionsList", {
reportId: item.id,
solutions,
category: item.category,
serviceType: String(item.service_type || ''),
});
return;
}
// 否则:回退到旧版详情页(接口可能直接返回详情对象)
const parsedLegacy = parseMaybeJson(solutionsResult);
uni.hideLoading();
if (parsedLegacy && typeof parsedLegacy === "object") {
emit("showDetail", parsedLegacy as any, item.category, String(item.service_type || ''));
return;
}
uni.showToast({ title: "暂无方案数据", icon: "none" });
return;
} catch (e: any) {
uni.hideLoading();
console.error('Error in viewNamingDetail:', e);
uni.showToast({ title: e.msg || e.message || "获取详情失败", icon: "none" });
}
};
// 查看缘分合盘详情
const viewAffinityDetail = async (item: AffinityListItem) => {
if (item.task_status !== 5) {
uni.showToast({ title: "生成中,请稍后查看", icon: "none" });
return;
}
uni.showLoading({ title: "加载中..." });
const detailData = await affinityApi.getAffinityDetail(item.id);
uni.hideLoading();
if (!detailData) {
uni.showToast({ title: "获取详情失败", icon: "none" });
return;
}
const transformedData = {
relationship: detailData.relationship,
relationshipLabel: getRelationshipLabel(detailData.relationship),
person1: {
name: detailData.person1_name,
gender: detailData.person1_gender,
birthDate: detailData.person1_birth_date,
},
person2: {
name: detailData.person2_name,
gender: detailData.person2_gender,
birthDate: detailData.person2_birth_date,
},
score: detailData.score,
scoreBadge: detailData.score_badge,
sixDimension: detailData.six_dimension ? JSON.parse(detailData.six_dimension) : null,
radarDesc: detailData.radar_desc,
analysisCards: detailData.analysis_cards ? JSON.parse(detailData.analysis_cards).map((card: any, index: number) => ({
id: `card-${index}`,
icon: ['💗', '💬', '🛡️', '⚡', '🎯', '🏠'][index] || '✨',
iconBg: ['rgba(236, 72, 153, 0.2)', 'rgba(59, 130, 246, 0.2)', 'rgba(34, 197, 94, 0.2)', 'rgba(234, 179, 8, 0.2)', 'rgba(168, 85, 247, 0.2)', 'rgba(249, 115, 22, 0.2)'][index] || 'rgba(156, 163, 175, 0.2)',
title: card.title,
score: String(card.score),
summary: card.content.substring(0, 50) + '...',
content: card.content,
})) : [],
unlocked: detailData.unlocked_content ? JSON.parse(detailData.unlocked_content) : null,
isUnlocked: detailData.is_unlocked === 1,
unlockPrice: detailData.unlock_price,
unlockStats: {
unlockCount: 12392,
accuracy: '98%',
},
};
emit('showAffinityResult', transformedData, true);
};
// 查看八字择吉详情
const viewZejiDetail = async (item: BaziZejiListItem) => {
uni.showLoading({ title: "加载中..." });
const detailData = await baziZejiApi.getBaziZejiDetail(item.id);
uni.hideLoading();
if (!detailData) {
uni.showToast({ title: "获取详情失败", icon: "none" });
return;
}
emit('showAuspiciousResult', detailData, true);
};
// 工具函数:相对时间(刚刚 / N分钟前 / N小时前 / …)
const formatDate = (dateStr: string) => {
if (!dateStr) return '';
const date = new Date(dateStr);
if (Number.isNaN(date.getTime())) return String(dateStr).trim();
const now = Date.now();
const diff = now - date.getTime();
if (diff < 0) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
return `${y}-${m}-${d} ${h}:${min}`;
}
const minutes = Math.floor(diff / (1000 * 60));
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}小时前`;
const days = Math.floor(hours / 24);
if (days === 1) return '昨天';
if (days < 7) return `${days}天前`;
if (days < 30) return `${Math.floor(days / 7)}周前`;
if (days < 365) return `${Math.floor(days / 30)}月前`;
return `${Math.floor(days / 365)}年前`;
};
const getRelationshipLabel = (relationship: string) => {
const labels: Record<string, string> = {
'couple': '恋人',
'married': '夫妻',
'crush': '暗恋',
'partner': '合作伙伴',
'friend': '朋友',
'family': '家人',
};
return labels[relationship] || relationship;
};
const getTaskStatusIcon = (status: number) => {
if (status === 5) return '✓';
if (status === 0) return '⏱';
return '⏱';
};
const getTaskStatusText = (status: number) => {
if (status === 5) return '已生成';
if (status === 0) return '生成中';
return '生成中';
};
const getEmptyText = () => {
if (currentTab.value === 'naming') return '暂无起名方案';
if (currentTab.value === 'affinity') return '暂无缘分合盘记录';
if (currentTab.value === 'zeji') return '暂无八字择吉记录';
return '暂无数据';
};
const getEmptyButtonText = () => {
if (currentTab.value === 'naming') return '去起名';
if (currentTab.value === 'affinity') return '去测算';
if (currentTab.value === 'zeji') return '去择吉';
return '去测算';
};
const handleEmptyAction = () => {
if (currentTab.value === 'naming') {
emit('navigate', 'naming');
} else if (currentTab.value === 'affinity') {
emit('navigate', 'affinity');
} else if (currentTab.value === 'zeji') {
emit('navigate', 'calendar');
}
};
const handleBack = () => {
emit('back');
};
// 初始化
onMounted(() => {
loadList(true);
});
</script>
<style scoped>
.plans-screen {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f0efe9;
position: relative;
font-family: SimSun, "Songti SC", "Songti TC", "Noto Serif SC", STSong, serif;
}
.status-bar-placeholder {
height: var(--status-bar-height, 0);
width: 100%;
flex-shrink: 0;
}
.plans-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
opacity: 0.3;
background-image: url("https://www.transparenttextures.com/patterns/rice-paper.png");
}
.plans-header {
position: sticky;
top: var(--status-bar-height, 0);
z-index: 100;
height: 88rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32rpx;
border-bottom: 1rpx solid #dcd3c9;
background-color: rgba(253, 251, 247, 0.95);
backdrop-filter: blur(10rpx);
}
.plans-back-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
padding: 0;
margin-left: -16rpx;
}
.plans-back-icon {
font-size: 48rpx;
color: #5a5a5a;
font-weight: 300;
}
.plans-title {
font-size: 32rpx;
font-weight: 700;
color: #2c2c2c;
letter-spacing: 0.2em;
}
.plans-refresh-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
padding: 0;
margin-right: -16rpx;
}
.plans-refresh-icon {
font-size: 40rpx;
color: #5a5a5a;
font-weight: 300;
transition: transform 0.3s ease;
}
.plans-refresh-icon.rotating {
animation: rotate 1s linear infinite;
}
.plans-tabs {
position: sticky;
top: calc(var(--status-bar-height, 0) + 88rpx);
z-index: 99;
display: flex;
background-color: rgba(253, 251, 247, 0.95);
border-bottom: 1rpx solid #dcd3c9;
padding: 0 32rpx;
backdrop-filter: blur(10rpx);
}
.plans-tab {
flex: 1;
padding: 24rpx 0;
text-align: center;
position: relative;
transition: all 0.3s ease;
}
.plans-tab-text {
font-size: 28rpx;
color: #999;
transition: all 0.3s ease;
}
.plans-tab-active .plans-tab-text {
color: #8b2323;
font-weight: 700;
}
.plans-tab-active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 48rpx;
height: 4rpx;
background-color: #8b2323;
border-radius: 2rpx;
}
.plans-scroll {
flex: 1;
height: calc(100vh - var(--status-bar-height, 0) - 88rpx - 76rpx);
}
.plans-content {
padding: 32rpx;
}
.initial-loading {
display: flex;
align-items: center;
justify-content: center;
height: 400rpx;
}
.loading-text {
font-size: 28rpx;
color: #999;
}
.list-item {
display: flex;
align-items: flex-start;
background-color: #fffdf9;
padding: 32rpx;
border-radius: 24rpx;
border: 1rpx solid #e5e5e5;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
margin-bottom: 32rpx;
transition: all 0.3s ease;
}
.list-item:active {
transform: scale(0.98);
background-color: #f8f6f2;
}
.item-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 32rpx;
}
.icon-red {
background-color: rgba(139, 35, 35, 0.1);
}
.icon-gold {
background-color: rgba(212, 175, 55, 0.1);
}
.icon-pink {
background-color: rgba(236, 72, 153, 0.1);
}
.icon-purple {
background-color: rgba(147, 51, 234, 0.1);
}
.icon-text {
font-size: 36rpx;
font-weight: 500;
}
.icon-red .icon-text {
color: #8b2323;
}
.icon-gold .icon-text {
color: #d4af37;
}
.icon-pink .icon-text {
color: #ec4899;
}
.icon-purple .icon-text {
color: #9333ea;
}
.item-content {
flex: 1;
min-width: 0;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8rpx;
}
.item-title {
font-size: 28rpx;
font-weight: 700;
color: #2c2c2c;
flex: 1;
margin-right: 16rpx;
}
.item-date {
font-size: 20rpx;
color: #999;
background-color: #f5f5f5;
padding: 4rpx 12rpx;
border-radius: 999rpx;
flex-shrink: 0;
}
.item-subtitle {
font-size: 24rpx;
color: #5a5a5a;
margin-bottom: 24rpx;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-tags {
display: flex;
align-items: center;
gap: 16rpx;
}
.item-tag {
font-size: 20rpx;
padding: 2rpx 12rpx;
border-width: 1rpx;
border-style: solid;
border-radius: 4rpx;
}
.tag-red {
border-color: rgba(139, 35, 35, 0.3);
color: #8b2323;
}
.tag-gold {
border-color: rgba(212, 175, 55, 0.3);
color: #d4af37;
}
.tag-pink {
border-color: rgba(236, 72, 153, 0.3);
color: #ec4899;
}
.tag-purple {
border-color: rgba(147, 51, 234, 0.3);
color: #9333ea;
}
.item-status {
font-size: 20rpx;
color: #ccc;
display: flex;
align-items: center;
}
.status-icon {
font-size: 20rpx;
margin-right: 8rpx;
}
.item-arrow {
align-self: center;
margin-left: 16rpx;
}
.arrow-icon {
font-size: 32rpx;
color: #ccc;
}
.load-more-wrapper {
padding: 32rpx 0;
text-align: center;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
padding: 20rpx;
}
.load-more-btn {
background: #8b2323;
color: white;
padding: 20rpx 40rpx;
border-radius: 12rpx;
display: inline-block;
}
.load-more-text {
font-size: 28rpx;
color: white;
}
.no-more {
padding: 20rpx;
}
.no-more-text {
font-size: 24rpx;
color: #999;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 500rpx;
}
.empty-icon {
font-size: 96rpx;
opacity: 0.3;
margin-bottom: 24rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
margin-bottom: 32rpx;
}
.empty-btn {
background-color: #8b2323;
padding: 16rpx 64rpx;
border-radius: 8rpx;
border: none;
}
.empty-btn-text {
font-size: 28rpx;
color: white;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>