upload project source code

This commit is contained in:
2026-04-30 18:49:43 +08:00
commit 9b394ba682
2277 changed files with 660945 additions and 0 deletions

View File

@@ -0,0 +1,916 @@
<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>