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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,115 @@
<template>
<view class="tabbar">
<view class="tabbar-inner">
<view v-for="tab in tabs" :key="tab.id" class="tab-btn" :class="{ active: currentTab === tab.id }"
@click="$emit('change', tab.id)">
<view class="tab-icon">
<HomeIcon v-if="tab.id === 'home'" size="24" class="tab-icon-svg" />
<TestIcon v-else-if="tab.id === 'test'" size="24" class="tab-icon-svg" />
<NamingIcon v-else-if="tab.id === 'naming'" size="24" class="tab-icon-svg" />
<RenamingIcon v-else-if="tab.id === 'renaming'" size="24" class="tab-icon-svg" />
<ProfileIcon v-else-if="tab.id === 'profile'" size="24" class="tab-icon-svg" />
<text v-else class="icon-dot" />
</view>
<text class="tab-label">{{ tab.label }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import HomeIcon from "./icons/HomeIcon.vue";
import TestIcon from "./icons/TestIcon.vue";
import NamingIcon from "./icons/NamingIcon.vue";
import RenamingIcon from "./icons/RenamingIcon.vue";
import ProfileIcon from "./icons/ProfileIcon.vue";
const props = defineProps<{
currentTab: string;
}>();
const tabs = [
{ id: "home", label: "首页" },
{ id: "test", label: "测名" },
{ id: "naming", label: "起名" },
{ id: "renaming", label: "改名" },
{ id: "profile", label: "我的" }
];
</script>
<style scoped>
.tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fdfbf7;
border-top: 2px solid #dcd3c9;
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.05);
z-index: 999;
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: constant(safe-area-inset-bottom);
}
.tabbar-inner {
display: flex;
justify-content: space-around;
align-items: center;
height: 60px;
padding: 10px 0;
max-width: 750px;
margin: 0 auto;
}
.tab-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
color: #5a5a5a;
font-size: 12px;
letter-spacing: 0.2em;
cursor: pointer;
user-select: none;
}
.tab-btn:active {
transform: scale(0.98);
}
.tab-btn.active {
color: #8b2323;
font-weight: 700;
}
.tab-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(139, 35, 35, 0.05);
transform: scale(1.05);
}
.tab-btn:not(.active) .tab-icon {
background: transparent;
}
.tab-icon-svg {
width: 24px;
height: 24px;
}
.icon-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: 0.9;
display: block;
}
</style>

View File

@@ -0,0 +1,508 @@
<!--
响应式营销站顶栏参考桌面端横向导航 + 手机端抽屉菜单
用法在页面中 <MarketingNavBar />按需改 navSections / brand / @login
-->
<template>
<view class="mnav">
<view class="mnav-inner">
<view class="mnav-brand">
<text class="mnav-logo">H</text>
<text class="mnav-title">海珀AI</text>
</view>
<!-- 桌面横向链接 -->
<view class="mnav-desktop">
<text
v-for="link in topLinks"
:key="link.id"
class="mnav-link"
:class="{ 'mnav-link--active': activeDropdown === link.id }"
@mouseenter="onDesktopEnter(link)"
@mouseleave="onDesktopLeave"
@click="onTopLinkClick(link)"
>
{{ link.label }}
</text>
</view>
<view class="mnav-right">
<text class="mnav-login mnav-login--bar" @click="emit('login')">登录</text>
<!-- 手机汉堡 -->
<view class="mnav-burger" @click="openDrawer">
<view class="mnav-burger-line" />
<view class="mnav-burger-line" />
<view class="mnav-burger-line" />
</view>
</view>
</view>
<!-- 桌面下拉面板 -->
<view
v-if="activeDropdown === 'solutions' && showMega"
class="mnav-mega"
@mouseenter="setMegaHover(true)"
@mouseleave="setMegaHover(false)"
>
<view class="mnav-mega-inner">
<view v-for="col in megaColumns" :key="col.title" class="mnav-mega-col">
<text class="mnav-mega-title">{{ col.title }}</text>
<text v-for="(item, i) in col.items" :key="i" class="mnav-mega-item" @click="emitNav(item)">{{ item }}</text>
</view>
</view>
</view>
<!-- 手机遮罩 + 抽屉 -->
<view v-if="drawerOpen" class="mnav-overlay" @click="closeDrawer" />
<view class="mnav-drawer" :class="{ 'mnav-drawer--open': drawerOpen }">
<view class="mnav-drawer-head">
<text class="mnav-drawer-title">菜单</text>
<view class="mnav-drawer-close" @click="closeDrawer">
<text class="mnav-drawer-close-x">×</text>
</view>
</view>
<scroll-view scroll-y class="mnav-drawer-scroll">
<view
v-for="sec in navSections"
:key="sec.id"
class="mnav-acc"
>
<view class="mnav-acc-head" @click="toggleAcc(sec.id)">
<text class="mnav-acc-title">{{ sec.title }}</text>
<text class="mnav-acc-chevron">{{ expandedId === sec.id ? '' : '+' }}</text>
</view>
<view v-if="expandedId === sec.id" class="mnav-acc-body">
<text
v-for="(line, li) in sec.items"
:key="li"
class="mnav-acc-link"
@click="onDrawerItem(line)"
>
{{ line }}
</text>
</view>
</view>
<view class="mnav-drawer-login-wrap">
<text class="mnav-drawer-login" @click="onDrawerLogin">登录</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const emit = defineEmits<{
login: [];
navigate: [payload: { label: string; section?: string }];
}>();
/** 顶部一级链接(无下拉的直接 emit */
const topLinks = [
{ id: 'home', label: '首页' },
{ id: 'cases', label: '客户案例' },
{ id: 'rpa', label: '电商RPA实例' },
{ id: 'solutions', label: '行业解决方案', mega: true },
{ id: 'eco', label: '生态合作' },
{ id: 'about', label: '关于HyperAigc' },
{ id: 'user', label: '个人中心' },
] as const;
/** 桌面端「行业解决方案」三列(可按后端配置替换) */
const megaColumns = [
{
title: '政务与公用事业',
items: ['党务政务', '研政购务', '政府政策运营', '舆情分析'],
},
{
title: '企业解决方案',
items: ['智能知识', '供应链管理', 'CRM', '对话管理', '人力资源', '营销自动化', '安全监督', '行业培训'],
},
{
title: '领域解决方案',
items: ['金融科技', '电商零售', '在线教育'],
},
];
/** 手机抽屉:分组折叠(与桌面信息等价,纵向更易读) */
const navSections = [
{
id: 'gov',
title: '政务与公用事业',
items: ['党务政务', '研政购务', '政府政策运营', '舆情分析'],
},
{
id: 'ent',
title: '企业解决方案',
items: ['智能知识', '供应链管理', 'CRM', '对话管理', '人力资源', '营销自动化', '安全监督', '行业培训'],
},
{
id: 'domain',
title: '领域解决方案',
items: ['金融科技', '电商零售', '在线教育'],
},
{
id: 'more',
title: '更多',
items: ['首页', '万年历', '起名服务', '姓名测试', '缘分合盘', '客户案例', '电商RPA实例', '生态合作', '关于HyperAigc', '个人中心'],
},
];
const drawerOpen = ref(false);
const expandedId = ref<string | null>('gov');
const activeDropdown = ref<string | null>(null);
const showMega = ref(false);
let megaTimer: ReturnType<typeof setTimeout> | null = null;
const megaEnter = ref(false);
function openDrawer() {
drawerOpen.value = true;
}
function closeDrawer() {
drawerOpen.value = false;
}
function toggleAcc(id: string) {
expandedId.value = expandedId.value === id ? null : id;
}
function onDrawerItem(label: string) {
emit('navigate', { label });
closeDrawer();
}
function onDrawerLogin() {
emit('login');
closeDrawer();
}
function onDesktopEnter(link: (typeof topLinks)[number]) {
if (megaTimer) {
clearTimeout(megaTimer);
megaTimer = null;
}
activeDropdown.value = link.id;
showMega.value = link.id === 'solutions' && 'mega' in link && link.mega;
}
function onDesktopLeave() {
megaTimer = setTimeout(() => {
if (!megaEnter.value) {
activeDropdown.value = null;
showMega.value = false;
}
}, 120);
}
function setMegaHover(v: boolean) {
if (v && megaTimer) {
clearTimeout(megaTimer);
megaTimer = null;
}
megaEnter.value = v;
if (!v) {
megaTimer = setTimeout(() => {
if (!megaEnter.value) {
activeDropdown.value = null;
showMega.value = false;
}
}, 80);
}
}
function onTopLinkClick(link: (typeof topLinks)[number]) {
if (link.id === 'solutions') {
// 窄屏无 hover点击展开/收起 mega宽屏以悬停为准避免误触反复开关
if (typeof window !== 'undefined' && window.innerWidth < 992) {
showMega.value = !showMega.value;
activeDropdown.value = showMega.value ? 'solutions' : null;
}
return;
}
emit('navigate', { label: link.label });
}
function emitNav(label: string) {
emit('navigate', { label, section: '行业解决方案' });
activeDropdown.value = null;
showMega.value = false;
megaEnter.value = false;
}
</script>
<style scoped>
.mnav {
position: relative;
z-index: 200;
background: rgba(8, 12, 28, 0.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.mnav-inner {
max-width: 1200px;
margin: 0 auto;
padding: 12px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.mnav-brand {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.mnav-logo {
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(145deg, #3b82f6, #1d4ed8);
color: #fff;
font-size: 18px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
}
.mnav-title {
font-size: 18px;
font-weight: 700;
color: #e8eefc;
letter-spacing: 0.06em;
}
.mnav-desktop {
display: none;
flex: 1;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 8px 20px;
}
.mnav-link {
font-size: 14px;
color: rgba(226, 232, 255, 0.85);
padding: 6px 0;
cursor: pointer;
}
.mnav-link--active {
color: #93c5fd;
}
.mnav-right {
display: flex;
align-items: center;
gap: 12px;
}
.mnav-login {
font-size: 14px;
color: #e8eefc;
padding: 8px 18px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.2);
cursor: pointer;
}
.mnav-burger {
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 40px;
height: 40px;
padding: 8px;
box-sizing: border-box;
cursor: pointer;
}
.mnav-burger-line {
height: 2px;
background: #e8eefc;
border-radius: 1px;
}
.mnav-mega {
position: absolute;
left: 0;
right: 0;
top: 100%;
padding: 16px 20px 24px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
}
.mnav-mega-inner {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.mnav-mega-title {
display: block;
font-size: 13px;
font-weight: 700;
color: #0f172a;
padding-bottom: 8px;
margin-bottom: 8px;
border-bottom: 2px solid #1e3a8a;
}
.mnav-mega-item {
display: block;
font-size: 13px;
color: #334155;
padding: 6px 0;
cursor: pointer;
}
.mnav-mega-item:active {
color: #1d4ed8;
}
.mnav-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 210;
}
.mnav-drawer {
position: fixed;
top: 0;
left: 0;
width: min(88vw, 360px);
height: 100%;
background: #f8fafc;
z-index: 220;
transform: translateX(-100%);
transition: transform 0.28s ease;
display: flex;
flex-direction: column;
box-shadow: 8px 0 32px rgba(0, 0, 0, 0.15);
}
.mnav-drawer--open {
transform: translateX(0);
}
.mnav-drawer-head {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid #e2e8f0;
background: #fff;
}
.mnav-drawer-title {
font-size: 17px;
font-weight: 700;
color: #0f172a;
}
.mnav-drawer-close {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.mnav-drawer-close-x {
font-size: 28px;
color: #64748b;
line-height: 1;
}
.mnav-drawer-scroll {
flex: 1;
height: 0;
padding: 8px 0 24px;
}
.mnav-acc {
border-bottom: 1px solid #e2e8f0;
background: #fff;
}
.mnav-acc-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
}
.mnav-acc-title {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
.mnav-acc-chevron {
font-size: 18px;
color: #64748b;
}
.mnav-acc-body {
padding: 0 18px 14px;
}
.mnav-acc-link {
display: block;
font-size: 14px;
color: #475569;
padding: 10px 0;
border-bottom: 1px solid #f1f5f9;
}
.mnav-acc-link:last-child {
border-bottom: none;
}
.mnav-drawer-login-wrap {
padding: 20px 18px;
}
.mnav-drawer-login {
display: block;
text-align: center;
padding: 12px;
border-radius: 10px;
background: #0f172a;
color: #f8fafc;
font-size: 15px;
font-weight: 600;
}
@media (min-width: 992px) {
.mnav-desktop {
display: flex;
}
.mnav-burger {
display: none;
}
}
@media (max-width: 991px) {
.mnav-mega {
display: none;
}
.mnav-login--bar {
display: none;
}
}
</style>

View File

@@ -0,0 +1,376 @@
<template>
<view class="mystic-compass-wrapper" :class="{ 'mystic-compass-wrapper--desktop': desktop }">
<!-- 手机端可退出电脑端测名仅等待结果不展示退出 -->
<view v-if="!desktop" class="compass-back-btn" @click="handleBack">
<text class="compass-back-icon">×</text>
</view>
<view class="mystic-compass">
<view class="compass-glow"></view>
<view class="compass-svg-wrap">
<!-- 外圈虚线圆 -->
<view class="circle-outer"></view>
<view class="circle-main"></view>
<!-- 天干地支 -->
<view v-for="(char, i) in runes" :key="'rune-' + i" class="rune-char" :class="{ active: i === activeCharIndex }"
:style="getRuneStyle(i)">
{{ char }}
</view>
<!-- 八卦符号 - 旋转圈 -->
<view class="bagua-circle" :style="{ transform: 'rotate(' + baGuaRotation + 'deg)' }">
<view v-for="(gua, i) in baGua" :key="'gua-' + i" class="bagua-char" :style="getBaGuaStyle(i)">
{{ gua }}
</view>
</view>
<!-- 中心罗盘指针 -->
<view class="compass-pointer" :style="{ transform: 'rotate(' + pointerRotation + 'deg)' }">
<view class="pointer-bar"></view>
<view class="pointer-dot"></view>
</view>
</view>
</view>
<!-- 加载文本 -->
<view class="loading-text">
<view class="loading-title">{{ title }}</view>
<view class="loading-subtitle">{{ subtitle }}</view>
<view v-if="!desktop" class="loading-tip">
分析时间预计1-2分钟可点击返回按钮退出并在我的方案中查看结果
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
interface Props {
title?: string;
subtitle?: string;
/** 电脑端网页:不显示退出按钮与「预计时间 / 我的方案」提示 */
desktop?: boolean;
}
withDefaults(defineProps<Props>(), {
title: '天机推演中',
subtitle: '易经数理 · 五行生克 · 三才五格',
desktop: false,
});
const emit = defineEmits<{
back: [];
}>();
const handleBack = () => {
emit('back');
};
const runes = [
"甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸",
"子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"
];
const baGua = ["☰", "☱", "☲", "☳", "☴", "☵", "☶", "☷"];
const activeCharIndex = ref(-1);
const baGuaRotation = ref(0);
const pointerRotation = ref(0);
let runeInterval: number | null = null;
let baGuaInterval: number | null = null;
let pointerInterval: number | null = null;
const getRuneStyle = (index: number) => {
const angle = (index * 360) / runes.length;
const radius = 120; // px
const angleRad = (angle - 90) * (Math.PI / 180);
const x = 150 + radius * Math.cos(angleRad);
const y = 150 + radius * Math.sin(angleRad);
return {
left: x + 'px',
top: y + 'px',
transform: `translate(-50%, -50%) rotate(${angle}deg)`
};
};
const getBaGuaStyle = (index: number) => {
const angle = (index * 360) / 8;
const radius = 72.5; // px
const angleRad = (angle - 90) * (Math.PI / 180);
// .bagua-circle 的宽高是 180px中心点在 (90, 90)
const x = 90 + radius * Math.cos(angleRad);
const y = 90 + radius * Math.sin(angleRad);
return {
left: x + 'px',
top: y + 'px',
transform: `translate(-50%, -50%) rotate(${angle}deg)`
};
};
onMounted(() => {
// 天干地支闪烁
let count = 0;
runeInterval = setInterval(() => {
activeCharIndex.value = count % runes.length;
count++;
}, 100);
// 八卦旋转
baGuaInterval = setInterval(() => {
baGuaRotation.value += 0.25;
}, 30);
// 指针旋转
let pointerCount = 0;
pointerInterval = setInterval(() => {
pointerCount += 1;
pointerRotation.value = pointerCount * 1.2;
}, 10);
});
onUnmounted(() => {
if (runeInterval) clearInterval(runeInterval);
if (baGuaInterval) clearInterval(baGuaInterval);
if (pointerInterval) clearInterval(pointerInterval);
});
</script>
<style scoped>
.mystic-compass-wrapper--desktop {
background: radial-gradient(ellipse 80% 60% at 50% 30%, rgba(212, 175, 55, 0.06), #0a0a0a 55%);
}
.mystic-compass-wrapper {
position: fixed;
inset: 0;
z-index: 9999;
width: 100vw;
height: 100vh;
min-height: -webkit-fill-available;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 48px;
background: #0a0a0a;
overflow: hidden;
}
.compass-back-btn {
position: absolute;
top: 24px;
left: 24px;
top: calc(24px + env(safe-area-inset-top, 0px));
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(212, 175, 55, 0.1);
border: 1px solid rgba(212, 175, 55, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
cursor: pointer;
transition: all 0.3s;
}
.compass-back-btn:active {
background: rgba(212, 175, 55, 0.2);
transform: scale(0.95);
}
.compass-back-icon {
font-size: 32px;
color: #d4af37;
font-weight: 300;
line-height: 1;
}
.mystic-compass {
position: relative;
width: min(300px, 80vw);
height: min(300px, 80vw);
display: flex;
align-items: center;
justify-content: center;
min-width: 260px;
min-height: 260px;
}
.compass-glow {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #d4af37;
filter: blur(30px);
opacity: 0.15;
animation: pulse 4s ease-in-out infinite;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
@keyframes pulse {
0%,
100% {
opacity: 0.1;
transform: translate(-50%, -50%) scale(0.95);
}
50% {
opacity: 0.3;
transform: translate(-50%, -50%) scale(1.05);
}
}
.compass-svg-wrap {
position: relative;
width: 300px;
height: 300px;
transform: scale(min(1, calc(80vw / 300)));
transform-origin: center center;
}
.circle-outer {
position: absolute;
left: 5px;
top: 5px;
width: 290px;
height: 290px;
border-radius: 50%;
border: 1px dashed #d4af37;
opacity: 0.2;
}
.circle-main {
position: absolute;
left: 10px;
top: 10px;
width: 280px;
height: 280px;
border-radius: 50%;
border: 2px solid #d4af37;
opacity: 0.3;
}
.rune-char {
position: absolute;
font-size: 12px;
color: #5a5a5a;
font-family: SimSun, serif;
transition: all 0.2s;
}
.rune-char.active {
color: #d4af37;
font-weight: bold;
text-shadow: 0 0 5px #d4af37;
}
.bagua-circle {
position: absolute;
left: 60px;
top: 60px;
width: 180px;
height: 180px;
border-radius: 50%;
border: 2px solid rgba(212, 175, 55, 0.1);
transition: transform 0.3s linear;
}
.bagua-char {
position: absolute;
font-size: 24px;
color: #d4af37;
opacity: 0.6;
font-family: SimSun, serif;
}
.compass-pointer {
position: absolute;
left: 150px;
top: 150px;
width: 0;
height: 0;
transition: transform 0.05s linear;
}
.pointer-bar {
position: absolute;
left: 50%;
top: -80px;
width: 2px;
height: 80px;
background-color: #9c2a2a;
transform: translateX(-50%);
}
.pointer-dot {
position: absolute;
left: 50%;
top: 50%;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #d4af37;
transform: translate(-50%, -50%);
}
/* Loading Text */
.loading-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
animation: fade 2.5s ease-in-out infinite;
}
@keyframes fade {
0%,
100% {
opacity: 0.3;
}
50% {
opacity: 1;
}
}
.loading-title {
color: #d4af37;
letter-spacing: 0.4em;
font-size: 20px;
font-weight: bold;
text-shadow: 0 0 10px rgba(212, 175, 55, 0.5);
font-family: SimSun, "Songti SC", serif;
}
.loading-subtitle {
color: #a0a0a0;
letter-spacing: 0.2em;
font-size: 12px;
text-align: center;
flex-wrap: wrap;
padding: 0 20px;
font-family: SimSun, "Songti SC", serif;
}
.loading-tip {
margin-top: 6px;
max-width: min(520px, 86vw);
text-align: center;
color: rgba(226, 226, 226, 0.72);
letter-spacing: 0.06em;
font-size: 11px;
line-height: 1.6;
font-family: SimSun, "Songti SC", serif;
}
</style>

View File

@@ -0,0 +1,576 @@
<template>
<transition name="mystic-date-picker">
<div v-if="isOpen" class="mystic-date-picker-overlay">
<!-- Backdrop -->
<div class="mystic-date-picker-backdrop" @click="handleClose"></div>
<!-- Modal Content -->
<div class="mystic-date-picker-modal">
<!-- Header -->
<div class="mystic-date-picker-header">
<div class="mystic-date-picker-close" @click="handleClose">
<CloseIcon :size="22" class="mystic-date-picker-icon" />
</div>
<span class="mystic-date-picker-title">{{ title }}</span>
<div class="mystic-date-picker-confirm" @click="handleConfirm">
<CheckIcon :size="22" class="mystic-date-picker-icon" />
</div>
</div>
<!-- Picker View -->
<div class="mystic-date-picker-view">
<div class="mystic-date-picker-indicator"></div>
<!-- Year Column -->
<div class="mystic-date-picker-column" @scroll="handleScroll($event, 0)">
<div class="mystic-date-picker-padding"></div>
<div v-for="(y, idx) in years" :key="y" :ref="el => setColumnRef(el, 0, idx)"
class="mystic-date-picker-item" :class="{ active: pickerValue[0] === idx }" @click="selectItem(0, idx)">
<span>{{ y }}</span>
</div>
<div class="mystic-date-picker-padding"></div>
</div>
<!-- Month Column -->
<div class="mystic-date-picker-column" @scroll="handleScroll($event, 1)">
<div class="mystic-date-picker-padding"></div>
<div v-for="(m, idx) in months" :key="idx" :ref="el => setColumnRef(el, 1, idx)"
class="mystic-date-picker-item" :class="{ active: pickerValue[1] === idx }" @click="selectItem(1, idx)">
<span>{{ m }}</span>
</div>
<div class="mystic-date-picker-padding"></div>
</div>
<!-- Day Column -->
<div class="mystic-date-picker-column" @scroll="handleScroll($event, 2)">
<div class="mystic-date-picker-padding"></div>
<div v-for="(d, idx) in days" :key="d.val" :ref="el => setColumnRef(el, 2, idx)"
class="mystic-date-picker-item" :class="{ active: pickerValue[2] === idx }" @click="selectItem(2, idx)">
<span>{{ d.name }}</span>
</div>
<div class="mystic-date-picker-padding"></div>
</div>
<!-- Time Column -->
<div class="mystic-date-picker-column" @scroll="handleScroll($event, 3)">
<div class="mystic-date-picker-padding"></div>
<div v-for="(s, idx) in shichenOptions" :key="s.id" :ref="el => setColumnRef(el, 3, idx)"
class="mystic-date-picker-item mystic-date-picker-item-time" :class="{ active: pickerValue[3] === idx }"
@click="selectItem(3, idx)">
<span class="mystic-date-picker-time-name">{{ s.name }}</span>
<span class="mystic-date-picker-time-detail">{{ s.time }}</span>
</div>
<div class="mystic-date-picker-padding"></div>
</div>
</div>
<!-- Footer Tip -->
<div class="mystic-date-picker-footer">
<span class="mystic-date-picker-tip">{{ footerTip || '滑动列表选择 · 系统自动换算干支' }}</span>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import CloseIcon from './icons/CloseIcon.vue';
import CheckIcon from './icons/CheckIcon.vue';
interface Props {
isOpen: boolean;
title?: string;
defaultValue?: string;
/** 年份列下界(公历,含)。与 maxYear 同时用于自定义区间(如择吉期望范围可选至未来多年) */
minYear?: number;
/** 年份列上界(公历,含) */
maxYear?: number;
/** 底部提示,便于与其它场景日期选择区分 */
footerTip?: string;
/** 为 true 时公历日期不可晚于「今天」(用于择吉期望开始日等) */
capAtToday?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
title: '请择良辰',
defaultValue: '',
minYear: undefined,
maxYear: undefined,
footerTip: '',
capAtToday: false,
});
const emit = defineEmits<{
close: [];
confirm: [val: string, apiVal: string];
}>();
// 时辰对照表
const SHI_CHEN = [
{ id: 'zi', name: '子时', time: '23:00-00:59', hour: '00:00:00' },
{ id: 'chou', name: '丑时', time: '01:00-02:59', hour: '02:00:00' },
{ id: 'yin', name: '寅时', time: '03:00-04:59', hour: '04:00:00' },
{ id: 'mao', name: '卯时', time: '05:00-06:59', hour: '06:00:00' },
{ id: 'chen', name: '辰时', time: '07:00-08:59', hour: '08:00:00' },
{ id: 'si', name: '巳时', time: '09:00-10:59', hour: '10:00:00' },
{ id: 'wu', name: '午时', time: '11:00-12:59', hour: '12:00:00' },
{ id: 'wei', name: '未时', time: '13:00-14:59', hour: '14:00:00' },
{ id: 'shen', name: '申时', time: '15:00-16:59', hour: '16:00:00' },
{ id: 'you', name: '酉时', time: '17:00-18:59', hour: '18:00:00' },
{ id: 'xu', name: '戌时', time: '19:00-20:59', hour: '20:00:00' },
{ id: 'hai', name: '亥时', time: '21:00-22:59', hour: '22:00:00' },
];
const CH_NUM = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
const MONTHS = ['正月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '冬月', '腊月'];
/**
* 年份列(降序):默认从当年起共 86 年(生辰等场景,不可超过当年);
* 若传入 minYear/maxYear 则按该区间生成(用于择吉期望日期等可选未来多年)。
*/
const years = computed(() => {
const nowY = new Date().getFullYear();
const useCustom = props.minYear !== undefined || props.maxYear !== undefined;
if (!useCustom) {
const maxY = nowY;
const minY = maxY - 85;
return Array.from({ length: 86 }, (_, i) => maxY - i);
}
const maxY = props.maxYear ?? nowY;
const minY = props.minYear ?? maxY - 85;
const hi = Math.max(minY, maxY);
const lo = Math.min(minY, maxY);
return Array.from({ length: hi - lo + 1 }, (_, i) => hi - i);
});
const months = MONTHS;
const shichenOptions = SHI_CHEN;
const pickerValue = ref([0, 0, 0, 0]);
const columnRefs = ref<Array<Array<HTMLElement | null>>>([[], [], [], []]);
const ITEM_HEIGHT = 50;
const setColumnRef = (el: any, columnIdx: number, itemIdx: number) => {
if (el) {
if (!columnRefs.value[columnIdx]) {
columnRefs.value[columnIdx] = [];
}
columnRefs.value[columnIdx][itemIdx] = el;
}
};
const isLeapYear = (year: number): boolean => {
return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
};
const getDaysInMonth = (year: number, month: number): number => {
const daysMap = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
if (month === 1 && isLeapYear(year)) {
return 29;
}
return daysMap[month];
};
const getDayName = (d: number): string => {
if (d <= 10) return `${CH_NUM[d]}`;
if (d === 20) return '二十';
if (d === 30) return '三十';
if (d < 20) return `${CH_NUM[d - 10]}`;
if (d < 30) return `廿${CH_NUM[d - 20]}`;
return `三十${CH_NUM[d - 30]}`;
};
const days = computed(() => {
const [yearIdx, monthIdx] = pickerValue.value;
const year = years.value[yearIdx] || 2000;
const daysCount = getDaysInMonth(year, monthIdx);
return Array.from({ length: daysCount }, (_, i) => ({
val: i + 1,
name: getDayName(i + 1)
}));
});
const scrollToIndex = (columnIdx: number, index: number, smooth = true) => {
const column = document.querySelectorAll('.mystic-date-picker-column')[columnIdx] as HTMLElement;
if (column) {
const scrollTop = index * ITEM_HEIGHT;
column.scrollTo({
top: scrollTop,
behavior: smooth ? 'smooth' : 'auto'
});
}
};
const selectItem = (columnIdx: number, index: number) => {
let newValue = [...pickerValue.value];
newValue[columnIdx] = index;
// 检查日期是否超出当月天数
if (columnIdx === 0 || columnIdx === 1) {
const [yearIdx, monthIdx] = newValue;
const year = years.value[yearIdx] || 2000;
const maxDays = getDaysInMonth(year, monthIdx);
if (newValue[2] >= maxDays) {
newValue[2] = maxDays - 1;
}
}
newValue = clampPickerValueToTodayMax(newValue, years.value);
pickerValue.value = newValue;
nextTick(() => {
pickerValue.value.forEach((val, idx) => {
scrollToIndex(idx, val, false);
});
});
};
let scrollTimer: number | null = null;
const handleScroll = (e: Event, columnIdx: number) => {
if (scrollTimer) {
clearTimeout(scrollTimer);
}
scrollTimer = window.setTimeout(() => {
const target = e.target as HTMLElement;
const scrollTop = target.scrollTop;
const index = Math.round(scrollTop / ITEM_HEIGHT);
selectItem(columnIdx, index);
}, 150);
};
const parseDefaultValue = (val: string, yearList: number[]) => {
if (!val) return null;
const yearMatch = val.match(/(\d+)年/);
const monthMatch = val.match(/年(.+?)(?:初|十|廿|三十)/);
const shichenMatch = val.match(/(子|丑|寅|卯|辰|巳|午|未|申|酉|戌|亥)时/);
if (!yearMatch) return null;
const year = parseInt(yearMatch[1]);
const yearIdx = yearList.findIndex(y => y === year);
let monthIdx = 0;
if (monthMatch) {
monthIdx = MONTHS.findIndex(m => m === monthMatch[1]);
if (monthIdx < 0) monthIdx = 0;
}
let dayIdx = 0;
const dayPart = val.replace(/\d+年/, '').replace(/.*月/, '').replace(/(子|丑|寅|卯|辰|巳|午|未|申|酉|戌|亥)时/, '');
if (dayPart) {
for (let i = 1; i <= 31; i++) {
if (getDayName(i) === dayPart) {
dayIdx = i - 1;
break;
}
}
}
let shichenIdx = 0;
if (shichenMatch) {
shichenIdx = SHI_CHEN.findIndex(s => s.name.startsWith(shichenMatch[1]));
if (shichenIdx < 0) shichenIdx = 0;
}
return {
yearIdx: yearIdx >= 0 ? yearIdx : 0,
monthIdx,
dayIdx,
shichenIdx
};
};
const clampPickerDay = (parts: number[], yearList: number[]): number[] => {
if (!yearList.length) return [0, 0, 0, 0];
const yearIdx = Math.min(Math.max(0, parts[0]), yearList.length - 1);
const monthIdx = Math.min(Math.max(0, parts[1]), 11);
const y = yearList[yearIdx] ?? new Date().getFullYear();
const maxD = getDaysInMonth(y, monthIdx);
const dayIdx = Math.min(Math.max(0, parts[2]), maxD - 1);
const shichenIdx = Math.min(Math.max(0, parts[3]), SHI_CHEN.length - 1);
return [yearIdx, monthIdx, dayIdx, shichenIdx];
};
const getTodayCalendar = () => {
const n = new Date();
return { y: n.getFullYear(), m: n.getMonth(), d: n.getDate() };
};
/** capAtToday将公历日期限制在「今天」及之前 */
const clampPickerValueToTodayMax = (parts: number[], yearList: number[]): number[] => {
let next = clampPickerDay(parts, yearList);
if (!props.capAtToday || !yearList.length) return next;
const t = getTodayCalendar();
let [yi, mi, di, si] = next;
const sy = yearList[yi];
if (sy > t.y) {
const ti = yearList.findIndex((yr) => yr === t.y);
if (ti >= 0) next = clampPickerDay([ti, t.m, t.d - 1, si], yearList);
return next;
}
if (sy < t.y) return next;
if (mi > t.m) {
next = clampPickerDay([yi, t.m, t.d - 1, si], yearList);
return next;
}
if (mi < t.m) return next;
if (di > t.d - 1) {
next = clampPickerDay([yi, mi, t.d - 1, si], yearList);
}
return next;
};
watch(() => props.isOpen, (newVal) => {
if (newVal) {
const ylist = years.value;
const parsed = parseDefaultValue(props.defaultValue, ylist);
if (parsed) {
const yi = Math.min(Math.max(0, parsed.yearIdx), Math.max(0, ylist.length - 1));
pickerValue.value = clampPickerValueToTodayMax(
[yi, parsed.monthIdx, parsed.dayIdx, parsed.shichenIdx],
ylist
);
} else {
const now = new Date();
const currentYear = now.getFullYear();
const yearIdx = ylist.findIndex(y => y === currentYear);
const monthIdx = now.getMonth();
const dayIdx = now.getDate() - 1;
const y = yearIdx >= 0 ? ylist[yearIdx] : ylist[0];
const pickY = yearIdx >= 0 ? yearIdx : 0;
pickerValue.value = clampPickerValueToTodayMax(
[
pickY,
monthIdx,
Math.min(dayIdx, getDaysInMonth(y, monthIdx) - 1),
0,
],
ylist
);
}
nextTick(() => {
pickerValue.value.forEach((val, idx) => {
scrollToIndex(idx, val, false);
});
});
}
});
const handleClose = () => {
emit('close');
};
const handleConfirm = () => {
const ylist = years.value;
pickerValue.value = clampPickerValueToTodayMax(pickerValue.value, ylist);
const [yearIdx, monthIdx, dayIdx, shichenIdx] = pickerValue.value;
const selectedYear = years.value[yearIdx] ?? years.value[0];
const selectedMonth = MONTHS[monthIdx];
const selectedDay = days.value[dayIdx]?.name || getDayName(dayIdx + 1);
const selectedShichen = shichenOptions[shichenIdx].name;
const displayStr = `${selectedYear}${selectedMonth}${selectedDay}${selectedShichen}`;
const month = String(monthIdx + 1).padStart(2, '0');
const day = String(dayIdx + 1).padStart(2, '0');
const hour = shichenOptions[shichenIdx].hour;
const apiStr = `${selectedYear}-${month}-${day} ${hour}`;
emit('confirm', displayStr, apiStr);
handleClose();
};
</script>
<style scoped>
.mystic-date-picker-overlay {
position: fixed;
inset: 0;
z-index: 999;
display: flex;
align-items: flex-end;
justify-content: center;
}
.mystic-date-picker-backdrop {
position: absolute;
inset: 0;
background: rgba(26, 26, 26, 0.6);
backdrop-filter: blur(4px);
}
.mystic-date-picker-modal {
position: relative;
width: 100%;
background: #fcfaf5;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
overflow: hidden;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.25);
border-top: 4px solid #8b2323;
}
.mystic-date-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #eaddcf;
background: #f9f7f2;
}
.mystic-date-picker-close,
.mystic-date-picker-confirm {
padding: 8px;
color: #5a5a5a;
cursor: pointer;
transition: opacity 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
.mystic-date-picker-close:active,
.mystic-date-picker-confirm:active {
opacity: 0.6;
}
.mystic-date-picker-confirm {
color: #8b2323;
}
.mystic-date-picker-icon {
display: block;
}
.mystic-date-picker-title {
font-size: 18px;
font-weight: bold;
color: #2c2c2c;
letter-spacing: 0.3em;
font-family: SimSun, "Songti SC", serif;
}
.mystic-date-picker-view {
height: 300px;
width: 100%;
display: flex;
position: relative;
overflow: hidden;
}
.mystic-date-picker-indicator {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 50px;
transform: translateY(-50%);
border-top: 1px solid #dcd3c9;
border-bottom: 1px solid #dcd3c9;
background: rgba(139, 35, 35, 0.02);
pointer-events: none;
z-index: 1;
}
.mystic-date-picker-column {
flex: 1;
height: 100%;
overflow-y: auto;
scroll-snap-type: y mandatory;
-webkit-overflow-scrolling: touch;
}
.mystic-date-picker-column::-webkit-scrollbar {
display: none;
}
.mystic-date-picker-padding {
height: 125px;
flex-shrink: 0;
}
.mystic-date-picker-item {
height: 50px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 14px;
color: #8a8a8a;
font-family: SimSun, "Songti SC", serif;
cursor: pointer;
transition: all 0.3s;
scroll-snap-align: center;
flex-shrink: 0;
}
.mystic-date-picker-item.active {
color: #2c2c2c;
font-weight: bold;
font-size: 16px;
}
.mystic-date-picker-item-time {
gap: 2px;
}
.mystic-date-picker-time-name {
font-weight: bold;
font-size: 14px;
}
.mystic-date-picker-item.active .mystic-date-picker-time-name {
font-size: 16px;
}
.mystic-date-picker-time-detail {
font-size: 10px;
opacity: 0.6;
color: #8a8a8a;
}
.mystic-date-picker-footer {
background: #f9f7f2;
padding: 12px;
text-align: center;
border-top: 1px solid #eaddcf;
}
.mystic-date-picker-tip {
font-size: 11px;
color: #8a8a8a;
letter-spacing: 0.1em;
}
/* Transition */
.mystic-date-picker-enter-active,
.mystic-date-picker-leave-active {
transition: opacity 0.3s;
}
.mystic-date-picker-enter-active .mystic-date-picker-modal,
.mystic-date-picker-leave-active .mystic-date-picker-modal {
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.mystic-date-picker-enter-from,
.mystic-date-picker-leave-to {
opacity: 0;
}
.mystic-date-picker-enter-from .mystic-date-picker-modal,
.mystic-date-picker-leave-to .mystic-date-picker-modal {
transform: translateY(100%);
}
</style>

View File

@@ -0,0 +1,487 @@
<template>
<view class="mfgb" aria-hidden="true">
<view class="mfgb__base" />
<!-- 暗纹万字锦地极低透明度 -->
<view class="mfgb__wanzi" />
<view class="mfgb__ink" />
<view
v-for="c in clouds"
:key="c.id"
class="mfgb__cloud"
:style="getCloudStyle(c)"
/>
<!-- 八卦 + 太极先天卦序极慢旋转 -->
<view class="mfgb__bagua-wrap">
<svg
class="mfgb__bagua-svg mfgb__bagua-spin"
viewBox="-100 -100 200 200"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient id="mfgb-gold-stroke" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color: rgba(212, 175, 55, 0.55)" />
<stop offset="100%" style="stop-color: rgba(212, 175, 55, 0.2)" />
</linearGradient>
</defs>
<circle
cx="0"
cy="0"
r="90"
fill="none"
stroke="url(#mfgb-gold-stroke)"
stroke-width="0.6"
opacity="0.35"
/>
<circle
cx="0"
cy="0"
r="78"
fill="none"
stroke="rgba(212, 175, 55, 0.12)"
stroke-width="0.4"
stroke-dasharray="4 6"
opacity="0.5"
/>
<g
v-for="(trig, ti) in baguaTrigrams"
:key="'bg-' + ti"
:transform="'rotate(' + (ti * 45 - 90) + ') translate(0 -72)'"
>
<g v-for="(solid, li) in trig" :key="'ln-' + ti + '-' + li">
<line
v-if="solid"
x1="-11"
:y1="8 - li * 8"
x2="11"
:y2="8 - li * 8"
stroke="rgba(212, 175, 55, 0.42)"
stroke-width="2.4"
stroke-linecap="round"
/>
<g v-else>
<line
x1="-11"
:y1="8 - li * 8"
x2="-3.5"
:y2="8 - li * 8"
stroke="rgba(212, 175, 55, 0.42)"
stroke-width="2.4"
stroke-linecap="round"
/>
<line
x1="3.5"
:y1="8 - li * 8"
x2="11"
:y2="8 - li * 8"
stroke="rgba(212, 175, 55, 0.42)"
stroke-width="2.4"
stroke-linecap="round"
/>
</g>
</g>
</g>
<!-- 太极简形阴阳鱼 -->
<circle cx="0" cy="0" r="20" fill="none" stroke="rgba(212, 175, 55, 0.28)" stroke-width="0.8" />
<path
d="M0-20 A20 20 0 0 1 0 20 A10 10 0 0 1 0 0 A10 10 0 0 0 0-20 Z"
fill="rgba(212, 175, 55, 0.08)"
/>
<path
d="M0 20 A20 20 0 0 1 0-20 A10 10 0 0 1 0 0 A10 10 0 0 0 0 20 Z"
fill="rgba(15, 23, 42, 0.45)"
/>
<circle cx="0" cy="-10" r="3.2" fill="rgba(15, 23, 42, 0.75)" />
<circle cx="0" cy="10" r="3.2" fill="rgba(212, 175, 55, 0.4)" />
</svg>
</view>
<!-- 四角卷云纹 -->
<view
v-for="corner in cornerKeys"
:key="corner"
class="mfgb__corner"
:class="'mfgb__corner--' + corner"
>
<svg class="mfgb__corner-svg" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path
class="mfgb__corner-path"
d="M6 58 C6 14 14 6 58 6 M10 54 C10 22 22 10 54 10 M16 48 C18 28 28 18 48 16"
fill="none"
stroke="rgba(212, 175, 55, 0.22)"
stroke-width="1.2"
stroke-linecap="round"
/>
<path
class="mfgb__corner-path"
d="M52 12 Q40 12 36 20 Q32 28 38 36"
fill="none"
stroke="rgba(212, 175, 55, 0.16)"
stroke-width="0.9"
stroke-linecap="round"
/>
</svg>
</view>
<view
class="mfgb__sigil mfgb__sigil--outer"
:class="sigilModifier"
/>
<view
class="mfgb__sigil mfgb__sigil--inner"
:class="sigilModifier"
/>
<view v-for="s in stars" :key="s.id" class="mfgb__star" :style="getStarStyle(s)" />
<view class="mfgb__glow" />
<view class="mfgb__mist" />
<view class="mfgb__vignette" />
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue';
export type MysticRoundPhase = 'ready' | 'loading' | 'result';
const props = withDefaults(
defineProps<{
/** 测名页:测算中加快阵法转速;登录页可省略(默认 ready */
roundPhase?: MysticRoundPhase;
}>(),
{ roundPhase: 'ready' },
);
const sigilModifier = computed(() => {
if (props.roundPhase === 'loading') return 'mfgb__sigil--phase-loading';
if (props.roundPhase === 'result') return 'mfgb__sigil--phase-result';
return '';
});
/** 先天八卦卦序(自下而上爻):乾 兑 离 震 巽 坎 艮 坤 */
const baguaTrigrams: boolean[][] = [
[true, true, true],
[true, true, false],
[true, false, true],
[true, false, false],
[false, true, true],
[false, true, false],
[false, false, true],
[false, false, false],
];
const cornerKeys = ['tl', 'tr', 'bl', 'br'] as const;
const stars = Array.from({ length: 36 }, (_, i) => ({
id: `mfgb-st-${i}`,
top: Math.random() * 100,
left: Math.random() * 100,
size: Math.random() * 2.2 + 0.8,
opacity: Math.random() * 0.35 + 0.12,
duration: Math.random() * 4 + 3,
delay: Math.random() * 4,
}));
const clouds = Array.from({ length: 7 }, (_, i) => ({
id: `mfgb-cl-${i}`,
top: Math.random() * 70 - 10,
left: Math.random() * 90 - 5,
w: 180 + Math.random() * 220,
h: 60 + Math.random() * 80,
opacity: 0.08 + Math.random() * 0.12,
duration: 28 + Math.random() * 18,
delay: Math.random() * -40,
}));
const getStarStyle = (s: (typeof stars)[number]) => ({
top: `${s.top}%`,
left: `${s.left}%`,
width: `${s.size}px`,
height: `${s.size}px`,
opacity: s.opacity,
animationDuration: `${s.duration}s`,
animationDelay: `${s.delay}s`,
});
const getCloudStyle = (c: (typeof clouds)[number]) => ({
top: `${c.top}%`,
left: `${c.left}%`,
width: `${c.w}px`,
height: `${c.h}px`,
opacity: c.opacity,
animationDuration: `${c.duration}s`,
animationDelay: `${c.delay}s`,
});
</script>
<style scoped>
.mfgb {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.mfgb__base {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 120% 80% at 50% -20%, rgba(212, 175, 55, 0.14), transparent 52%),
radial-gradient(ellipse 90% 60% at 80% 20%, rgba(88, 28, 135, 0.18), transparent 45%),
radial-gradient(ellipse 70% 50% at 10% 30%, rgba(127, 29, 29, 0.12), transparent 50%),
linear-gradient(165deg, #070a12 0%, #12102a 38%, #0a1628 72%, #05080f 100%);
}
/* 万字锦地暗纹 */
.mfgb__wanzi {
position: absolute;
inset: 0;
opacity: 0.04;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='48' viewBox='0 0 48 48'%3E%3Cpath fill='none' stroke='%23d4af37' stroke-width='0.6' d='M24 4v40M4 24h40M12 12l24 24M36 12L12 36'/%3E%3C/svg%3E");
background-size: 48px 48px;
}
.mfgb__ink {
position: absolute;
left: 0;
right: 0;
bottom: -5%;
height: 42%;
opacity: 0.35;
background:
radial-gradient(ellipse 100% 55% at 20% 100%, rgba(15, 23, 42, 0.95), transparent 70%),
radial-gradient(ellipse 90% 50% at 55% 100%, rgba(30, 41, 59, 0.85), transparent 68%),
radial-gradient(ellipse 80% 45% at 85% 100%, rgba(15, 23, 42, 0.9), transparent 65%);
filter: blur(1px);
mask-image: linear-gradient(to top, black 0%, black 55%, transparent 100%);
}
.mfgb__cloud {
position: absolute;
border-radius: 50%;
background: radial-gradient(ellipse at center, rgba(212, 175, 55, 0.2) 0%, rgba(212, 175, 55, 0.06) 45%, transparent 70%);
filter: blur(18px);
animation: mfgb-cloud-drift linear infinite;
}
@keyframes mfgb-cloud-drift {
0% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(-2%, 1.5%) scale(1.03);
}
100% {
transform: translate(0, 0) scale(1);
}
}
/* 八卦层:置于祥云之下、阵环之上,略透明 */
.mfgb__bagua-wrap {
position: absolute;
left: 50%;
top: 52%;
width: min(92vw, 680px);
height: min(92vw, 680px);
transform: translate(-50%, -50%);
opacity: 0.38;
z-index: 1;
}
.mfgb__bagua-svg {
width: 100%;
height: 100%;
display: block;
overflow: visible;
}
.mfgb__bagua-spin {
animation: mfgb-bagua-spin 200s linear infinite;
transform-origin: center center;
}
@keyframes mfgb-bagua-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.mfgb__corner {
position: absolute;
width: 72px;
height: 72px;
z-index: 1;
opacity: 0.85;
}
.mfgb__corner-svg {
width: 100%;
height: 100%;
display: block;
}
.mfgb__corner--tl {
top: calc(12px + env(safe-area-inset-top, 0px));
left: 12px;
}
.mfgb__corner--tr {
top: calc(12px + env(safe-area-inset-top, 0px));
right: 12px;
transform: scaleX(-1);
}
.mfgb__corner--bl {
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
left: 12px;
transform: scaleY(-1);
}
.mfgb__corner--br {
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
right: 12px;
transform: scale(-1);
}
.mfgb__sigil {
position: absolute;
left: 50%;
top: 54%;
border-radius: 50%;
transform: translate(-50%, -50%);
border: 1px dashed rgba(212, 175, 55, 0.28);
box-shadow:
0 0 40px rgba(212, 175, 55, 0.08),
inset 0 0 30px rgba(212, 175, 55, 0.04);
opacity: 0.65;
z-index: 2;
}
.mfgb__sigil--outer {
width: min(72vw, 560px);
height: min(72vw, 560px);
animation: mfgb-sigil-cw 48s linear infinite;
}
.mfgb__sigil--inner {
width: min(52vw, 400px);
height: min(52vw, 400px);
border-style: dotted;
border-color: rgba(212, 175, 55, 0.2);
animation: mfgb-sigil-ccw 32s linear infinite;
}
.mfgb__sigil--outer.mfgb__sigil--phase-loading {
animation-duration: 2.8s;
border-color: rgba(212, 175, 55, 0.55);
opacity: 0.85;
box-shadow:
0 0 56px rgba(212, 175, 55, 0.15),
inset 0 0 36px rgba(212, 175, 55, 0.08);
}
.mfgb__sigil--inner.mfgb__sigil--phase-loading {
animation-duration: 2s;
border-color: rgba(212, 175, 55, 0.42);
opacity: 0.88;
}
.mfgb__sigil--outer.mfgb__sigil--phase-result {
animation-duration: 5.5s;
border-color: rgba(212, 175, 55, 0.35);
opacity: 0.62;
}
.mfgb__sigil--inner.mfgb__sigil--phase-result {
animation-duration: 4s;
border-color: rgba(212, 175, 55, 0.22);
opacity: 0.58;
}
@keyframes mfgb-sigil-cw {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
@keyframes mfgb-sigil-ccw {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(-360deg);
}
}
.mfgb__star {
position: absolute;
border-radius: 50%;
background: rgba(255, 248, 220, 0.95);
box-shadow: 0 0 12px rgba(212, 175, 55, 0.45), 0 0 2px rgba(255, 255, 255, 0.8);
animation: mfgb-twinkle 2.8s ease-in-out infinite;
z-index: 3;
}
@keyframes mfgb-twinkle {
0%,
100% {
transform: scale(0.9);
opacity: 0.2;
}
50% {
transform: scale(1.2);
opacity: 0.65;
}
}
.mfgb__glow {
position: absolute;
left: 50%;
top: 48%;
width: min(90vw, 680px);
height: min(90vw, 680px);
transform: translate(-50%, -50%);
background: radial-gradient(circle at 50% 35%, rgba(212, 175, 55, 0.22), rgba(139, 92, 246, 0.06) 45%, transparent 65%);
filter: blur(36px);
opacity: 0.9;
animation: mfgb-glow-pulse 4.2s ease-in-out infinite;
z-index: 2;
}
@keyframes mfgb-glow-pulse {
0%,
100% {
transform: translate(-50%, -50%) scale(0.94);
opacity: 0.7;
}
50% {
transform: translate(-50%, -50%) scale(1.06);
opacity: 1;
}
}
.mfgb__mist {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 38%;
background: linear-gradient(to top, rgba(2, 6, 23, 0.75), rgba(2, 6, 23, 0));
pointer-events: none;
z-index: 4;
}
.mfgb__vignette {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 75% 65% at 50% 45%, transparent 30%, rgba(0, 0, 0, 0.45) 100%);
pointer-events: none;
z-index: 5;
}
</style>

View File

@@ -0,0 +1,317 @@
<template>
<transition name="mystic-loading">
<div v-if="isOpen" class="mystic-loading-overlay">
<!-- 背景 -->
<div class="mystic-loading-bg"></div>
<!-- 主要内容 -->
<div class="mystic-loading-content">
<!-- 八卦罗盘 -->
<div class="mystic-compass">
<!-- 外圈 - 八卦符号 -->
<div class="mystic-compass-outer">
<svg class="mystic-compass-bagua" viewBox="0 0 200 200">
<!-- 八卦符号 -->
<g class="bagua-symbol" v-for="(gua, i) in bagua" :key="i"
:transform="`rotate(${i * 45} 100 100)`">
<text x="100" y="30" text-anchor="middle" class="bagua-text">{{ gua }}</text>
</g>
</svg>
</div>
<!-- 中圈 - 天干地支 -->
<div class="mystic-compass-middle">
<svg class="mystic-compass-tiangan" viewBox="0 0 200 200">
<g class="tiangan-symbol" v-for="(tg, i) in tiangan" :key="i"
:transform="`rotate(${i * 36} 100 100)`">
<text x="100" y="50" text-anchor="middle" class="tiangan-text">{{ tg }}</text>
</g>
</svg>
</div>
<!-- 内圈 - 太极图 -->
<div class="mystic-compass-inner">
<svg class="mystic-taiji" viewBox="0 0 100 100">
<defs>
<clipPath id="yin">
<path d="M 50 0 A 50 50 0 0 1 50 100 A 25 25 0 0 1 50 50 A 25 25 0 0 0 50 0 Z" />
</clipPath>
<clipPath id="yang">
<path d="M 50 0 A 50 50 0 0 0 50 100 A 25 25 0 0 0 50 50 A 25 25 0 0 1 50 0 Z" />
</clipPath>
</defs>
<!-- -->
<circle cx="50" cy="50" r="50" fill="#2c2c2c" clip-path="url(#yin)" />
<circle cx="50" cy="75" r="5" fill="#fdfbf7" />
<!-- -->
<circle cx="50" cy="50" r="50" fill="#fdfbf7" clip-path="url(#yang)" />
<circle cx="50" cy="25" r="5" fill="#2c2c2c" />
</svg>
</div>
</div>
<!-- 加载文字 -->
<div class="mystic-loading-text">
<div class="mystic-loading-title">{{ title }}</div>
<div class="mystic-loading-subtitle">{{ subtitle }}</div>
<div class="mystic-loading-tip">分析时间预计1-2分钟可点击返回按钮退出并在我的方案中查看结果</div>
</div>
<!-- 进度提示 -->
<div class="mystic-loading-dots">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
interface Props {
isOpen: boolean;
title?: string;
subtitle?: string;
}
withDefaults(defineProps<Props>(), {
title: '正在推演命盘',
subtitle: '易经数理 · 五行生克 · 三才五格'
});
// 八卦符号
const bagua = ['☰', '☱', '☲', '☳', '☴', '☵', '☶', '☷'];
// 天干
const tiangan = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'];
</script>
<style scoped>
.mystic-loading-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: #0a0a0a;
}
.mystic-loading-bg {
position: absolute;
inset: 0;
background: radial-gradient(circle at center, rgba(139, 35, 35, 0.1) 0%, transparent 70%);
animation: pulse 3s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
.mystic-loading-content {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 40px;
}
/* 罗盘容器 */
.mystic-compass {
position: relative;
width: 280px;
height: 280px;
display: flex;
align-items: center;
justify-content: center;
}
/* 外圈 - 八卦 */
.mystic-compass-outer {
position: absolute;
width: 100%;
height: 100%;
animation: rotate-clockwise 20s linear infinite;
}
.mystic-compass-bagua {
width: 100%;
height: 100%;
filter: drop-shadow(0 0 10px rgba(139, 35, 35, 0.5));
}
.bagua-text {
font-size: 24px;
fill: #8b2323;
font-family: SimSun, "Songti SC", serif;
font-weight: bold;
}
/* 中圈 - 天干 */
.mystic-compass-middle {
position: absolute;
width: 70%;
height: 70%;
animation: rotate-counter-clockwise 15s linear infinite;
}
.mystic-compass-tiangan {
width: 100%;
height: 100%;
filter: drop-shadow(0 0 8px rgba(218, 165, 32, 0.4));
}
.tiangan-text {
font-size: 18px;
fill: #daa520;
font-family: SimSun, "Songti SC", serif;
font-weight: bold;
}
/* 内圈 - 太极 */
.mystic-compass-inner {
position: absolute;
width: 35%;
height: 35%;
animation: rotate-clockwise 10s linear infinite;
filter: drop-shadow(0 0 15px rgba(255, 255, 255, 0.3));
}
.mystic-taiji {
width: 100%;
height: 100%;
}
@keyframes rotate-clockwise {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes rotate-counter-clockwise {
from {
transform: rotate(0deg);
}
to {
transform: rotate(-360deg);
}
}
/* 加载文字 */
.mystic-loading-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.mystic-loading-title {
font-size: 24px;
font-weight: bold;
color: #fdfbf7;
font-family: SimSun, "Songti SC", serif;
letter-spacing: 0.3em;
text-shadow: 0 0 20px rgba(139, 35, 35, 0.8);
}
.mystic-loading-subtitle {
font-size: 14px;
color: #8a8a8a;
font-family: SimSun, "Songti SC", serif;
letter-spacing: 0.2em;
}
.mystic-loading-tip {
margin-top: 10px;
max-width: min(520px, 86vw);
text-align: center;
font-size: 12px;
line-height: 1.6;
color: rgba(226, 226, 226, 0.72);
letter-spacing: 0.06em;
font-family: SimSun, "Songti SC", serif;
}
/* 加载点 */
.mystic-loading-dots {
display: flex;
gap: 8px;
align-items: center;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #8b2323;
animation: dot-pulse 1.4s ease-in-out infinite;
}
.dot:nth-child(1) {
animation-delay: 0s;
}
.dot:nth-child(2) {
animation-delay: 0.2s;
}
.dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes dot-pulse {
0%,
80%,
100% {
opacity: 0.3;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1.2);
}
}
/* 过渡动画 */
.mystic-loading-enter-active,
.mystic-loading-leave-active {
transition: opacity 0.5s;
}
.mystic-loading-enter-from,
.mystic-loading-leave-to {
opacity: 0;
}
.mystic-loading-enter-active .mystic-compass {
animation: compass-enter 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes compass-enter {
from {
transform: scale(0) rotate(-180deg);
opacity: 0;
}
to {
transform: scale(1) rotate(0deg);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,270 @@
<template>
<transition name="mystic-select">
<view v-if="isOpen" class="mystic-select-overlay">
<!-- Backdrop -->
<view class="mystic-select-backdrop" @click="handleClose"></view>
<!-- Modal Content -->
<view class="mystic-select-modal">
<!-- Header -->
<view class="mystic-select-header">
<view class="mystic-select-close" @click="handleClose">
<CloseIcon class="mystic-select-icon" />
</view>
<text class="mystic-select-title">{{ title }}</text>
<view class="mystic-select-confirm" @click="handleConfirm">
<CheckIcon class="mystic-select-icon" />
</view>
</view>
<!-- H5 友好用可滚动列表保证能下滑能点击选中 -->
<scroll-view
scroll-y
class="mystic-select-view"
:scroll-into-view="`opt-${selectedIndex}`"
>
<view
v-for="(item, idx) in options"
:key="item.value"
:id="`opt-${idx}`"
class="mystic-select-item"
:class="{ 'mystic-select-item-active': idx === selectedIndex }"
@click="selectOption(idx)"
>
<view class="mystic-select-item-main">
<text class="mystic-select-item-label">{{ item.label }}</text>
<text v-if="item.desc" class="mystic-select-item-desc">{{ item.desc }}</text>
</view>
<view v-if="idx === selectedIndex" class="mystic-select-check">
<CheckIcon class="mystic-select-check-icon" />
</view>
</view>
</scroll-view>
<!-- Footer Tip -->
<view v-if="tip" class="mystic-select-footer">
<text class="mystic-select-tip">{{ tip }}</text>
</view>
</view>
</view>
</transition>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import CloseIcon from './icons/CloseIcon.vue';
import CheckIcon from './icons/CheckIcon.vue';
export interface SelectOption {
value: string;
label: string;
desc?: string;
}
interface Props {
isOpen: boolean;
title?: string;
tip?: string;
options: SelectOption[];
defaultValue?: string;
}
const props = withDefaults(defineProps<Props>(), {
title: '请选择',
tip: '',
defaultValue: ''
});
const emit = defineEmits<{
close: [];
confirm: [option: SelectOption];
}>();
const selectedIndex = ref(0);
// 当打开时设置默认值
watch(() => props.isOpen, (newVal: boolean) => {
if (newVal) {
// 延迟设置,确保列表渲染完成
setTimeout(() => {
const idx = props.options.findIndex((o: SelectOption) => o.value === props.defaultValue);
selectedIndex.value = idx >= 0 ? idx : 0;
}, 50);
}
}, { immediate: true });
const handleClose = () => {
emit('close');
};
const handleConfirm = () => {
const selectedOption = props.options[selectedIndex.value];
emit('confirm', selectedOption);
handleClose();
};
const selectOption = (idx: number) => {
selectedIndex.value = idx;
};
</script>
<style scoped>
.mystic-select-overlay {
position: fixed;
inset: 0;
z-index: 999;
display: flex;
align-items: flex-end;
justify-content: center;
}
.mystic-select-backdrop {
position: absolute;
inset: 0;
background: rgba(26, 26, 26, 0.6);
backdrop-filter: blur(8rpx);
z-index: 1;
}
.mystic-select-modal {
position: relative;
z-index: 2;
width: 100%;
background: #fcfaf5;
border-top-left-radius: 32rpx;
border-top-right-radius: 32rpx;
overflow: hidden;
box-shadow: 0 -16rpx 64rpx rgba(0, 0, 0, 0.25);
border-top: 8rpx solid #8b2323;
}
.mystic-select-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx 40rpx;
border-bottom: 2rpx solid #eaddcf;
background: #f9f7f2;
}
.mystic-select-close,
.mystic-select-confirm {
padding: 16rpx;
color: #5a5a5a;
cursor: pointer;
transition: opacity 0.3s;
}
.mystic-select-close:active,
.mystic-select-confirm:active {
opacity: 0.6;
}
.mystic-select-confirm {
color: #8b2323;
}
.mystic-select-icon {
width: 44rpx;
height: 44rpx;
display: block;
}
.mystic-select-title {
font-size: 36rpx;
font-weight: bold;
color: #2c2c2c;
letter-spacing: 0.3em;
font-family: SimSun, "Songti SC", serif;
}
.mystic-select-view {
height: 480rpx;
width: 100%;
}
.mystic-select-item {
height: 100rpx;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
font-family: SimSun, "Songti SC", serif;
padding: 0 32rpx;
box-sizing: border-box;
transition: background-color 0.2s ease, border-color 0.2s ease;
border-bottom: 1rpx solid rgba(234, 221, 207, 0.7);
cursor: pointer;
}
.mystic-select-item-main {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4rpx;
min-width: 0;
}
.mystic-select-item-active {
background: rgba(139, 35, 35, 0.06);
border-bottom-color: rgba(139, 35, 35, 0.25);
}
.mystic-select-item-label {
font-size: 32rpx;
font-weight: bold;
color: #2c2c2c;
}
.mystic-select-item-desc {
font-size: 20rpx;
color: #8a8a8a;
}
.mystic-select-check {
width: 56rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.mystic-select-check-icon {
width: 36rpx;
height: 36rpx;
}
.mystic-select-footer {
background: #f9f7f2;
padding: 24rpx;
text-align: center;
border-top: 2rpx solid #eaddcf;
}
.mystic-select-tip {
font-size: 22rpx;
color: #8a8a8a;
letter-spacing: 0.1em;
}
/* Transition */
.mystic-select-enter-active,
.mystic-select-leave-active {
transition: opacity 0.3s;
}
.mystic-select-enter-active .mystic-select-modal,
.mystic-select-leave-active .mystic-select-modal {
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.mystic-select-enter-from,
.mystic-select-leave-to {
opacity: 0;
}
.mystic-select-enter-from .mystic-select-modal,
.mystic-select-leave-to .mystic-select-modal {
transform: translateY(100%);
}
</style>

View File

@@ -0,0 +1,270 @@
<template>
<view v-if="visible" class="pay-modal-overlay" @click="handleClose">
<view class="pay-modal" @click.stop>
<view class="pay-modal-header">
<text class="pay-modal-title">解锁完整报告</text>
<view class="pay-modal-close" @click="handleClose"></view>
</view>
<view class="pay-modal-content">
<view class="pay-modal-preview">
<view class="pay-modal-name">{{ data?.name || '吉名' }}</view>
<view class="pay-modal-pinyin">{{ data?.pinyin || '' }}</view>
<view class="pay-modal-lock-icon">🔒</view>
<text class="pay-modal-lock-text">详细解析已加密</text>
</view>
<view class="pay-modal-features">
<view class="pay-modal-feature">
<text class="pay-modal-feature-icon"></text>
<text class="pay-modal-feature-text">字义与生肖解析</text>
</view>
<view class="pay-modal-feature">
<text class="pay-modal-feature-icon"></text>
<text class="pay-modal-feature-text">三才五格数理分析</text>
</view>
<view class="pay-modal-feature">
<text class="pay-modal-feature-icon"></text>
<text class="pay-modal-feature-text">六维格局与周易卦象</text>
</view>
<view class="pay-modal-feature">
<text class="pay-modal-feature-icon"></text>
<text class="pay-modal-feature-text">开运锦囊与人生运程</text>
</view>
</view>
<view class="pay-modal-price">
<text class="pay-modal-price-label">限时特惠</text>
<text class="pay-modal-price-value">¥9.9</text>
<text class="pay-modal-price-original">¥29.9</text>
</view>
</view>
<view class="pay-modal-actions">
<button class="pay-modal-btn pay-modal-btn-share" open-type="share" @click="handleShare">
<text class="pay-modal-btn-text">分享解锁</text>
</button>
<button class="pay-modal-btn pay-modal-btn-pay" @click="handlePay">
<text class="pay-modal-btn-text">立即解锁</text>
</button>
</view>
<view class="pay-modal-tip">
<text>分享给好友即可免费解锁完整报告</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { GeneratedName } from "./NamingResult.vue";
const props = defineProps<{
visible: boolean;
data: GeneratedName | null;
}>();
const emit = defineEmits<{
close: [];
pay: [data: GeneratedName];
share: [];
unlock: [data: GeneratedName];
}>();
const handleClose = () => {
emit("close");
};
const handlePay = () => {
// 支付成功后直接跳转到详情页
if (props.data) {
emit("pay", props.data);
}
};
const handleShare = () => {
emit("share");
};
</script>
<style scoped>
.pay-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.pay-modal {
width: 85%;
max-width: 600rpx;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-radius: 24rpx;
border: 1px solid rgba(212, 175, 55, 0.3);
overflow: hidden;
}
.pay-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.pay-modal-title {
font-size: 18px;
font-weight: 700;
color: #d4af37;
letter-spacing: 0.1em;
}
.pay-modal-close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
color: #a0a0a0;
font-size: 16px;
}
.pay-modal-content {
padding: 32rpx;
}
.pay-modal-preview {
text-align: center;
padding: 32rpx;
background: rgba(255, 255, 255, 0.05);
border-radius: 16rpx;
margin-bottom: 24rpx;
}
.pay-modal-name {
font-size: 28px;
font-weight: 700;
color: #e2e2e2;
letter-spacing: 0.2em;
margin-bottom: 8rpx;
}
.pay-modal-pinyin {
font-size: 12px;
color: #a0a0a0;
margin-bottom: 24rpx;
}
.pay-modal-lock-icon {
font-size: 32px;
margin-bottom: 12rpx;
}
.pay-modal-lock-text {
font-size: 12px;
color: #5a5a5a;
}
.pay-modal-features {
margin-bottom: 24rpx;
}
.pay-modal-feature {
display: flex;
align-items: center;
padding: 12rpx 0;
}
.pay-modal-feature-icon {
color: #d4af37;
margin-right: 16rpx;
font-size: 12px;
}
.pay-modal-feature-text {
font-size: 13px;
color: #a0a0a0;
}
.pay-modal-price {
display: flex;
align-items: baseline;
justify-content: center;
padding: 24rpx;
background: rgba(212, 175, 55, 0.1);
border-radius: 12rpx;
margin-bottom: 24rpx;
}
.pay-modal-price-label {
font-size: 12px;
color: #d4af37;
margin-right: 16rpx;
}
.pay-modal-price-value {
font-size: 28px;
font-weight: 700;
color: #d4af37;
}
.pay-modal-price-original {
font-size: 14px;
color: #5a5a5a;
text-decoration: line-through;
margin-left: 12rpx;
}
.pay-modal-actions {
display: flex;
gap: 16rpx;
padding: 0 32rpx 32rpx;
}
.pay-modal-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12rpx;
border-radius: 12rpx;
border: none;
font-size: 14px;
}
.pay-modal-btn-share {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #e2e2e2;
}
.pay-modal-btn-pay {
background: linear-gradient(135deg, #d4af37 0%, #8a6e1e 100%);
color: #1a1a2e;
font-weight: 700;
}
.pay-modal-btn-icon {
margin-right: 8rpx;
}
.pay-modal-btn-text {
font-size: 14px;
}
.pay-modal-tip {
text-align: center;
padding: 0 32rpx 32rpx;
}
.pay-modal-tip text {
font-size: 11px;
color: #5a5a5a;
}
</style>

View File

@@ -0,0 +1,292 @@
<template>
<view class="naming-result">
<view class="naming-result-header">
<text class="naming-result-title">甄选吉名</text>
<view class="naming-result-reset" @click="$emit('reset')">
<text>重测</text>
</view>
</view>
<view class="naming-result-list">
<view v-for="item in results" :key="item.id" class="naming-result-item">
<view class="naming-result-item-bg"></view>
<view class="naming-result-item-actions">
<view class="naming-result-action-btn" @click.stop="checkDup(item.id)">查重</view>
<view
class="naming-result-action-btn naming-result-action-btn-heart"
:class="item.isFavorite ? 'naming-result-action-btn-heart-active' : ''"
@click.stop="toggleFav(item.id)"
></view>
</view>
<view class="naming-result-item-content" @click="handleItemClick(item)">
<text class="naming-result-item-name">{{ item.name }}</text>
<text class="naming-result-item-pinyin">{{ item.pinyin }}</text>
</view>
<view class="naming-result-item-tags" @click="handleItemClick(item)">
<text v-for="(t, i) in item.tags" :key="i" class="naming-result-tag">{{ t }}</text>
<text v-if="item.duplicateRate" class="naming-result-tag naming-result-tag-gold">
重名率: {{ item.duplicateRate }}
</text>
</view>
<view class="naming-result-item-divider"></view>
<view class="naming-result-item-details" @click="handleItemClick(item)">
<view class="naming-result-detail-row">
<text class="naming-result-detail-label naming-result-detail-label-primary">寓意</text>
<text class="naming-result-detail-text">{{ item.meaning }}</text>
</view>
<view class="naming-result-detail-row">
<text class="naming-result-detail-label">出处</text>
<text class="naming-result-detail-text naming-result-detail-text-small">{{ item.source }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { userApi } from "@/api";
declare const uni: any;
export interface GeneratedName {
id: string;
name: string;
pinyin: string;
meaning: string;
source: string;
tags: string[];
isFavorite?: boolean;
duplicateRate?: string;
score?: number;
zodiac?: string;
wuxing?: string;
constellation?: string;
}
const props = withDefaults(defineProps<{
data: GeneratedName[];
category?: 'personal' | 'company';
payBusinessId?: number;
payAmount?: number;
}>(), {
category: 'company',
payAmount: 9.9,
});
const emit = defineEmits<{
reset: [];
showDetail: [data: GeneratedName];
}>();
const results = ref<GeneratedName[]>([]);
watch(() => props.data, (newData: GeneratedName[]) => {
if (newData) {
results.value = newData.map((item: GeneratedName) => ({ ...item }));
}
}, { immediate: true });
const toggleFav = async (id: string) => {
const idx = results.value.findIndex((r: GeneratedName) => r.id === id);
if (idx < 0) return;
const solutionId = Number(id);
const prev = !!results.value[idx].isFavorite;
if (!prev) {
const res = await userApi.favoriteSolution({ solution_id: solutionId, category: props.category });
results.value[idx].isFavorite = true;
uni.showToast({ title: res?.msg || '收藏成功', icon: 'success' });
} else {
const res = await userApi.unfavoriteSolution({ solution_id: solutionId });
results.value[idx].isFavorite = false;
uni.showToast({ title: res?.msg || '已取消收藏', icon: 'success' });
}
};
const checkDup = (id: string) => {
const rates = ["低 (<100人)", "中 (~1000人)", "高 (>10000人)"];
const rate = rates[Math.floor(Math.random() * rates.length)];
results.value = results.value.map((r: GeneratedName) =>
r.id === id ? { ...r, duplicateRate: rate } : r
);
};
const handleItemClick = (item: GeneratedName) => {
emit("showDetail", item);
};
</script>
<style scoped>
.naming-result {
padding-bottom: 80rpx;
}
.naming-result-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.naming-result-title {
font-size: 18px;
font-weight: 700;
color: #8b2323;
}
.naming-result-reset {
border: none;
background: transparent;
font-size: 12px;
color: #5a5a5a;
}
.naming-result-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.naming-result-item {
background-color: #f9f7f2;
border-radius: 16rpx;
border: 1px solid #dcd3c9;
padding: 28rpx 24rpx 24rpx;
position: relative;
overflow: hidden;
}
.naming-result-item-bg {
position: absolute;
top: 0;
right: 0;
padding: 8rpx;
opacity: 0.08;
pointer-events: none;
}
.naming-result-item-actions {
position: absolute;
top: 12rpx;
right: 12rpx;
display: flex;
align-items: center;
gap: 8rpx;
z-index: 2;
}
.naming-result-action-btn {
border-radius: 999rpx;
border: 1px solid #dcd3c9;
background: rgba(255, 255, 255, 0.6);
font-size: 12px;
color: #5a5a5a;
padding:7rpx 12rpx;
}
.naming-result-action-btn-heart {
padding: 6rpx;
width: 15px;
height: 15px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.naming-result-action-btn-heart-active {
color: #8b2323;
}
.naming-result-item-content {
padding-right: 80rpx;
}
.naming-result-item-name {
font-size: 22px;
font-weight: 700;
color: #2c2c2c;
margin-bottom: 4rpx;
display: block;
}
.naming-result-item-pinyin {
font-size: 12px;
color: #5a5a5a;
margin-bottom: 8rpx;
display: block;
}
.naming-result-item-tags {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
margin-bottom: 8rpx;
}
.naming-result-tag {
font-size: 10px;
padding: 4rpx 8rpx;
border-radius: 6rpx;
border: 1px solid rgba(139, 35, 35, 0.2);
color: #8b2323;
background: rgba(139, 35, 35, 0.04);
}
.naming-result-tag-gold {
border-color: #d4af37;
color: #d4af37;
background: rgba(212, 175, 55, 0.08);
}
.naming-result-item-divider {
height: 1px;
margin: 12rpx 0;
background-color: #e5e5e5;
}
.naming-result-item-details {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.naming-result-detail-row {
display: flex;
align-items: flex-start;
gap: 12rpx;
}
.naming-result-detail-label {
font-size: 10px;
padding: 4rpx 8rpx;
border-radius: 4rpx;
border: 1px solid #5a5a5a;
color: #5a5a5a;
margin-top: 4rpx;
white-space: nowrap;
}
.naming-result-detail-label-primary {
border-color: #8b2323;
color: #8b2323;
}
.naming-result-detail-text {
font-size: 14px;
color: #2c2c2c;
}
.naming-result-detail-text-small {
font-size: 12px;
color: #5a5a5a;
font-style: italic;
}
</style>

View File

@@ -0,0 +1,313 @@
<template>
<view v-if="visible" class="payment-modal" @click="handleClose">
<view class="payment-modal-content" @click.stop>
<!-- 关闭按钮 -->
<view class="payment-close-btn" @click="handleClose">
<text class="payment-close-icon">×</text>
</view>
<!-- 商品信息 -->
<view class="payment-product">
<view class="payment-product-icon">{{ productIcon }}</view>
<text class="payment-product-name">{{ productName }}</text>
<text class="payment-product-desc">{{ productDesc }}</text>
</view>
<!-- 价格 -->
<view class="payment-price">
<text class="payment-price-symbol">¥</text>
<text class="payment-price-amount">{{ amount }}</text>
</view>
<!-- 支付方式 -->
<view class="payment-method">
<view class="payment-method-item payment-method-active">
<view class="payment-method-left">
<text class="payment-method-icon">💳</text>
<text class="payment-method-label">微信支付</text>
</view>
<view class="payment-method-check"></view>
</view>
</view>
<!-- 支付按钮 -->
<view class="payment-submit-btn" :class="{ 'payment-submit-disabled': paying }" @click="handlePay">
<text class="payment-submit-text">{{ paying ? '支付中...' : '立即支付' }}</text>
</view>
<!-- 提示 -->
<view class="payment-tips">
<text class="payment-tips-text">支付即代表同意服务协议隐私政策</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { wxPay } from "@/utils/payment";
import { isWechatBrowser, payWithWechatJsapiH5 } from "@/utils/wechat-h5-jsapi-pay";
declare const uni: any;
interface Props {
visible: boolean;
productName: string; // 商品名称
productDesc?: string; // 商品描述
productIcon?: string; // 商品图标
amount: number; // 支付金额
businessType: string; // 业务类型
businessId: number; // 业务ID
}
const props = withDefaults(defineProps<Props>(), {
productDesc: '',
productIcon: '📦'
});
const emit = defineEmits<{
close: [];
success: [outTradeNo: string];
fail: [msg: string];
}>();
const paying = ref(false);
// 处理支付:微信 H5 内与财运月度详批一致走 JSAPI其它环境走 uni 支付封装
const handlePay = async () => {
if (paying.value) return;
paying.value = true;
try {
if (typeof window !== 'undefined' && isWechatBrowser()) {
const r = await payWithWechatJsapiH5({
description: props.productDesc || props.productName,
totalAmountYuan: props.amount,
businessType: props.businessType,
businessId: props.businessId,
});
if (r.redirected) {
return;
}
if (r.ok) {
emit('success', r.outTradeNo || '');
handleClose();
} else if (r.msg && r.msg !== 'not_wechat') {
emit('fail', r.msg || '支付失败');
}
return;
}
const result = await wxPay({
description: props.productName,
total_amount: props.amount,
business_type: props.businessType,
business_id: props.businessId
});
if (result.success) {
uni.showToast({ title: '支付成功', icon: 'success' });
emit('success', result.outTradeNo || '');
handleClose();
} else {
emit('fail', (result as any).msg || '支付失败');
}
} catch (error: any) {
console.error('支付失败:', error);
uni.showToast({ title: error.msg || '支付失败', icon: 'none' });
emit('fail', error.msg || '支付失败');
} finally {
paying.value = false;
}
};
// 关闭弹窗
const handleClose = () => {
if (paying.value) {
uni.showToast({ title: '支付进行中,请稍候', icon: 'none' });
return;
}
emit('close');
};
</script>
<style scoped>
.payment-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 9999;
}
.payment-modal-content {
width: 100%;
background-color: #fff;
border-radius: 32rpx 32rpx 0 0;
padding: 48rpx 32rpx;
padding-bottom: calc(48rpx + env(safe-area-inset-bottom, 0px));
position: relative;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.payment-close-btn {
position: absolute;
top: 24rpx;
right: 24rpx;
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
}
.payment-close-icon {
font-size: 48rpx;
color: #999;
line-height: 1;
}
/* 商品信息 */
.payment-product {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
margin-bottom: 48rpx;
}
.payment-product-icon {
font-size: 80rpx;
margin-bottom: 8rpx;
}
.payment-product-name {
font-size: 32rpx;
font-weight: 700;
color: #2c2c2c;
}
.payment-product-desc {
font-size: 24rpx;
color: #999;
}
/* 价格 */
.payment-price {
text-align: center;
margin-bottom: 48rpx;
}
.payment-price-symbol {
font-size: 40rpx;
color: #8b2323;
font-weight: 700;
}
.payment-price-amount {
font-size: 72rpx;
color: #8b2323;
font-weight: 700;
}
/* 支付方式 */
.payment-method {
margin-bottom: 32rpx;
}
.payment-method-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx;
background-color: #f5f5f5;
border-radius: 16rpx;
border: 2rpx solid transparent;
transition: all 0.3s;
}
.payment-method-active {
background-color: rgba(139, 35, 35, 0.05);
border-color: #8b2323;
}
.payment-method-left {
display: flex;
align-items: center;
gap: 16rpx;
}
.payment-method-icon {
font-size: 32rpx;
}
.payment-method-label {
font-size: 28rpx;
color: #2c2c2c;
font-weight: 500;
}
.payment-method-check {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background-color: #8b2323;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
}
/* 支付按钮 */
.payment-submit-btn {
width: 100%;
padding: 28rpx 0;
background-color: #8b2323;
border-radius: 16rpx;
display: flex;
justify-content: center;
margin-bottom: 24rpx;
transition: opacity 0.3s;
}
.payment-submit-btn:active {
opacity: 0.8;
}
.payment-submit-disabled {
opacity: 0.6;
}
.payment-submit-text {
font-size: 32rpx;
font-weight: 700;
color: #f2e6d8;
}
/* 提示 */
.payment-tips {
text-align: center;
}
.payment-tips-text {
font-size: 20rpx;
color: #999;
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<view class="radar-chart">
<!-- 使用普通容器 + SVG 渲染避免 getContext 报错 -->
<view ref="chartRef" class="radar-chart-inner"></view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
// 通过 index.html 全局引入 echarts运行时从 window 上获取
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare const window: any;
const chartRef = ref<HTMLElement | null>(null);
let chart: any = null;
onMounted(() => {
const el = chartRef.value;
if (!el || !window || !window.echarts) return;
const echarts = window.echarts;
// 使用 SVG 渲染器,避免对 canvas.getContext 的依赖
chart = echarts.init(el, undefined, { renderer: 'svg' });
const option: any = {
radar: {
indicator: [
{ name: '事业', max: 100 },
{ name: '财运', max: 100 },
{ name: '健康', max: 100 },
{ name: '家庭', max: 100 },
{ name: '社交', max: 100 },
{ name: '智慧', max: 100 }
],
splitNumber: 4,
shape: 'polygon',
splitLine: {
lineStyle: {
color: 'rgba(255,255,255,0.12)'
}
},
splitArea: {
areaStyle: {
color: ['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.01)']
}
},
axisLine: {
lineStyle: {
color: 'rgba(255,255,255,0.12)'
}
},
axisName: {
color: '#cfd2dc',
fontSize: 10
}
},
series: [
{
type: 'radar',
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(255,193,7,0.9)' },
{ offset: 1, color: 'rgba(255,87,34,0.7)' }
])
},
lineStyle: {
color: 'rgba(255,193,7,0.9)'
},
symbol: 'circle',
symbolSize: 3,
itemStyle: {
color: '#ffc107'
},
data: [
{
value: [88, 92, 86, 80, 75, 90]
}
]
}
]
};
chart.setOption(option);
});
onUnmounted(() => {
if (chart) {
chart.dispose();
chart = null;
}
});
</script>
<style scoped>
.radar-chart {
width: 100%;
height: 260rpx;
}
.radar-chart-inner {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,597 @@
<template>
<view class="share-poster-modal">
<view v-if="visible && !showPosterPreview" class="modal-overlay" @click="handleClose">
<view class="modal-box" @click.stop>
<!-- 关闭按钮 -->
<view class="close-btn" @click="handleClose">
<text class="close-icon">×</text>
</view>
<!-- 海报预览 -->
<view class="poster-preview">
<view class="poster-header">
<text class="poster-symbol"></text>
<text class="poster-title">易凡起名</text>
<text class="poster-subtitle">传承国学智慧 · 赋予美好寓意</text>
</view>
<view class="poster-card">
<view class="service-list">
<view class="service-item" v-for="item in services" :key="item">
<view class="service-dot"></view>
<text class="service-text">{{ item }}</text>
</view>
</view>
<view class="poster-divider"></view>
<view class="qr-area">
<view class="qr-box">
<image v-if="qrImageUrl" :src="qrImageUrl" class="qr-image" mode="aspectFit" />
</view>
<text class="invite-text">邀请码{{ userId }}</text>
</view>
</view>
<text class="poster-footer"> 邀您共探姓名玄机 </text>
</view>
<!-- 按钮 -->
<view class="btn-row">
<button class="btn btn-share" open-type="share">
<text class="btn-label">分享好友</text>
</button>
<view class="btn btn-save" @click="handleSave">
<text class="btn-label">{{ isSaving ? '生成中...' : '保存图片' }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 海报图片预览直接展示 -->
<view v-if="showPosterPreview" class="poster-save-overlay" @click="handleClose">
<view class="poster-save-tip">{{ isSaving ? '海报生成中...' : '海报已生成,可保存到本地' }}</view>
<img v-if="posterDataUrl" :src="posterDataUrl" class="poster-save-image" @click.stop />
<view v-else class="poster-save-loading">海报生成中...</view>
<view class="poster-save-actions" @click.stop>
<view class="poster-save-btn" :class="{ 'poster-save-btn-disabled': !posterDataUrl || isSaving }" @click="handleSave">
{{ isSaving ? '生成中...' : '保存图片' }}
</view>
<view class="poster-save-close" @click="handleClose">关闭</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
// @ts-ignore
import QRCode from '@/utils/qrcode.js';
const props = defineProps<{
visible: boolean;
userId: number;
}>();
const emit = defineEmits<{
close: [];
}>();
const services = ['个人起名', '公司起名', '专业测名', '择吉日'];
const qrModules = ref<number[][]>([]);
const qrImageUrl = ref('');
const isSaving = ref(false);
const posterDataUrl = ref('');
const showPosterPreview = ref(false);
const QR_SIZE = 400;
const POSTER_WIDTH = 300;
const POSTER_HEIGHT = 500;
const POSTER_SCALE = 2;
const qrUrl = computed(() => `https://yfh5.action-ai.cn?invite_id=${encodeURIComponent(String(props.userId || ''))}`);
watch(() => props.visible, async (val) => {
if (val) {
posterDataUrl.value = '';
qrImageUrl.value = '';
showPosterPreview.value = true;
await nextTick();
await preparePoster();
} else {
showPosterPreview.value = false;
}
});
const createCanvas = (width: number, height: number): HTMLCanvasElement => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
};
const generateQrModules = (): number[][] => {
const result = QRCode.generate(qrUrl.value, { errorCorrectionLevel: 'M' });
const modules = result.modules || [];
qrModules.value = modules;
return modules;
};
const drawQrModulesToCanvas = (
ctx: CanvasRenderingContext2D,
modules: number[][],
x: number,
y: number,
size: number
) => {
const count = modules.length;
if (!count) return;
const cell = size / count;
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(x, y, size, size);
ctx.fillStyle = '#000000';
for (let r = 0; r < count; r++) {
for (let c = 0; c < count; c++) {
if (modules[r][c]) ctx.fillRect(x + c * cell, y + r * cell, Math.ceil(cell), Math.ceil(cell));
}
}
};
const generateQrImage = async (): Promise<string> => {
qrImageUrl.value = '';
const modules = generateQrModules();
if (!modules.length) {
throw new Error('qrcode modules empty');
}
const canvas = createCanvas(QR_SIZE, QR_SIZE);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('qrcode canvas context unavailable');
}
drawQrModulesToCanvas(ctx, modules, 0, 0, QR_SIZE);
qrImageUrl.value = canvas.toDataURL('image/png');
return qrImageUrl.value;
};
const handleClose = () => {
showPosterPreview.value = false;
emit('close');
};
// H5专用loading/toast
const showLoading = (title: string) => {
if (typeof uni?.showLoading === 'function') {
uni.showLoading({ title });
}
};
const hideLoading = () => {
if (typeof uni?.hideLoading === 'function') {
uni.hideLoading();
}
};
const showToast = (opts: { title: string; icon?: string }) => {
if (typeof uni?.showToast === 'function') {
uni.showToast(opts);
} else {
alert(opts.title);
}
};
// 绘制圆角矩形
const drawRoundRect = (ctx: any, x: number, y: number, w: number, h: number, r: number) => {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
};
const drawImage = (
ctx: CanvasRenderingContext2D,
src: string,
x: number,
y: number,
w: number,
h: number
) => {
return new Promise<void>((resolve, reject) => {
const img = new Image();
img.onload = () => {
ctx.drawImage(img, x, y, w, h);
resolve();
};
img.onerror = () => reject(new Error('image load failed'));
img.src = src;
});
};
const triggerDownload = (src: string, fileName: string) => {
try {
const link = document.createElement('a');
if (typeof link.download !== 'string') {
return false;
}
link.href = src;
link.download = fileName;
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
return true;
} catch (e) {
return false;
}
};
const buildPosterDataUrl = async (qrSrc: string) => {
const width = POSTER_WIDTH * POSTER_SCALE;
const height = POSTER_HEIGHT * POSTER_SCALE;
const posterCanvas = createCanvas(width, height);
const ctx = posterCanvas.getContext('2d');
if (!ctx) {
throw new Error('poster canvas context unavailable');
}
ctx.scale(POSTER_SCALE, POSTER_SCALE);
const w = POSTER_WIDTH;
const h = POSTER_HEIGHT;
// 1. 背景
ctx.fillStyle = '#2d1515';
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = '#d4af37';
ctx.fillRect(0, 0, w, 5);
// 2. 标题区
ctx.textAlign = 'center';
ctx.fillStyle = '#d4af37';
ctx.font = 'bold 28px serif';
ctx.fillText('☯', w / 2, 45);
ctx.fillStyle = '#f2e6d8';
ctx.font = 'bold 22px serif';
ctx.fillText('易凡起名', w / 2, 80);
ctx.fillStyle = '#d4af37';
ctx.font = '12px sans-serif';
ctx.fillText('传承国学智慧 · 赋予美好寓意', w / 2, 100);
// 3. 卡片
const cardX = 20;
const cardY = 115;
const cardW = w - 40;
const cardH = 310;
ctx.fillStyle = '#fffdf9';
drawRoundRect(ctx, cardX, cardY, cardW, cardH, 8);
ctx.fill();
// 4. 服务项目
ctx.font = '13px sans-serif';
ctx.textAlign = 'left';
services.forEach((name, i) => {
const col = i % 2;
const row = Math.floor(i / 2);
const x = cardX + 25 + col * 115;
const y = cardY + 32 + row * 26;
ctx.fillStyle = '#8b2323';
ctx.beginPath();
ctx.arc(x - 8, y - 4, 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#333';
ctx.fillText(name, x, y);
});
// 5. 分隔线
const lineY = cardY + 85;
ctx.strokeStyle = '#d4af37';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cardX + 15, lineY);
ctx.lineTo(cardX + cardW - 15, lineY);
ctx.stroke();
// 6. 二维码
const qrSize = 120;
const qrX = (w - qrSize) / 2;
const qrY = lineY + 15;
ctx.strokeStyle = '#d4af37';
ctx.lineWidth = 2;
ctx.strokeRect(qrX - 5, qrY - 5, qrSize + 10, qrSize + 10);
ctx.fillStyle = '#fff';
ctx.fillRect(qrX, qrY, qrSize, qrSize);
try {
await drawImage(ctx, qrSrc, qrX, qrY, qrSize, qrSize);
} catch (e) {
if (qrModules.value.length) {
drawQrModulesToCanvas(ctx, qrModules.value, qrX, qrY, qrSize);
}
}
// 7. 邀请码
ctx.textAlign = 'center';
ctx.fillStyle = '#8b2323';
ctx.font = '12px sans-serif';
ctx.fillText(`邀请码:${props.userId}`, w / 2, qrY + qrSize + 20);
ctx.fillStyle = '#999';
ctx.font = '11px sans-serif';
ctx.fillText('长按识别 · 开启好运', w / 2, qrY + qrSize + 38);
// 8. 底部
ctx.fillStyle = '#d4af37';
ctx.font = '11px sans-serif';
ctx.fillText('— 邀您共探姓名玄机 —', w / 2, h - 20);
ctx.fillRect(0, h - 5, w, 5);
return posterCanvas.toDataURL('image/png');
};
const preparePoster = async () => {
if (isSaving.value) return;
isSaving.value = true;
showLoading('生成中...');
try {
const qrSrc = qrImageUrl.value || await generateQrImage();
posterDataUrl.value = await buildPosterDataUrl(qrSrc);
} catch (err: any) {
console.error('海报生成失败:', err);
showToast({ title: '生成失败', icon: 'none' });
} finally {
hideLoading();
isSaving.value = false;
}
};
const handleSave = async () => {
if (isSaving.value) return;
if (!posterDataUrl.value) {
await preparePoster();
}
if (!posterDataUrl.value) return;
const downloaded = triggerDownload(posterDataUrl.value, `poster-${props.userId || 'share'}.png`);
showToast({ title: downloaded ? '已下载海报' : '请长按图片保存', icon: downloaded ? 'success' : 'none' });
};
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.modal-box {
width: 85%;
max-width: 600rpx;
position: relative;
}
.close-btn {
position: absolute;
top: -50rpx;
right: 0;
width: 50rpx;
height: 50rpx;
display: flex;
align-items: center;
justify-content: center;
}
.close-icon {
font-size: 40rpx;
color: rgba(255, 255, 255, 0.7);
}
/* 海报预览 */
.poster-preview {
background: linear-gradient(180deg, #1a0a0a 0%, #2d1515 50%, #1a0a0a 100%);
border-radius: 16rpx;
padding: 40rpx 32rpx;
border: 4rpx solid #d4af37;
}
.poster-header {
text-align: center;
margin-bottom: 32rpx;
}
.poster-symbol {
font-size: 44rpx;
color: #d4af37;
display: block;
margin-bottom: 12rpx;
}
.poster-title {
font-size: 44rpx;
font-weight: bold;
color: #f2e6d8;
letter-spacing: 0.2em;
display: block;
}
.poster-subtitle {
font-size: 20rpx;
color: #d4af37;
display: block;
margin-top: 12rpx;
}
.poster-card {
background: #fffdf9;
border-radius: 12rpx;
padding: 28rpx 24rpx;
}
.service-list {
display: flex;
flex-wrap: wrap;
}
.service-item {
width: 50%;
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.service-dot {
width: 10rpx;
height: 10rpx;
background: #8b2323;
border-radius: 50%;
margin-right: 12rpx;
}
.service-text {
font-size: 24rpx;
color: #333;
}
.poster-divider {
height: 1rpx;
background: #d4af37;
margin: 20rpx 0;
}
.qr-area {
display: flex;
flex-direction: column;
align-items: center;
}
.qr-box {
width: 180rpx;
height: 180rpx;
padding: 8rpx;
border: 2rpx solid #d4af37;
background: #fff;
}
.qr-image {
width: 100%;
height: 100%;
}
.invite-text {
font-size: 22rpx;
color: #8b2323;
margin-top: 16rpx;
}
.scan-text {
font-size: 18rpx;
color: #999;
margin-top: 6rpx;
}
.poster-footer {
text-align: center;
font-size: 20rpx;
color: #d4af37;
margin-top: 24rpx;
display: block;
}
/* 按钮 */
.btn-row {
display: flex;
gap: 20rpx;
margin-top: 28rpx;
}
.btn {
flex: 1;
height: 84rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12rpx;
border: none;
padding: 0;
margin: 0;
}
.btn::after {
border: none;
}
.btn-share {
background: linear-gradient(135deg, #8b2323, #6b1a1a);
}
.btn-save {
background: linear-gradient(135deg, #d4af37, #b8962e);
}
.btn-label {
font-size: 28rpx;
color: #fff;
font-weight: 500;
}
.poster-save-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10000;
}
.poster-save-tip {
color: #fff;
font-size: 28rpx;
margin-bottom: 24rpx;
}
.poster-save-image {
max-width: 80%;
max-height: 70vh;
border-radius: 16rpx;
}
.poster-save-loading {
color: rgba(255, 255, 255, 0.8);
font-size: 24rpx;
padding: 60rpx 0;
}
.poster-save-actions {
display: flex;
gap: 20rpx;
margin-top: 24rpx;
}
.poster-save-btn {
background: linear-gradient(135deg, #d4af37, #b8962e);
color: #fff;
font-size: 26rpx;
border-radius: 12rpx;
padding: 14rpx 40rpx;
}
.poster-save-btn-disabled {
opacity: 0.6;
}
.poster-save-close {
margin-top: 0;
color: rgba(255, 255, 255, 0.7);
font-size: 28rpx;
padding: 16rpx 40rpx;
}
</style>

View File

@@ -0,0 +1,245 @@
<template>
<view class="sixdim-section">
<!-- 顶部图标 + 标题 -->
<!-- <view class="sixdim-bar">
<view class="sixdim-icon-grid">
<view v-for="n in 9" :key="n" class="sixdim-icon-dot"></view>
</view>
<text class="sixdim-bar-title">六维格局</text>
</view> -->
<!-- 卡片主体 -->
<view class="sixdim-card">
<view class="sixdim-info">
<view class="sixdim-info-icon">i</view>
</view>
<view class="sixdim-radar">
<!-- SVG 绘制六边形网格线 -->
<svg class="sixdim-radar-svg" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet">
<!-- 中圈六边形 -->
<polygon
points="50,22 78,36 78,64 50,78 22,64 22,36"
fill="none"
stroke="rgba(255, 255, 255, 0.4)"
stroke-width="0.8"
/>
</svg>
<view
v-for="(label, idx) in labels"
:key="idx"
class="sixdim-label"
:class="'sixdim-label-' + idx"
>
<text>{{ label }}</text>
</view>
<view class="sixdim-radar-fill" :style="fillStyle"></view>
</view>
<view class="sixdim-remark sixdim-remark-center">
<text class="sixdim-remark-label">大师的批注</text>
<text class="sixdim-remark-text">{{ remark }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
labels: string[]; // 六维文字数组,如 ['事业', '财运', '健康', '家庭', '社交', '智慧']
values: number[]; // 六维数据数组,范围 0-100
remark: string; // 大师批注
}
const props = withDefaults(defineProps<Props>(), {
labels: () => ['事业', '财运', '健康', '家庭', '社交', '智慧'],
values: () => [100, 92, 86, 80, 75, 90],
remark: '此名天格人格地格配置上佳,主事业运亨通。水木相生,智慧超群,唯需注意社交圆融。'
});
// 根据 values 计算填充区域的 clip-path
// 六边形顶点位置从顶部开始顺时针0°(上), 60°(右上), 120°(右下), 180°(下), 240°(左下), 300°(左上)
const fillStyle = computed(() => {
const maxValue = 100;
const centerX = 50; // 中心点 x 百分比
const centerY = 50; // 中心点 y 百分比
const baseRadius = 44; // 基础半径(从中心到外圈的百分比,约 44%
// 计算每个维度的实际半径0-100 映射到 0-baseRadius
const points = props.values.map((value: number, idx: number) => {
const angle = (idx * 60 - 90) * (Math.PI / 180); // 转换为弧度,-90° 使顶部为起点
const radius = (value / maxValue) * baseRadius;
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
return { x, y };
});
// 生成 clip-path polygon 字符串
const path = points.map((p: { x: any; y: any; }) => `${p.x}% ${p.y}%`).join(', ');
return {
clipPath: `polygon(${path})`
};
});
</script>
<style scoped>
.sixdim-section {
margin-bottom: 64rpx;
}
.sixdim-bar {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 24rpx;
padding: 0 8rpx;
}
.sixdim-icon-grid {
width: 28rpx;
height: 28rpx;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 2rpx;
}
.sixdim-icon-dot {
width: 6rpx;
height: 6rpx;
border-radius: 2rpx;
background: #d4af37;
opacity: 0.85;
}
.sixdim-bar-title {
font-size: 24rpx;
font-weight: bold;
color: #e2e2e2;
letter-spacing: 0.24em;
}
.sixdim-card {
position: relative;
background: rgba(255, 255, 255, 0.05);
border-radius: 20rpx;
border: 1px solid rgba(255, 255, 255, 0.06);
padding: 28rpx 24rpx 32rpx;
backdrop-filter: blur(12px);
overflow: hidden;
}
.sixdim-info {
position: absolute;
top: 16rpx;
right: 16rpx;
}
.sixdim-info-icon {
width: 28px;
height: 28px;
border-radius: 999rpx;
border: 1px solid rgba(255, 255, 255, 0.16);
color: rgba(255, 255, 255, 0.45);
font-size: 18px;
text-align: center;
line-height: 28rpx;
}
.sixdim-radar {
position: relative;
width: 100%;
max-width: 380rpx;
margin: 0 auto;
padding-top: 65%;
background: radial-gradient(circle at 50% 50%, rgba(255, 193, 7, 0.04), transparent 70%);
border-radius: 24rpx;
overflow: hidden;
}
.sixdim-radar-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 3;
pointer-events: none;
}
.sixdim-radar-fill {
position: absolute;
inset: 24%;
margin: auto;
background: linear-gradient(to bottom, rgba(255, 193, 7, 0.75), rgba(255, 87, 34, 0.55));
opacity: 0.85;
transition: clip-path 0.5s ease-out;
z-index: 2;
}
.sixdim-label {
position: absolute;
font-size: 20rpx;
color: #cfd2dc;
}
.sixdim-label-0 {
top: 6%;
left: 50%;
transform: translateX(-50%);
}
.sixdim-label-1 {
top: 26%;
right: 6%;
}
.sixdim-label-2 {
bottom: 28%;
right: 4%;
}
.sixdim-label-3 {
bottom: 4%;
left: 50%;
transform: translateX(-50%);
}
.sixdim-label-4 {
bottom: 28%;
left: 4%;
}
.sixdim-label-5 {
top: 26%;
left: 6%;
}
.sixdim-remark {
font-size: 20rpx;
line-height: 1.8;
color: #a0a4b8;
}
.sixdim-remark-center {
margin-top: 24rpx;
text-align: center;
padding: 0 24rpx;
}
.sixdim-remark-label {
color: #fdd835;
}
.sixdim-remark-text {
color: #c0c3d0;
}
</style>

View File

@@ -0,0 +1,255 @@
<template>
<view class="sixdim-section">
<view class="sixdim-card">
<view class="sixdim-info">
<view class="sixdim-info-icon">i</view>
</view>
<!-- 电脑端雷达图ECharts 渲染 -->
<view class="sixdim-radar">
<view ref="chartRef" class="sixdim-echart-inner" />
</view>
<view class="sixdim-remark sixdim-remark-center">
<text class="sixdim-remark-label">大师批注</text>
<text class="sixdim-remark-text">{{ remark }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from "vue";
import * as echarts from "echarts";
interface Props {
labels: string[];
values: number[];
remark: string;
}
const props = defineProps<Props>();
const chartRef = ref<HTMLElement | null>(null);
let chart: echarts.ECharts | null = null;
const getSafeValues = (vals: number[]) => (Array.isArray(vals) ? vals.map((v) => (Number.isFinite(v as any) ? Number(v) : 0)) : []);
const getSafeLabels = (labs: string[]) => (Array.isArray(labs) ? labs.map((s) => String(s ?? "")) : []);
/** 雷达图 indicator 与 value 必须等长且非空,否则 ECharts radarLayout 会报 push undefined */
function alignRadarData(rawLabels: string[], rawValues: number[]): { labels: string[]; values: number[] } {
let labels = getSafeLabels(rawLabels).map((s) => s.trim());
let values = getSafeValues(rawValues);
let n = Math.max(labels.length, values.length);
// 个人测名默认六维:事业、财运、健康、家庭、社交、智慧
const defaultSix = ["事业", "财运", "健康", "家庭", "社交", "智慧"];
if (n === 0) {
labels = defaultSix;
values = [0, 0, 0, 0, 0, 0];
n = 6;
} else if (n <= 6) {
// 不足 6 维时,补足到 6 维并强制使用默认中文维度,确保「家庭」等字段一定出现
n = 6;
while (values.length < n) values.push(0);
labels = defaultSix;
} else {
while (labels.length < n) labels.push(`维度${labels.length + 1}`);
while (values.length < n) values.push(0);
labels = labels.slice(0, n);
values = values.slice(0, n);
}
// 再次截断到 n防御性处理
labels = labels.slice(0, n).map((s) => s || "—");
values = values.slice(0, n);
return { labels, values };
}
const buildOption = () => {
const { labels, values } = alignRadarData(props.labels, props.values);
const radarIndicator = labels.map((name) => ({ name: name || "—", max: 100 }));
return {
animationDuration: 700,
animationEasing: "cubicOut" as any,
tooltip: {
trigger: "item",
formatter: (params: any) => {
const valueArr: number[] = params?.value ?? [];
const parts = labels.map((lab, idx) => `${lab}${valueArr[idx] ?? 0}`);
return parts.join("<br/>");
},
},
radar: {
indicator: radarIndicator,
splitNumber: 4,
shape: "polygon",
center: ["50%", "48%"],
// 轴文字可能在较小容器内被裁切:适当缩小半径,让 label 落在视窗内
radius: "62%",
axisName: {
color: "rgba(207, 210, 220, 0.88)",
fontSize: 9,
padding: 6,
},
axisLine: {
lineStyle: {
color: "rgba(212, 220, 236, 0.32)",
width: 1,
},
},
splitLine: {
lineStyle: {
color: "rgba(255, 255, 255, 0.12)",
width: 1,
},
},
splitArea: {
areaStyle: {
color: ["rgba(255, 255, 255, 0.02)", "rgba(255, 193, 7, 0.015)"],
},
},
},
series: [
{
type: "radar",
data: [
{
value: [...values],
},
],
lineStyle: {
color: "rgba(255, 193, 7, 0.92)",
width: 2,
},
itemStyle: {
color: "rgba(255, 193, 7, 0.95)",
},
symbol: "circle",
symbolSize: 3,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "rgba(255, 193, 7, 0.78)" },
{ offset: 1, color: "rgba(243, 138, 43, 0.52)" },
]),
opacity: 0.95,
},
},
],
// 包含所有轴文字(避免 label 在容器内被裁切)
grid: { containLabel: true },
};
};
const render = () => {
if (!chartRef.value) return;
if (!chart) {
// 使用 svg 渲染器,避免 canvas getContext 在某些环境出错
chart = echarts.init(chartRef.value, undefined, { renderer: "svg" });
}
chart.setOption(buildOption(), { notMerge: true, lazyUpdate: true });
};
onMounted(() => {
render();
// 适配父容器缩放(桌面布局下尤其必要)
const onResize = () => chart?.resize();
window.addEventListener("resize", onResize);
onUnmounted(() => window.removeEventListener("resize", onResize));
});
watch(
() => [props.labels, props.values],
() => {
render();
},
{ deep: true }
);
onUnmounted(() => {
if (chart) {
chart.dispose();
chart = null;
}
});
</script>
<style scoped>
.sixdim-section {
margin-bottom: 64rpx;
}
.sixdim-card {
position: relative;
background: rgba(255, 255, 255, 0.05);
border-radius: 20rpx;
border: 1px solid rgba(255, 255, 255, 0.06);
padding: 28rpx 24rpx 32rpx;
backdrop-filter: blur(12px);
overflow: hidden;
}
.sixdim-info {
position: absolute;
top: 16px;
right: 16rpx;
}
.sixdim-info-icon {
width: 28px;
height: 28px;
border-radius: 999rpx;
border: 1px solid rgba(255, 255, 255, 0.16);
color: rgba(255, 255, 255, 0.45);
font-size: 18px;
text-align: center;
}
.sixdim-radar {
position: relative;
width: 100%;
max-width: 380rpx;
margin: 0 auto;
padding-top: 0;
background: radial-gradient(circle at 50% 50%, rgba(255, 193, 7, 0.04), transparent 70%);
border-radius: 24rpx;
height: 280px;
display: flex;
align-items: center;
justify-content: center;
}
.sixdim-echart-inner {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
top: 0;
}
.sixdim-remark {
font-size: 20rpx;
line-height: 1.8;
color: #a0a4b8;
}
.sixdim-remark-center {
margin-top: 24rpx;
text-align: center;
padding: 0 24rpx;
}
.sixdim-remark-label {
color: #fdd835;
}
.sixdim-remark-text {
color: #c0c3d0;
}
</style>

View File

@@ -0,0 +1,367 @@
<template>
<view class="payment-example">
<view class="example-header">
<text class="example-title">支付功能示例</text>
</view>
<!-- 示例1: 购买报告 -->
<view class="example-section">
<text class="example-section-title">示例1: 购买命名报告</text>
<view class="example-card">
<view class="example-card-content">
<text class="example-card-name">张三命名报告</text>
<text class="example-card-desc">包含六维分析周易卦象等</text>
<text class="example-card-price">¥99</text>
</view>
<view class="example-card-btn" @click="buyReport">
<text class="example-card-btn-text">立即购买</text>
</view>
</view>
</view>
<!-- 示例2: 推广合伙人 -->
<view class="example-section">
<text class="example-section-title">示例2: 开通推广合伙人</text>
<view class="example-card">
<view class="example-card-content">
<text class="example-card-name">推广合伙人权益</text>
<text class="example-card-desc">开启睡后收入之旅</text>
<text class="example-card-price">¥99</text>
</view>
<view class="example-card-btn" @click="buyPartner">
<text class="example-card-btn-text">立即开通</text>
</view>
</view>
</view>
<!-- 示例3: 测试支付 -->
<view class="example-section">
<text class="example-section-title">示例3: 测试支付0.01</text>
<view class="example-card">
<view class="example-card-content">
<text class="example-card-name">测试商品</text>
<text class="example-card-desc">用于测试支付流程</text>
<text class="example-card-price">¥0.01</text>
</view>
<view class="example-card-btn" @click="testPay">
<text class="example-card-btn-text">测试支付</text>
</view>
</view>
</view>
<!-- 订单列表 -->
<view class="example-section">
<text class="example-section-title">最近订单</text>
<view v-if="orders.length === 0" class="example-empty">
<text class="example-empty-text">暂无订单</text>
</view>
<view v-else class="example-orders">
<view v-for="order in orders" :key="order.out_trade_no" class="example-order">
<view class="example-order-info">
<text class="example-order-name">{{ order.business_type }}</text>
<text class="example-order-time">{{ order.paid_at || '待支付' }}</text>
</view>
<view class="example-order-right">
<text class="example-order-amount">¥{{ order.total_amount }}</text>
<text class="example-order-status" :class="`status-${order.status}`">
{{ getStatusText(order.status) }}
</text>
</view>
</view>
</view>
</view>
<!-- 支付弹窗 -->
<PaymentModal :visible="showPayment" :product-name="paymentInfo.productName"
:product-desc="paymentInfo.productDesc" :product-icon="paymentInfo.productIcon" :amount="paymentInfo.amount"
:business-type="paymentInfo.businessType" :business-id="paymentInfo.businessId" @close="showPayment = false"
@success="handlePaymentSuccess" @fail="handlePaymentFail" />
</view>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import PaymentModal from '../PaymentModal.vue';
import type { QueryOrderResponse } from '@/api/types';
declare const uni: any;
const showPayment = ref(false);
const orders = ref<QueryOrderResponse[]>([]);
const paymentInfo = reactive({
productName: '',
productDesc: '',
productIcon: '📦',
amount: 0,
businessType: '',
businessId: 0
});
// 购买报告
const buyReport = () => {
paymentInfo.productName = '张三命名报告';
paymentInfo.productDesc = '包含六维分析、周易卦象、开运建议等';
paymentInfo.productIcon = '📊';
paymentInfo.amount = 99;
paymentInfo.businessType = 'naming_report';
paymentInfo.businessId = 123;
showPayment.value = true;
};
// 购买推广合伙人
const buyPartner = () => {
paymentInfo.productName = '推广合伙人权益';
paymentInfo.productDesc = '开启睡后收入之旅';
paymentInfo.productIcon = '💼';
paymentInfo.amount = 99;
paymentInfo.businessType = 'partner_apply';
paymentInfo.businessId = 456;
showPayment.value = true;
};
// 测试支付
const testPay = () => {
paymentInfo.productName = '测试商品';
paymentInfo.productDesc = '用于测试支付流程';
paymentInfo.productIcon = '🧪';
paymentInfo.amount = 0.01;
paymentInfo.businessType = 'test';
paymentInfo.businessId = 999;
showPayment.value = true;
};
// 支付成功回调
const handlePaymentSuccess = (outTradeNo: string) => {
// Web环境使用alertuni-app环境使用showModal
if (typeof uni?.showModal === 'function') {
uni.showModal({
title: '支付成功',
content: `订单号:${outTradeNo}\n感谢您的购买`,
showCancel: false,
success: () => {
// 刷新订单列表
loadOrders();
}
});
} else {
// Web环境使用原生alert
alert(`支付成功\n\n订单号${outTradeNo}\n感谢您的购买`);
loadOrders();
}
};
// 支付失败回调
const handlePaymentFail = (message: string) => {
console.log('支付失败:', message);
// Web环境使用alertuni-app环境使用showModal
if (typeof uni?.showModal === 'function') {
uni.showModal({
title: '支付失败',
content: message || '支付过程中出现问题,请重试',
showCancel: false
});
} else {
// Web环境使用原生alert
alert(`支付失败\n\n${message || '支付过程中出现问题,请重试'}`);
}
};
// 加载订单列表(示例数据)
const loadOrders = () => {
// 实际应该调用API获取订单列表
orders.value = [
{
out_trade_no: 'ORDER_001',
status: 'paid',
total_amount: 99,
paid_amount: 99,
paid_at: '2026-01-15 10:30:00',
business_type: 'naming_report',
business_id: 123
},
{
out_trade_no: 'ORDER_002',
status: 'pending',
total_amount: 99,
business_type: 'partner_apply',
business_id: 456
}
];
};
// 获取状态文本
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
pending: '待支付',
paid: '已支付',
cancelled: '已取消',
refunded: '已退款'
};
return statusMap[status] || status;
};
onMounted(() => {
loadOrders();
});
</script>
<style scoped>
.payment-example {
padding: 32rpx;
background-color: #f5f5f5;
min-height: 100vh;
}
.example-header {
margin-bottom: 32rpx;
}
.example-title {
font-size: 40rpx;
font-weight: 700;
color: #2c2c2c;
}
.example-section {
margin-bottom: 32rpx;
}
.example-section-title {
font-size: 28rpx;
font-weight: 700;
color: #2c2c2c;
margin-bottom: 16rpx;
display: block;
}
.example-card {
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.example-card-content {
display: flex;
flex-direction: column;
gap: 8rpx;
margin-bottom: 16rpx;
}
.example-card-name {
font-size: 32rpx;
font-weight: 700;
color: #2c2c2c;
}
.example-card-desc {
font-size: 24rpx;
color: #999;
}
.example-card-price {
font-size: 40rpx;
font-weight: 700;
color: #8b2323;
margin-top: 8rpx;
}
.example-card-btn {
width: 100%;
padding: 20rpx 0;
background-color: #8b2323;
border-radius: 12rpx;
display: flex;
justify-content: center;
}
.example-card-btn-text {
font-size: 28rpx;
font-weight: 700;
color: #fff;
}
.example-empty {
text-align: center;
padding: 64rpx 0;
}
.example-empty-text {
font-size: 24rpx;
color: #999;
}
.example-orders {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.example-order {
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.example-order-info {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.example-order-name {
font-size: 28rpx;
color: #2c2c2c;
font-weight: 500;
}
.example-order-time {
font-size: 20rpx;
color: #999;
}
.example-order-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8rpx;
}
.example-order-amount {
font-size: 32rpx;
font-weight: 700;
color: #2c2c2c;
}
.example-order-status {
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.status-paid {
background-color: #e8f5e9;
color: #4caf50;
}
.status-pending {
background-color: #fff3e0;
color: #ff9800;
}
.status-cancelled {
background-color: #f5f5f5;
color: #999;
}
.status-refunded {
background-color: #ffebee;
color: #f44336;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<image
:style="{ width: size + 'px', height: size + 'px' }"
:src="iconSrc"
mode="aspectFit"
:class="className || ''"
/>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number | string;
className?: string;
}>(),
{
size: 24,
className: "",
}
);
const iconSrc =
"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMjIgMTJoLTIuNDhhMiAyIDAgMCAwLTEuOTMgMS40NmwtMi4zNSA4LjM2YS4yNS4yNSAwIDAgMS0uNDggMEw5LjI0IDIuMThhLjI1LjI1IDAgMCAwLS40OCAwbC0yLjM1IDguMzZBMiAyIDAgMCAxIDQuNDkgMTJIMiIgc3Ryb2tlPSIjOGIyMzIzIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PC9zdmc+";
</script>
<style scoped>
image {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
:class="className || ''">
<path d="M8 2v4" stroke="#8b2323" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M16 2v4" stroke="#8b2323" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<rect width="18" height="18" x="3" y="4" rx="2" stroke="#8b2323" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M3 10h18" stroke="#8b2323" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number | string;
className?: string;
}>(),
{
size: 24,
className: "",
}
);
</script>
<style scoped>
svg {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,26 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
:class="className || ''">
<path d="M20 6 9 17l-5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number | string;
className?: string;
}>(),
{
size: 24,
className: "",
}
);
</script>
<style scoped>
svg {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,26 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
:class="className || ''">
<path d="m6 9 6 6 6-6" stroke="#8b2323" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number | string;
className?: string;
}>(),
{
size: 24,
className: "",
}
);
</script>
<style scoped>
svg {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,27 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
:class="className || ''">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number | string;
className?: string;
}>(),
{
size: 24,
className: "",
}
);
</script>
<style scoped>
svg {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
:class="className || ''">
<path d="M3 11L12 3L21 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M5 11V21H19V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 21V15H15V21" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M2 11H22" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number | string;
className?: string;
}>(),
{
size: 24,
className: "",
}
);
</script>
<style scoped>
svg {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
:class="className || ''">
<path d="M12 19L19 12L22 15L15 22L12 19Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M18 13L16.5 14.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M2 22L4.5 20" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M2 22C2 22 4 18 9 13L13 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M11 5L15 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number | string;
className?: string;
}>(),
{
size: 24,
className: "",
}
);
</script>
<style scoped>
svg {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<image
:style="{ width: size + 'px', height: size + 'px' }"
:src="iconSrc"
mode="aspectFit"
:class="className || ''"
/>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number | string;
className?: string;
}>(),
{
size: 24,
className: "",
}
);
const iconSrc =
"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTUuNzA3IDIxLjI5M2ExIDEgMCAwIDEtMS40MTQgMGwtMS41ODYtMS41ODZhMSAxIDAgMCAxIDAtMS40MTRsNS41ODYtNS41ODZhMSAxIDAgMCAxIDEuNDE0IDBsMS41ODYgMS41ODZhMSAxIDAgMCAxIDAgMS40MTR6IiBzdHJva2U9IiM4YjIzMjMiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJtMTggMTMtMS4zNzUtNi44NzRhMSAxIDAgMCAwLS43NDYtLjc3NkwzLjIzNSAyLjAyOGExIDEgMCAwIDAtMS4yMDcgMS4yMDdMNS4zNSAxNS44NzlhMSAxIDAgMCAwIC43NzYuNzQ2TDEzIDE4IiBzdHJva2U9IiM4YjIzMjMiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJtMi4zIDIuMyA3LjI4NiA3LjI4NiIgc3Ryb2tlPSIjOGIyMzIzIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PGNpcmNsZSBjeD0iMTEiIGN5PSIxMSIgcj0iMiIgc3Ryb2tlPSIjOGIyMzIzIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PC9zdmc+";
</script>
<style scoped>
image {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,29 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
:class="className || ''">
<circle cx="12" cy="7" r="4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M6 21V19C6 15.6863 8.68629 13 12 13C15.3137 13 18 15.6863 18 19V21" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number | string;
className?: string;
}>(),
{
size: 24,
className: "",
}
);
</script>
<style scoped>
svg {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
:class="className || ''">
<path d="M21 12C21 16.9706 16.9706 21 12 21C9.69494 21 7.59227 20.1332 6.00488 18.7121" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M3 12C3 7.02944 7.02944 3 12 3C14.3051 3 16.4077 3.86676 17.9951 5.28793" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M18 5V9H14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M6 19V15H10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.5" />
</svg>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number | string;
className?: string;
}>(),
{
size: 24,
className: "",
}
);
</script>
<style scoped>
svg {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,29 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
:class="className || ''">
<path d="m21 21-4.34-4.34" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number | string;
className?: string;
}>(),
{
size: 24,
className: "",
}
);
</script>
<style scoped>
svg {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
:class="className || ''">
<rect x="5" y="3" width="14" height="18" rx="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M9 7H15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 11H15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 15H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="15" cy="16" r="1.5" stroke="currentColor" stroke-width="1.5" />
</svg>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number | string;
className?: string;
}>(),
{
size: 24,
className: "",
}
);
</script>
<style scoped>
svg {
display: inline-block;
vertical-align: middle;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
<template>
<view class="analysis-screen">
<!-- Starry Background -->
<view class="analysis-bg">
<view class="analysis-bg-gradient"></view>
<view
v-for="s in stars"
:key="s.id"
class="analysis-star"
:style="{top: s.top + '%', left: s.left + '%', width: s.size + 'px', height: s.size + 'px'}">
</view>
<view class="analysis-bg-blur-1"></view>
<view class="analysis-bg-blur-2"></view>
</view>
<!-- Loading / Analyzing Stage -->
<view v-if="stage !== 'result'" class="analysis-loading">
<MysticCompass />
</view>
<!-- Result Stage -->
<AnalysisResult v-else @back="$emit('back')" />
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import MysticCompass from '../MysticCompass.vue';
import AnalysisResult from '../AnalysisResult.vue';
const emit = defineEmits<{
back: [];
}>();
const stage = ref<'loading' | 'analyzing' | 'result'>('loading');
// Starry background
const stars = ref(Array.from({ length: 50 }).map((_, i) => ({
id: i,
top: Math.random() * 100,
left: Math.random() * 100,
size: Math.random() * 2 + 1
})));
onMounted(() => {
setTimeout(() => {
stage.value = 'analyzing';
}, 100);
setTimeout(() => {
stage.value = 'result';
}, 3500);
});
</script>
<style scoped>
.analysis-screen {
height: 100%;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
font-family: SimSun, "Songti SC", "Songti TC", "Noto Serif SC", STSong, serif;
color: #e2e2e2;
}
/* Background */
.analysis-bg {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.analysis-bg-gradient {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, #050508, #10101a, #1a1a2e);
}
.analysis-star {
position: absolute;
border-radius: 50%;
background: white;
opacity: 0.2;
animation: twinkle 3s ease-in-out infinite;
}
@keyframes twinkle {
0%, 100% { opacity: 0.1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
}
.analysis-bg-blur-1 {
position: absolute;
top: -10%;
left: -10%;
width: 50%;
height: 50%;
background: #2a3d5d;
opacity: 0.2;
filter: blur(100px);
border-radius: 50%;
}
.analysis-bg-blur-2 {
position: absolute;
bottom: -10%;
right: -10%;
width: 50%;
height: 50%;
background: #9c2a2a;
opacity: 0.1;
filter: blur(100px);
border-radius: 50%;
}
/* Loading Stage */
.analysis-loading {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
z-index: 10;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,12 @@
<template>
<view class="h-full flex flex-col items-center justify-center bg-[#050508] text-[#d4af37] font-serif relative overflow-hidden">
<view class="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-[#1a1a2e] via-[#050508] to-[#000] opacity-80"></view>
<view class="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/stardust.png')] opacity-20"></view>
<view class="relative z-10 text-center px-8">
<view class="text-5xl mb-4"></view>
<text class="block text-xl font-bold tracking-[0.4em] mb-2">命盘解析生成中</text>
<text class="block text-sm text-[#e2e2e2]/80">分析时间预计1-2分钟可点击返回按钮退出并在我的方案中查看结果</text>
</view>
</view>
</template>

View File

@@ -0,0 +1,590 @@
<template>
<view class="auspicious-form">
<view class="auspicious-texture"></view>
<!-- 固定Header -->
<view class="auspicious-fixed-header">
<view class="status-bar-placeholder"></view>
<view class="auspicious-header">
<view class="auspicious-back" @click="$emit('back')"></view>
<text class="auspicious-title">精准八字择吉</text>
<view class="auspicious-header-spacer"></view>
</view>
</view>
<!-- 头部占位 -->
<view class="auspicious-header-placeholder"></view>
<scroll-view scroll-y class="auspicious-scroll">
<view class="auspicious-container">
<view class="auspicious-intro">
<text class="auspicious-intro-title">顺天时 · 得地利 · 人和顺</text>
<text class="auspicious-intro-sub">根据您的生辰八字精准测算最佳黄道吉日</text>
</view>
<!-- 事项 -->
<view class="auspicious-section">
<label class="auspicious-label">
您要求测的事项
</label>
<view class="auspicious-grid">
<button v-for="type in eventTypes" :key="type.id" @click="form.eventType = type.id"
:class="['auspicious-event-card', form.eventType === type.id ? 'is-active' : '']">
<text class="auspicious-event-text">{{ type.label }}</text>
</button>
</view>
<view v-if="form.eventType === 'other'" class="auspicious-custom">
<input v-model="form.customEvent" type="text" placeholder="请输入您要求测的事项 (如: 签约, 出行, 动土...)"
class="auspicious-input" />
</view>
</view>
<!-- 福主信息 -->
<view class="auspicious-section">
<label class="auspicious-label">
福主信息
</label>
<view class="auspicious-card">
<view class="auspicious-field">
<label class="auspicious-field-label">您的姓名</label>
<input v-model="form.name" type="text" placeholder="请输入真实姓名" class="auspicious-field-input" />
</view>
<view class="auspicious-field">
<label class="auspicious-field-label">性别</label>
<view class="auspicious-gender">
<button :class="['auspicious-gender-btn', form.gender === 'male' ? 'is-active' : '']"
@click="form.gender = 'male'">
</button>
<button :class="['auspicious-gender-btn', form.gender === 'female' ? 'is-active' : '']"
@click="form.gender = 'female'">
</button>
</view>
</view>
<view class="auspicious-field">
<label class="auspicious-field-label">出生日期时辰</label>
<view class="auspicious-date-trigger" @click="showDatePicker = true">
<text class="auspicious-date-text" :class="{ 'is-placeholder': !form.birthDateDisplay }">
{{ form.birthDateDisplay || '请选择出生日期时辰' }}
</text>
<text class="auspicious-date-arrow"></text>
</view>
</view>
<view class="auspicious-field">
<label class="auspicious-field-label">出生地</label>
<input v-model="form.birthPlace" type="text" placeholder="请输入出生地(如:临沂市)" class="auspicious-field-input" />
</view>
</view>
</view>
<!-- 择吉目的 -->
<view class="auspicious-section">
<label class="auspicious-label">
择吉目的
</label>
<view class="auspicious-card">
<textarea v-model="form.zejiPurpose" placeholder="请描述您的择吉目的(如:选择结婚吉日,希望婚姻美满幸福)" class="auspicious-textarea"
maxlength="200" />
</view>
</view>
<!-- 期望日期范围 -->
<view class="auspicious-section">
<label class="auspicious-label">
期望日期范围
</label>
<view class="auspicious-card">
<view class="auspicious-field">
<label class="auspicious-field-label">开始日期</label>
<view class="auspicious-date-trigger" @click="showStartDatePicker = true">
<text class="auspicious-date-text" :class="{ 'is-placeholder': !form.dateRangeStartDisplay }">
{{ form.dateRangeStartDisplay || '请选择开始日期' }}
</text>
<text class="auspicious-date-arrow"></text>
</view>
</view>
<view class="auspicious-field">
<label class="auspicious-field-label">结束日期</label>
<view class="auspicious-date-trigger" @click="showEndDatePicker = true">
<text class="auspicious-date-text" :class="{ 'is-placeholder': !form.dateRangeEndDisplay }">
{{ form.dateRangeEndDisplay || '请选择结束日期' }}
</text>
<text class="auspicious-date-arrow"></text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 日期选择器 -->
<MysticDatePicker :is-open="showDatePicker" title="请择良辰" :default-value="form.birthDateDisplay"
@close="showDatePicker = false" @confirm="handleDateConfirm" />
<!-- 开始日期不可晚于今天年份为过去至今年 -->
<MysticDatePicker :is-open="showStartDatePicker" title="选择开始日期" :default-value="form.dateRangeStartDisplay"
:min-year="zejiStartMinYear" :max-year="zejiStartMaxYear" cap-at-today
footer-tip="开始日期不可选择今天之后的日期滑动选择后自动对应农历干支"
@close="showStartDatePicker = false" @confirm="handleStartDateConfirm" />
<!-- 结束日期选择器 -->
<MysticDatePicker :is-open="showEndDatePicker" title="选择结束日期" :default-value="form.dateRangeEndDisplay"
:min-year="zejiExpectRangeMinYear" :max-year="zejiExpectRangeMaxYear"
footer-tip="期望日期区间支持选择至未来多年滑动选择后自动对应农历干支"
@close="showEndDatePicker = false" @confirm="handleEndDateConfirm" />
<!-- Footer -->
<view class="auspicious-footer">
<button class="auspicious-submit" @click="submit">
立即测算
</button>
<text class="auspicious-footer-tip">
已有 28,392 人通过壹梵择得良辰吉日
</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import MysticDatePicker from "../MysticDatePicker.vue";
import { baziZejiApi, type BaziZejiCalculateRequest } from '../../api';
declare const uni: any;
const emit = defineEmits<{
submit: [data: any];
back: [];
}>();
const form = reactive({
eventType: "wedding",
customEvent: "",
name: "",
gender: "male",
birthDateDisplay: "",
birthDateApi: "",
birthPlace: "",
zejiPurpose: "",
dateRangeStartDisplay: "",
dateRangeStart: "",
dateRangeEndDisplay: "",
dateRangeEnd: "",
});
const showDatePicker = ref(false);
const showStartDatePicker = ref(false);
const showEndDatePicker = ref(false);
/** 精准八字择吉 · 结束日期:从当年起可往后选多年 */
const ZEJI_RANGE_FORWARD_YEARS = 50;
const zejiExpectRangeMinYear = computed(() => new Date().getFullYear());
const zejiExpectRangeMaxYear = computed(() => new Date().getFullYear() + ZEJI_RANGE_FORWARD_YEARS);
/** 开始日期:至多为今天,年份列与生辰类似(当年往前若干年) */
const zejiStartMinYear = computed(() => new Date().getFullYear() - 85);
const zejiStartMaxYear = computed(() => new Date().getFullYear());
const eventTypes = [
{ id: "wedding", label: "婚嫁择吉", icon: "💒" },
{ id: "business", label: "开业择吉", icon: "🧧" },
{ id: "move", label: "搬家择吉", icon: "🏠" },
{ id: "travel", label: "出行择吉", icon: "✈️" },
{ id: "investment", label: "投资择吉", icon: "💰" },
{ id: "surgery", label: "手术择吉", icon: "🏥" },
{ id: "contract", label: "签约择吉", icon: "📝" },
{ id: "other", label: "其他择吉", icon: "✍️" },
];
const handleDateConfirm = (displayVal: string, apiVal: string) => {
form.birthDateDisplay = displayVal;
form.birthDateApi = apiVal;
showDatePicker.value = false;
};
const handleStartDateConfirm = (displayVal: string, apiVal: string) => {
form.dateRangeStartDisplay = displayVal;
// 从API格式中提取日期部分 (YYYY-MM-DD)
form.dateRangeStart = apiVal.split(' ')[0];
showStartDatePicker.value = false;
};
const handleEndDateConfirm = (displayVal: string, apiVal: string) => {
form.dateRangeEndDisplay = displayVal;
// 从API格式中提取日期部分 (YYYY-MM-DD)
form.dateRangeEnd = apiVal.split(' ')[0];
showEndDatePicker.value = false;
};
const submit = async () => {
if (form.eventType === "other" && !form.customEvent.trim()) {
uni.showToast({ title: "请输入您要求测的事项", icon: "none" });
return;
}
if (!form.name || !form.birthDateDisplay) {
uni.showToast({ title: "请填写真实信息以确保准确", icon: "none" });
return;
}
if (!form.birthPlace) {
uni.showToast({ title: "请输入出生地", icon: "none" });
return;
}
if (!form.zejiPurpose) {
uni.showToast({ title: "请输入择吉目的", icon: "none" });
return;
}
if (!form.dateRangeStart || !form.dateRangeEnd) {
uni.showToast({ title: "请选择期望日期范围", icon: "none" });
return;
}
uni.showLoading({ title: '测算中...', mask: true });
try {
const requestData: BaziZejiCalculateRequest = {
name: form.name,
gender: form.gender as 'male' | 'female',
birth_date: form.birthDateDisplay,
birth_date_api: form.birthDateApi,
birth_place: form.birthPlace,
zeji_type: form.eventType as any,
zeji_purpose: form.eventType === 'other' ? form.customEvent : form.zejiPurpose,
date_range_start: form.dateRangeStart,
date_range_end: form.dateRangeEnd,
};
const result = await baziZejiApi.calculateBaziZeji(requestData);
uni.hideLoading();
emit('submit', result);
} catch (error: any) {
uni.hideLoading();
// 如果是认证失败错误不显示toast因为API函数中已经处理了跳转
if (error.message !== '认证失败,请登录后再试') {
uni.showToast({
title: error.message || '测算失败,请稍后重试',
icon: 'none',
duration: 2000,
});
}
}
};
</script>
<style scoped>
.auspicious-form {
height: 100%;
display: flex;
flex-direction: column;
background: #f0efe9;
position: relative;
overflow: hidden;
font-family: SimSun, "Songti SC", "Songti TC", "Noto Serif SC", STSong, serif;
}
.auspicious-texture {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.4;
mix-blend-mode: multiply;
background-image: url("https://www.transparenttextures.com/patterns/rice-paper.png");
z-index: 0;
}
/* 固定头部容器 */
.auspicious-fixed-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: #f0efe9;
}
/* 状态栏占位 */
.status-bar-placeholder {
height: var(--status-bar-height, 0);
width: 100%;
}
/* 头部占位 */
.auspicious-header-placeholder {
height: calc(var(--status-bar-height, 0) + 100rpx);
flex-shrink: 0;
}
.auspicious-header {
position: relative;
z-index: 10;
padding: 24rpx 32rpx;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #eaddcf;
}
.auspicious-back {
padding: 16rpx;
margin-left: -8rpx;
color: #5a5a5a;
background: transparent;
border: none;
}
.auspicious-title {
font-size: 18px;
font-weight: bold;
color: #2c2c2c;
letter-spacing: 0.3em;
}
.auspicious-header-spacer {
width: 32rpx;
}
.auspicious-scroll {
flex: 1;
position: relative;
z-index: 10;
height: 0;
}
.auspicious-container {
max-width: 700rpx;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 32rpx;
}
.auspicious-intro {
text-align: center;
margin-bottom: 48rpx;
}
.auspicious-intro-title {
font-size: 24px;
font-weight: bold;
color: #2c2c2c;
display: block;
margin-bottom: 12rpx;
letter-spacing: 0.1em;
}
.auspicious-intro-sub {
font-size: 12px;
color: #5a5a5a;
letter-spacing: 0.05em;
}
.auspicious-section {
margin-bottom: 48rpx;
}
.auspicious-label {
display: block;
font-size: 14px;
font-weight: bold;
color: #2c2c2c;
margin-bottom: 20rpx;
padding-left: 12rpx;
border-left: 4rpx solid #8b2323;
letter-spacing: 0.05em;
}
.auspicious-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
align-items: stretch;
}
.auspicious-event-card {
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 24rpx 16rpx;
border-radius: 16rpx;
border: 1px solid #e5e5e5;
background: #fffdf9;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
color: #5a5a5a;
transition: all 0.2s ease;
box-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.04);
}
.auspicious-event-card.is-active {
background: #8b2323;
border-color: #8b2323;
color: #fdfbf7;
box-shadow: 0 10rpx 16rpx -4rpx rgba(139, 35, 35, 0.35);
}
.auspicious-event-icon {
font-size: 22px;
}
.auspicious-event-text {
font-size: 14px;
font-weight: bold;
letter-spacing: 0.05em;
}
.auspicious-custom {
margin-top: 16rpx;
}
.auspicious-input {
width: 100%;
background: #fffdf9;
border: 1px solid #e5e5e5;
border-radius: 14rpx;
padding: 20rpx;
font-size: 14px;
color: #2c2c2c;
outline: none;
box-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.04);
}
.auspicious-input::placeholder {
color: #bfbfbf;
}
.auspicious-card {
background: #fffdf9;
border: 1px solid #e5e5e5;
border-radius: 16rpx;
padding: 28rpx;
box-shadow: 0 6rpx 12rpx -4rpx rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
gap: 20rpx;
}
.auspicious-field {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.auspicious-field-label {
font-size: 12px;
color: #8a8a8a;
}
.auspicious-field-input {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid #e5e5e5;
padding: 14rpx 0;
font-size: 14px;
color: #2c2c2c;
outline: none;
}
.auspicious-field-input::placeholder {
color: #bfbfbf;
}
.auspicious-textarea {
width: 100%;
min-height: 120rpx;
background: transparent;
border: none;
border-bottom: 1px solid #e5e5e5;
padding: 14rpx 0;
font-size: 14px;
color: #2c2c2c;
outline: none;
resize: none;
font-family: inherit;
}
.auspicious-textarea::placeholder {
color: #bfbfbf;
}
.auspicious-gender {
display: flex;
gap: 16rpx;
}
.auspicious-gender-btn {
flex: 1;
border-radius: 12rpx;
border: 1px solid #e5e5e5;
background: transparent;
color: #5a5a5a;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
}
.auspicious-gender-btn.is-active {
background: #2c2c2c;
color: #d4af37;
border-color: #2c2c2c;
}
.auspicious-date-trigger {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e5e5e5;
padding: 14rpx 0;
}
.auspicious-date-text {
font-size: 14px;
color: #2c2c2c;
}
.auspicious-date-text.is-placeholder {
color: #bfbfbf;
}
.auspicious-date-arrow {
color: #8b2323;
font-size: 24rpx;
opacity: 0.6;
}
.auspicious-footer {
padding: 32rpx;
background: #fdfbf7;
border-top: 1px solid #e5e5e5;
position: relative;
z-index: 20;
}
.auspicious-submit {
width: 100%;
background: #8b2323;
color: #fdfbf7;
font-weight: bold;
padding: 18rpx 0;
border-radius: 16rpx;
box-shadow: 0 12rpx 18rpx -8rpx rgba(139, 35, 35, 0.35);
font-size: 16px;
border: none;
}
.auspicious-footer-tip {
display: block;
text-align: center;
font-size: 10px;
color: #999;
margin-top: 12rpx;
}
</style>

View File

@@ -0,0 +1,320 @@
<template>
<view class="aus-load">
<view class="aus-load-bg"></view>
<view class="aus-compass-wrapper">
<view class="aus-compass">
<view class="compass-glow"></view>
<view class="compass-svg-wrap">
<!-- 外圈虚线圆 -->
<view class="circle-outer"></view>
<view class="circle-main"></view>
<!-- 天干地支 -->
<view
v-for="(char, i) in runes"
:key="'rune-' + i"
class="rune-char"
:class="{ active: i === activeCharIndex }"
:style="getRuneStyle(i)"
>
{{ char }}
</view>
<!-- 八卦符号 - 旋转圈 -->
<view class="bagua-circle" :style="{ transform: 'rotate(' + baGuaRotation + 'deg)' }">
<view
v-for="(gua, i) in baGua"
:key="'gua-' + i"
class="bagua-char"
:style="getBaGuaStyle(i)"
>
{{ gua }}
</view>
</view>
<!-- 中心罗盘指针 -->
<view class="compass-pointer" :style="{ transform: 'rotate(' + pointerRotation + 'deg)' }">
<view class="pointer-bar"></view>
<view class="pointer-dot"></view>
</view>
</view>
</view>
<!-- 加载文本 -->
<view class="loading-text">
<view class="loading-title">择吉推演中</view>
<view class="loading-subtitle">结合八字 · 排盘择吉 · 避讳冲煞</view>
<view class="loading-tip">分析时间预计1-2分钟可点击返回按钮退出并在我的方案中查看结果</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const runes = [
"甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸",
"子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"
];
const baGua = ["☰", "☱", "☲", "☳", "☴", "☵", "☶", "☷"];
const activeCharIndex = ref(-1);
const baGuaRotation = ref(0);
const pointerRotation = ref(0);
let runeInterval: number | null = null;
let baGuaInterval: number | null = null;
let pointerInterval: number | null = null;
const getRuneStyle = (index: number) => {
const angle = (index * 360) / runes.length;
const radius = 240;
const angleRad = (angle - 90) * (Math.PI / 180);
const x = 300 + radius * Math.cos(angleRad);
const y = 300 + radius * Math.sin(angleRad);
return {
left: x + "rpx",
top: y + "rpx",
transform: `translate(-50%, -50%) rotate(${angle}deg)`
};
};
const getBaGuaStyle = (index: number) => {
const angle = (index * 360) / 8;
const radius = 145;
const angleRad = (angle - 90) * (Math.PI / 180);
const x = 180 + radius * Math.cos(angleRad);
const y = 180 + radius * Math.sin(angleRad);
return {
left: x + "rpx",
top: y + "rpx",
transform: `translate(-50%, -50%) rotate(${angle}deg)`
};
};
onMounted(() => {
let count = 0;
runeInterval = setInterval(() => {
activeCharIndex.value = count % runes.length;
count++;
}, 100);
baGuaInterval = setInterval(() => {
baGuaRotation.value += 0.25;
}, 30);
let pointerCount = 0;
pointerInterval = setInterval(() => {
pointerCount += 1;
pointerRotation.value = pointerCount * 1.2;
}, 10);
});
onUnmounted(() => {
if (runeInterval) clearInterval(runeInterval);
if (baGuaInterval) clearInterval(baGuaInterval);
if (pointerInterval) clearInterval(pointerInterval);
});
</script>
<style scoped>
.aus-load {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #1a1a1a;
position: relative;
overflow: hidden;
font-family: SimSun, "Songti SC", "Songti TC", "Noto Serif SC", STSong, serif;
}
.aus-load-bg {
position: absolute;
inset: 0;
background: url("https://www.transparenttextures.com/patterns/rice-paper.png");
opacity: 0.08;
pointer-events: none;
}
.aus-compass-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 96rpx;
}
.aus-compass {
position: relative;
width: 100%;
height: 60vh;
display: flex;
align-items: center;
justify-content: center;
min-width: 512rpx;
min-height: 512rpx;
}
.compass-glow {
position: absolute;
inset: 0;
border-radius: 50%;
background-color: #d4af37;
filter: blur(60rpx);
opacity: 0.2;
animation: pulse 4s ease-in-out infinite;
max-width: 80vw;
max-height: 80vw;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
@keyframes pulse {
0%, 100% {
opacity: 0.15;
transform: translate(-50%, -50%) scale(0.95);
}
50% {
opacity: 0.35;
transform: translate(-50%, -50%) scale(1.05);
}
}
.compass-svg-wrap {
position: relative;
width: 80vw;
height: 80vw;
max-width: 600rpx;
max-height: 600rpx;
}
.circle-outer {
position: absolute;
left: 10rpx;
top: 10rpx;
width: 580rpx;
height: 580rpx;
border-radius: 50%;
border: 1rpx dashed #d4af37;
opacity: 0.3;
}
.circle-main {
position: absolute;
left: 20rpx;
top: 20rpx;
width: 560rpx;
height: 560rpx;
border-radius: 50%;
border: 2rpx solid #d4af37;
opacity: 0.4;
}
.rune-char {
position: absolute;
font-size: 24rpx;
color: #888;
font-family: SimSun, serif;
transition: all 0.2s;
}
.rune-char.active {
color: #d4af37;
font-weight: bold;
text-shadow: 0 0 10rpx #d4af37;
}
.bagua-circle {
position: absolute;
left: 120rpx;
top: 120rpx;
width: 360rpx;
height: 360rpx;
border-radius: 50%;
border: 2rpx solid rgba(212, 175, 55, 0.15);
transition: transform 0.3s linear;
}
.bagua-char {
position: absolute;
font-size: 48rpx;
color: #d4af37;
opacity: 0.7;
font-family: SimSun, serif;
}
.compass-pointer {
position: absolute;
left: 300rpx;
top: 300rpx;
width: 0;
height: 0;
transition: transform 0.05s linear;
}
.pointer-bar {
position: absolute;
left: 50%;
top: -160rpx;
width: 4rpx;
height: 160rpx;
background-color: #8b2323;
transform: translateX(-50%);
}
.pointer-dot {
position: absolute;
left: 50%;
top: 50%;
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background-color: #d4af37;
transform: translate(-50%, -50%);
}
.loading-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
animation: fade 2.5s ease-in-out infinite;
}
@keyframes fade {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
.loading-title {
color: #d4af37;
letter-spacing: 0.4em;
font-size: 40rpx;
font-weight: bold;
text-shadow: 0 0 20rpx rgba(212, 175, 55, 0.5);
}
.loading-subtitle {
color: #888;
letter-spacing: 0.2em;
font-size: 24rpx;
}
.loading-tip {
margin-top: 16rpx;
padding: 0 48rpx;
color: rgba(226, 226, 226, 0.72);
letter-spacing: 0.06em;
font-size: 22rpx;
line-height: 1.6;
text-align: center;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,608 @@
<template>
<view class="calendar-screen">
<view class="calendar-texture"></view>
<!-- 固定头部 -->
<view class="calendar-fixed-header">
<!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view>
<!-- Header -->
<view class="calendar-header">
<view class="calendar-back" @click="$emit('back')"></view>
<view class="calendar-title">
<text class="calendar-year">{{ year }} {{ lunarMonths[month] }}</text>
<text class="calendar-subtitle">Year of the Dragon</text>
</view>
<view class="calendar-header-spacer"></view>
</view>
</view>
<!-- 头部占位 -->
<view class="calendar-header-placeholder"></view>
<!-- Content -->
<scroll-view scroll-y class="calendar-scroll">
<!-- Controls -->
<view class="calendar-controls">
<button class="calendar-nav-button" @click="prevMonth"></button>
<text class="calendar-month">
{{ month + 1 }} <text class="calendar-month-unit"></text>
</text>
<button class="calendar-nav-button" @click="nextMonth"></button>
</view>
<!-- Weekdays -->
<view class="calendar-weekdays">
<text v-for="(d, i) in weekdays" :key="i" class="calendar-weekday"
:class="{ 'calendar-weekday-weekend': i === 0 || i === 6 }">
{{ d }}
</text>
</view>
<!-- Calendar Grid -->
<view class="calendar-grid">
<view v-for="cell in calendarCells" :key="cell.key" class="calendar-cell"
:class="cell.day ? ['calendar-cell-filled', cellSelected(cell.day) ? 'is-selected' : '', cellToday(cell.day) ? 'is-today' : ''] : 'calendar-cell-empty'"
@click="cell.day && selectDay(cell.day)">
<template v-if="cell.day">
<text class="calendar-cell-day"
:class="{ 'is-selected': cellSelected(cell.day), 'is-today': cellToday(cell.day) }">
{{ cell.day }}
</text>
<text class="calendar-cell-lunar" :class="{ 'is-selected': cellSelected(cell.day) }">
{{ lunarDay(cell.day) }}
</text>
<view v-if="cellToday(cell.day) && !cellSelected(cell.day)" class="calendar-today-dot"></view>
</template>
</view>
</view>
<!-- Detail Card -->
<view class="calendar-detail-wrapper">
<view class="calendar-detail">
<view class="calendar-detail-corner"></view>
<view class="calendar-detail-header">
<view class="calendar-detail-date">
<text class="calendar-detail-day">{{ selected.getDate() }}</text>
<view class="calendar-detail-meta">
<text class="calendar-detail-lunar">{{ lunarDay(selected.getDate()) }}</text>
<text class="calendar-detail-weekday">{{ weekdayText(selected.getDay()) }}</text>
</view>
</view>
<view class="calendar-detail-badge">今日运势</view>
</view>
<view class="calendar-detail-body">
<view class="calendar-detail-row">
<view class="calendar-detail-icon calendar-detail-icon-yi"></view>
<view class="calendar-detail-tags">
<text v-for="(item, i) in yiJi.yi" :key="i" class="calendar-detail-tag">{{ item }}</text>
</view>
</view>
<view class="calendar-detail-row">
<view class="calendar-detail-icon calendar-detail-icon-ji"></view>
<view class="calendar-detail-tags">
<text v-for="(item, i) in yiJi.ji" :key="i" class="calendar-detail-tag calendar-detail-tag-ji">{{ item
}}</text>
</view>
</view>
</view>
</view>
</view>
<!-- CTA -->
<view class="calendar-cta">
<button class="calendar-cta-card" @click="$emit('auspicious')">
<view class="calendar-cta-glow"></view>
<view class="calendar-cta-content">
<view>
<text class="calendar-cta-title">精准八字择吉</text>
<text class="calendar-cta-subtitle">结婚 · 开业 · 乔迁 · 动土</text>
</view>
<view class="calendar-cta-arrow"></view>
</view>
<view class="calendar-cta-tags">
<text class="calendar-cta-tag">个人定制</text>
<text class="calendar-cta-tag">避讳冲煞</text>
</view>
</button>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
const props = defineProps<{
onBack?: () => void;
onNavigateToAuspicious?: () => void;
}>();
const weekdays = ["日", "一", "二", "三", "四", "五", "六"];
const lunarMonths = ["正月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "冬月", "腊月"];
const lunarDays = [
"初一", "初二", "初三", "初四", "初五", "初六", "初七", "初八", "初九", "初十",
"十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十",
"廿一", "廿二", "廿三", "廿四", "廿五", "廿六", "廿七", "廿八", "廿九", "三十"
];
const yiJiData = [
{ yi: ["出行", "开市", "交易", "裁衣", "安床"], ji: ["动土", "安葬", "破土", "作灶", "入宅"] },
{ yi: ["嫁娶", "订盟", "纳采", "祭祀", "祈福"], ji: ["开仓", "出货", "盖屋", "造桥", "破土"] },
{ yi: ["解除", "扫舍", "整手足甲", "沐浴"], ji: ["安门", "分居", "修造", "动土"] },
{ yi: ["塑绘", "开光", "进人口", "纳畜"], ji: ["嫁娶", "安葬", "行丧", "伐木"] },
{ yi: ["祭祀", "会亲友", "纳财", "捕捉"], ji: ["嫁娶", "开市", "安床", "探病"] }
];
const current = ref(new Date());
const selected = ref(new Date());
const year = computed(() => current.value.getFullYear());
const month = computed(() => current.value.getMonth());
const daysInMonth = computed(() => new Date(year.value, month.value + 1, 0).getDate());
const firstDayOfMonth = computed(() => new Date(year.value, month.value, 1).getDay());
const calendarCells = computed(() => {
const cells: { key: string; day?: number }[] = [];
for (let i = 0; i < firstDayOfMonth.value; i++) {
cells.push({ key: `empty-${i}` });
}
for (let d = 1; d <= daysInMonth.value; d++) {
cells.push({ key: `d-${d}`, day: d });
}
return cells;
});
const lunarDay = (d: number) => lunarDays[(d - 1) % 30];
const getYiJi = (d: number) => yiJiData[d % yiJiData.length];
const yiJi = computed(() => getYiJi(selected.value.getDate()));
const weekdayText = (d: number) => ["周日", "周一", "周二", "周三", "周四", "周五", "周六"][d];
const selectDay = (d: number) => {
selected.value = new Date(year.value, month.value, d);
};
const prevMonth = () => {
current.value = new Date(year.value, month.value - 1, 1);
if (month.value === selected.value.getMonth()) {
selected.value = new Date(year.value, month.value, 1);
}
};
const nextMonth = () => {
current.value = new Date(year.value, month.value + 1, 1);
if (month.value === selected.value.getMonth()) {
selected.value = new Date(year.value, month.value + 1, 1);
}
};
const cellSelected = (d: number) => selected.value.getDate() === d && selected.value.getMonth() === month.value;
const cellToday = (d: number) => {
const today = new Date();
return today.getFullYear() === year.value && today.getMonth() === month.value && today.getDate() === d;
};
</script>
<style scoped>
.calendar-screen {
height: 100vh;
display: flex;
flex-direction: column;
background: #fdfbf7;
font-family: SimSun, "Songti SC", "Songti TC", "Noto Serif SC", STSong, serif;
position: relative;
overflow: hidden;
box-sizing: border-box;
}
/* 固定头部容器 */
.calendar-fixed-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: #fdfbf7;
}
/* 状态栏占位 */
.status-bar-placeholder {
height: var(--status-bar-height, 0);
width: 100%;
flex-shrink: 0;
}
/* 头部占位,防止内容被固定头部遮挡 */
.calendar-header-placeholder {
height: calc(var(--status-bar-height, 0) + 120rpx);
flex-shrink: 0;
}
.calendar-texture {
position: absolute;
inset: 0;
opacity: 0.1;
pointer-events: none;
background-image: url("https://www.transparenttextures.com/patterns/rice-paper.png");
}
.calendar-header {
position: relative;
z-index: 10;
padding: 28rpx 32rpx;
border-bottom: 1px solid #eaddcf;
background: rgba(255, 253, 249, 0.85);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.04);
}
.calendar-back {
padding: 16rpx;
margin-left: -12rpx;
color: #5a5a5a;
background: transparent;
border: none;
}
.calendar-title {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.calendar-year {
font-size: 18px;
font-weight: bold;
color: #2c2c2c;
letter-spacing: 0.3em;
}
.calendar-subtitle {
font-size: 10px;
color: #8a8a8a;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.calendar-header-spacer {
width: 48rpx;
}
.calendar-scroll {
flex: 1;
position: relative;
z-index: 10;
padding-bottom: 80rpx;
height: 0;
}
.calendar-controls {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 48rpx 16rpx;
}
.calendar-nav-button {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
border: 1px solid #eaddcf;
color: #8b2323;
background: #fffdf9;
box-shadow: 0 4rpx 10rpx rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
line-height: 1;
padding: 0;
}
.calendar-month {
font-size: 24px;
font-weight: bold;
color: #2c2c2c;
}
.calendar-month-unit {
font-size: 12px;
color: #8a8a8a;
margin-left: 8rpx;
}
.calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
text-align: center;
padding: 0 32rpx;
margin-bottom: 12rpx;
}
.calendar-weekday {
font-size: 12px;
font-weight: 600;
color: #5a5a5a;
letter-spacing: 0.1em;
}
.calendar-weekday-weekend {
color: #8b2323;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
padding: 0 32rpx;
gap: 12rpx 0;
margin-bottom: 32rpx;
}
.calendar-cell {
height: 112rpx;
}
.calendar-cell-filled {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
border-radius: 12rpx;
transition: all 0.2s ease;
}
.calendar-cell-filled.is-selected {
background: #8b2323;
box-shadow: 0 6rpx 12rpx rgba(139, 35, 35, 0.2);
}
.calendar-cell-filled.is-today:not(.is-selected) {
background: rgba(139, 35, 35, 0.05);
}
.calendar-cell-day {
font-size: 18px;
font-weight: bold;
color: #2c2c2c;
letter-spacing: 0.1em;
}
.calendar-cell-day.is-selected {
color: #fdfbf7;
}
.calendar-cell-day.is-today:not(.is-selected) {
color: #8b2323;
}
.calendar-cell-lunar {
font-size: 10px;
color: #8a8a8a;
transform: scale(0.95);
}
.calendar-cell-lunar.is-selected {
color: rgba(253, 251, 247, 0.8);
}
.calendar-today-dot {
position: absolute;
bottom: 6rpx;
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: #8b2323;
}
.calendar-detail-wrapper {
padding: 0 32rpx;
}
.calendar-detail {
background: #fffdf9;
border: 1px solid #eaddcf;
border-radius: 20rpx;
box-shadow: 0 12rpx 20rpx -8rpx rgba(0, 0, 0, 0.12);
padding: 32rpx 32rpx 28rpx;
position: relative;
overflow: hidden;
}
.calendar-detail-corner {
position: absolute;
top: 0;
right: 0;
width: 96rpx;
height: 96rpx;
background: rgba(139, 35, 35, 0.05);
border-bottom-left-radius: 160rpx;
}
.calendar-detail-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 24rpx;
border-bottom: 1px solid #eaddcf;
padding-bottom: 16rpx;
}
.calendar-detail-date {
display: flex;
align-items: flex-end;
gap: 16rpx;
}
.calendar-detail-day {
font-size: 44px;
font-weight: bold;
color: #2c2c2c;
letter-spacing: 0.05em;
}
.calendar-detail-meta {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.calendar-detail-lunar {
font-size: 16px;
font-weight: bold;
color: #2c2c2c;
}
.calendar-detail-weekday {
font-size: 12px;
color: #8a8a8a;
}
.calendar-detail-badge {
display: inline-block;
padding: 6rpx 12rpx;
background: #8b2323;
color: #fdfbf7;
font-size: 10px;
letter-spacing: 0.2em;
border-radius: 6rpx;
}
.calendar-detail-body {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.calendar-detail-row {
display: flex;
align-items: flex-start;
gap: 16rpx;
}
.calendar-detail-icon {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
border: 1px solid #2c2c2c;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
}
.calendar-detail-icon-yi {
color: #2c2c2c;
}
.calendar-detail-icon-ji {
border-color: #8b2323;
color: #8b2323;
}
.calendar-detail-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
padding-top: 6rpx;
}
.calendar-detail-tag {
font-size: 14px;
color: #5a5a5a;
padding: 6rpx 12rpx;
background: #f7f2ea;
border-radius: 8rpx;
}
.calendar-detail-tag-ji {
color: #8b2323;
background: rgba(139, 35, 35, 0.08);
}
.calendar-cta {
padding: 24rpx 32rpx 64rpx;
}
.calendar-cta-card {
width: 100%;
background: #2c2c2c;
border-radius: 20rpx;
padding: 32rpx;
position: relative;
overflow: hidden;
box-shadow: 0 12rpx 24rpx rgba(0, 0, 0, 0.18);
text-align: left;
}
.calendar-cta-glow {
position: absolute;
top: -20rpx;
right: -20rpx;
width: 180rpx;
height: 180rpx;
background: #d4af37;
opacity: 0.25;
filter: blur(40rpx);
border-radius: 50%;
}
.calendar-cta-content {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.calendar-cta-title {
font-size: 18px;
font-weight: bold;
color: #d4af37;
letter-spacing: 0.1em;
display: block;
margin-bottom: 6rpx;
}
.calendar-cta-subtitle {
font-size: 12px;
color: rgba(242, 230, 216, 0.8);
display: block;
}
.calendar-cta-arrow {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: rgba(212, 175, 55, 0.15);
color: #d4af37;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.calendar-cta-tags {
position: relative;
z-index: 2;
display: flex;
gap: 12rpx;
margin-top: 16rpx;
}
.calendar-cta-tag {
font-size: 10px;
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.08);
padding: 6rpx 12rpx;
border-radius: 8rpx;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,823 @@
<template>
<view class="company-desktop-detail">
<view class="detail-header">
<view class="detail-header-back" @click="emit('back')">
<text class="back-icon"></text>
<text class="back-text">返回</text>
</view>
<text class="detail-header-title">公司测名详解</text>
<view class="detail-header-placeholder" />
</view>
<scroll-view scroll-y class="detail-content">
<view class="report-body">
<!-- header -->
<view
class="section"
:class="{ 'section--click': hasNodes(header.details?.nodes) }"
@click="hasNodes(header.details?.nodes) && openDetail(header.details?.title || '总分详解', header.details?.nodes)"
>
<text class="section-label">header · 总分</text>
<view class="score-row">
<text class="name">{{ toText(header.name) || '—' }}</text>
<text class="score">{{ num(header.score, 0) }}</text>
</view>
<view class="tag-row">
<text class="pill">{{ toText(header.tagLeft) }}</text>
<text class="pill">{{ toText(header.tagRight) }}</text>
</view>
<text class="body-text">{{ toText(header.quote) }}</text>
<text v-if="hasNodes(header.details?.nodes)" class="section-hint">点击本段查看{{ toText(header.details?.title) || '详解' }}</text>
</view>
<!-- characterAnalysis -->
<view
class="section"
:class="{ 'section--click': hasNodes(characterAnalysis.details?.nodes) }"
@click="
hasNodes(characterAnalysis.details?.nodes) &&
openDetail(characterAnalysis.details?.title || '字义数理详解', characterAnalysis.details?.nodes)
"
>
<text class="section-label">characterAnalysis · 字义数理</text>
<view class="char-grid">
<view v-for="(it, idx) in arr(characterAnalysis.characters)" :key="idx" class="char-box">
<text class="char-single">{{ toText(it?.char) }}</text>
<text class="char-meta">{{ toText(it?.element) }} · {{ num(it?.stroke, 0) }}</text>
<text class="body-text small">{{ toText(it?.meaning) }}</text>
</view>
</view>
<text class="body-text">{{ toText(characterAnalysis.analysis) }}</text>
<text v-if="hasNodes(characterAnalysis.details?.nodes)" class="section-hint">
点击本段查看{{ toText(characterAnalysis.details?.title) || '详细拆解' }}
</text>
</view>
<!-- businessPattern -->
<view class="section">
<text class="section-label">businessPattern · 商业格局</text>
<SixDimensionRadarDesktopEchart
:labels="arr(businessPattern.radar?.labels)"
:values="arr(businessPattern.radar?.values)"
:remark="summaryText"
/>
<view v-if="arr(businessPattern.summary).length" class="kv-inline">
<view v-for="(s, si) in arr(businessPattern.summary)" :key="si" class="summary-chip">
<text class="chip-k">{{ toText(s?.label) }}</text>
<text class="chip-v">{{ toText(s?.value) }}</text>
</view>
</view>
<view
v-if="hasNodes(businessPattern.details?.nodes)"
class="section-link"
:class="{ 'section--click': true }"
@click="openDetail(businessPattern.details?.title || '商业六维详解', businessPattern.details?.nodes)"
>
<text>{{ toText(businessPattern.details?.title) || '商业六维 · 解释与建议' }} </text>
</view>
</view>
<!-- gua -->
<view
class="section"
:class="{ 'section--click': hasNodes(gua.details?.nodes) }"
@click="hasNodes(gua.details?.nodes) && openDetail(gua.details?.title || '卦象解读', gua.details?.nodes)"
>
<text class="section-label">gua · 卦象</text>
<text v-if="toText(gua.bg)" class="gua-bg">卦字{{ toText(gua.bg) }}</text>
<text class="headline">{{ toText(gua.name) }} · {{ toText(gua.badge) }}</text>
<text class="body-text">{{ toText(gua.desc) }}</text>
<view v-if="arr(gua.tags).length" class="tag-list">
<text v-for="(t, ti) in arr(gua.tags)" :key="ti" class="pill pill--soft">{{ toText(t) }}</text>
</view>
<text class="body-text">{{ toText(gua.insight) }}</text>
<text v-if="hasNodes(gua.details?.nodes)" class="section-hint">点击本段查看卦象详解</text>
</view>
<!-- team -->
<view
class="section"
:class="{ 'section--click': hasNodes(team.details?.nodes) }"
@click="hasNodes(team.details?.nodes) && openDetail(team.details?.title || '团队契合', team.details?.nodes)"
>
<text class="section-label">team · 团队契合</text>
<view v-for="(m, mi) in arr(team.members)" :key="mi" class="member-block">
<text class="member-line">
{{ toText(m?.role) }} · {{ num(m?.score, 0) }} · {{ toText(m?.match) }}
</text>
<text v-if="toText(m?.desc)" class="body-text small">{{ toText(m?.desc) }}</text>
</view>
<text v-if="toText(team.note)" class="body-text note">{{ toText(team.note) }}</text>
<text v-if="hasNodes(team.details?.nodes)" class="section-hint">点击本段查看团队说明</text>
</view>
<!-- years -->
<view
class="section"
:class="{ 'section--click': hasNodes(years.details?.nodes) }"
@click="hasNodes(years.details?.nodes) && openDetail(years.details?.title || '流年运势', years.details?.nodes)"
>
<text class="section-label">years · 流年</text>
<view v-for="(y, yi) in arr(years.items)" :key="yi" class="year-row">
<text class="year-key">{{ toText(y?.year) }}</text>
<text class="year-luck">{{ toText(y?.luck) }}</text>
<text class="year-text">{{ toText(y?.text) }}</text>
</view>
<text v-if="hasNodes(years.details?.nodes)" class="section-hint">点击本段查看流年预警/建议</text>
</view>
<!-- wealthTrend -->
<view
class="section"
:class="{ 'section--click': hasNodes(wealthTrend.details?.nodes) }"
@click="hasNodes(wealthTrend.details?.nodes) && openDetail(wealthTrend.details?.title || '财运走势', wealthTrend.details?.nodes)"
>
<text class="section-label">wealthTrend · 财运走势</text>
<view class="bars">
<view v-for="(v, vi) in arr(wealthTrend.bars)" :key="vi" class="bar-wrap">
<view class="bar" :style="{ height: `${Math.max(8, Math.min(100, num(v, 0)))}%` }" />
</view>
</view>
<text class="body-text">{{ toText(wealthTrend.note) }}</text>
<text v-if="hasNodes(wealthTrend.details?.nodes)" class="section-hint">点击本段查看走势建议</text>
</view>
<!-- direction -->
<view
class="section"
:class="{ 'section--click': hasNodes(direction.details?.nodes) }"
@click="hasNodes(direction.details?.nodes) && openDetail(direction.details?.title || '吉凶方位', direction.details?.nodes)"
>
<text class="section-label">direction · 方位</text>
<text class="body-text">{{ toText(direction.note) }}</text>
<text v-if="direction.goodDot && (direction.goodDot.x != null || direction.goodDot.y != null)" class="body-text small">
吉位参考点相对坐标x {{ num(direction.goodDot?.x, 0) }}y {{ num(direction.goodDot?.y, 0) }}
</text>
<text v-if="hasNodes(direction.details?.nodes)" class="section-hint">点击本段查看方位说明</text>
</view>
<!-- layout -->
<view
class="section"
:class="{ 'section--click': hasNodes(layout.details?.nodes) }"
@click="hasNodes(layout.details?.nodes) && openDetail(layout.details?.title || '办公布局', layout.details?.nodes)"
>
<text class="section-label">layout · 办公布局</text>
<view v-for="(it, li) in arr(layout.items)" :key="li" class="layout-line">
<text class="layout-strong">{{ toText(it?.strong) }}</text>
<view class="layout-flow">
<text class="body-text small">{{ toText(it?.textBefore) }}</text>
<text v-for="(h, hi) in arr(it?.highlights)" :key="hi" class="highlight">{{ toText(h) }}</text>
<text class="body-text small">{{ toText(it?.textAfter) }}</text>
</view>
</view>
<text v-if="hasNodes(layout.details?.nodes)" class="section-hint">点击本段查看布局建议</text>
</view>
<!-- execution -->
<view
class="section section--wide"
:class="{ 'section--click': hasNodes(execution.details?.nodes) }"
@click="hasNodes(execution.details?.nodes) && openDetail(execution.details?.title || '执行建议', execution.details?.nodes)"
>
<text class="section-label">execution · 执行建议</text>
<text class="body-text">{{ toText(execution.text) }}</text>
<text v-if="hasNodes(execution.details?.nodes)" class="section-hint">点击本段查看执行条目</text>
</view>
<!-- liuyao -->
<view
v-if="hasLiuyao"
class="section"
:class="{ 'section--click': hasNodes(liuyao.details?.nodes) }"
@click="hasNodes(liuyao.details?.nodes) && openDetail('六爻', liuyao.details?.nodes)"
>
<text class="section-label">liuyao · 六爻</text>
<text class="headline">{{ toText(liuyao.hexagram_title) }}</text>
<text class="body-text">{{ toText(liuyao.changing_summary) }}</text>
<text class="body-text">{{ toText(liuyao.interpretation) }}</text>
<text v-for="(yl, yi) in arr(liuyao.yao_lines)" :key="yi" class="body-text small mono">· {{ toText(yl) }}</text>
<text v-if="hasNodes(liuyao.details?.nodes)" class="section-hint">点击本段查看六爻详解</text>
</view>
<!-- wuxing_bagua -->
<view
v-if="hasWuxingBagua"
class="section"
:class="{ 'section--click': hasNodes(wuxingBagua.details?.nodes) }"
@click="hasNodes(wuxingBagua.details?.nodes) && openDetail('五行八卦', wuxingBagua.details?.nodes)"
>
<text class="section-label">wuxing_bagua · 五行八卦</text>
<text class="body-text">{{ toText(wuxingBagua.wuxing_sketch) }}</text>
<text class="body-text">{{ toText(wuxingBagua.bagua_profile) }}</text>
<text class="body-text">{{ toText(wuxingBagua.mutual_sketch) }}</text>
<text class="body-text strong-end">{{ toText(wuxingBagua.summary) }}</text>
<text v-if="hasNodes(wuxingBagua.details?.nodes)" class="section-hint">点击本段查看详解</text>
</view>
<!-- zodiac_sign -->
<view
v-if="hasZodiac"
class="section"
:class="{ 'section--click': hasNodes(zodiacSign.details?.nodes) }"
@click="hasNodes(zodiacSign.details?.nodes) && openDetail('属相', zodiacSign.details?.nodes)"
>
<text class="section-label">zodiac_sign · 属相</text>
<text class="headline">
{{ toText(zodiacSign.animal_icon) }} {{ toText(zodiacSign.animal) }}{{ toText(zodiacSign.earthly_branch) }}
</text>
<text class="body-text">{{ toText(zodiacSign.trait_summary) }}</text>
<text class="body-text">{{ toText(zodiacSign.name_harmony) }}</text>
<text v-if="hasNodes(zodiacSign.details?.nodes)" class="section-hint">点击本段查看属相详解</text>
</view>
<!-- career_plan -->
<view
v-if="hasCareerPlan"
class="section"
:class="{ 'section--click': hasNodes(careerPlan.details?.nodes) }"
@click="hasNodes(careerPlan.details?.nodes) && openDetail('事业规划', careerPlan.details?.nodes)"
>
<text class="section-label">career_plan · 事业规划</text>
<text class="body-text">{{ toText(careerPlan.summary) }}</text>
<view v-for="(ms, msi) in arr(careerPlan.milestones)" :key="msi" class="milestone">
<text class="milestone-title">{{ toText(ms?.phase) }}{{ toText(ms?.period) ? ' · ' + toText(ms.period) : '' }}</text>
<text v-if="toText(ms?.focus)" class="body-text small">重点{{ toText(ms.focus) }}</text>
<text v-if="toText(ms?.advice)" class="body-text small">建议{{ toText(ms.advice) }}</text>
</view>
<text v-if="hasNodes(careerPlan.details?.nodes)" class="section-hint">点击本段查看规划详解</text>
</view>
<!-- lucky_numbers -->
<view
v-if="hasLuckyNumbers"
class="section"
:class="{ 'section--click': hasNodes(luckyNumbers.details?.nodes) }"
@click="hasNodes(luckyNumbers.details?.nodes) && openDetail('幸运数字', luckyNumbers.details?.nodes)"
>
<text class="section-label">lucky_numbers · 幸运数字</text>
<text class="headline">首推{{ toText(luckyNumbers.primary) }}</text>
<text class="body-text">{{ arr(luckyNumbers.numbers).join('、') }}</text>
<text class="body-text">{{ toText(luckyNumbers.meaning) }}</text>
<text v-if="hasNodes(luckyNumbers.details?.nodes)" class="section-hint">点击本段查看数字详解</text>
</view>
<!-- lucky_colors -->
<view
v-if="hasLuckyColors"
class="section"
:class="{ 'section--click': hasNodes(luckyColors.details?.nodes) }"
@click="hasNodes(luckyColors.details?.nodes) && openDetail('幸运色', luckyColors.details?.nodes)"
>
<text class="section-label">lucky_colors · 幸运色</text>
<text class="headline">主推{{ toText(luckyColors.primary) }}</text>
<view class="color-row">
<view v-for="(c, ci) in arr(luckyColors.colors)" :key="ci" class="color-item">
<view class="color-swatch" :style="{ backgroundColor: toText(c?.hex) || '#333' }" />
<text class="body-text small">{{ toText(c?.name) }}{{ toText(c?.note) ? ' · ' + toText(c.note) : '' }}</text>
</view>
</view>
<text class="body-text">{{ toText(luckyColors.meaning) }}</text>
<text v-if="hasNodes(luckyColors.details?.nodes)" class="section-hint">点击本段查看用色详解</text>
</view>
</view>
</scroll-view>
<view v-if="props.showBusinessFortune !== false" class="footer-action">
<button class="fortune-btn" type="button" @click="emit('businessFortune', detailData)">查看商业运势</button>
</view>
<view v-if="showModal" class="modal-mask" @click="closeModal">
<view class="detail-modal" @click.stop>
<view class="detail-modal-card">
<text class="modal-title">{{ modalTitle }}</text>
<text class="close" @click="closeModal">×</text>
</view>
<scroll-view scroll-y class="detail-modal-body">
<view v-for="(node, idx) in modalNodes" :key="idx" class="node">
<text v-if="node?.type === 'text'" class="line">{{ toText(node.text) }}</text>
<view v-else-if="node?.type === 'list'">
<text v-for="(item, j) in arr(node.items)" :key="j" class="line">- {{ toText(item) }}</text>
</view>
<view v-else-if="node?.type === 'kv'">
<text v-for="(item, j) in arr(node.items)" :key="j" class="line">{{ toText(item?.label) }}{{ toText(item?.value) }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import SixDimensionRadarDesktopEchart from "../SixDimensionRadarDesktopEchart.vue";
const props = defineProps<{
data: any;
showBusinessFortune?: boolean;
}>();
const emit = defineEmits<{
back: [];
businessFortune: [any];
}>();
const parseMaybeJson = (value: any) => {
if (value && typeof value === "object") return value;
if (typeof value !== "string") return {};
try {
return JSON.parse(value);
} catch {
return {};
}
};
const toText = (v: any) => String(v ?? "").trim();
const num = (v: any, fallback = 0) => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
};
const arr = (v: any) => (Array.isArray(v) ? v : []);
const hasNodes = (nodes: any) => arr(nodes).length > 0;
const detailData = computed(() => parseMaybeJson(props.data));
const header = computed(() => detailData.value?.header || {});
const characterAnalysis = computed(() => detailData.value?.characterAnalysis || {});
const businessPattern = computed(() => detailData.value?.businessPattern || {});
const gua = computed(() => detailData.value?.gua || {});
const team = computed(() => detailData.value?.team || {});
const years = computed(() => detailData.value?.years || {});
const wealthTrend = computed(() => detailData.value?.wealthTrend || {});
const direction = computed(() => detailData.value?.direction || {});
const layout = computed(() => detailData.value?.layout || {});
const execution = computed(() => detailData.value?.execution || {});
const liuyao = computed(() => detailData.value?.liuyao || {});
const wuxingBagua = computed(() => detailData.value?.wuxing_bagua || {});
const zodiacSign = computed(() => detailData.value?.zodiac_sign || {});
const careerPlan = computed(() => detailData.value?.career_plan || {});
const luckyNumbers = computed(() => detailData.value?.lucky_numbers || {});
const luckyColors = computed(() => detailData.value?.lucky_colors || {});
const summaryText = computed(() =>
arr(businessPattern.value?.summary)
.map((x: any) => `${toText(x?.label)} ${toText(x?.value)}`)
.filter(Boolean)
.join(" · "),
);
const hasLiuyao = computed(
() =>
!!(
toText(liuyao.value?.hexagram_title) ||
toText(liuyao.value?.changing_summary) ||
toText(liuyao.value?.interpretation) ||
arr(liuyao.value?.yao_lines).length ||
hasNodes(liuyao.value?.details?.nodes)
),
);
const hasWuxingBagua = computed(
() =>
!!(
toText(wuxingBagua.value?.wuxing_sketch) ||
toText(wuxingBagua.value?.bagua_profile) ||
toText(wuxingBagua.value?.mutual_sketch) ||
toText(wuxingBagua.value?.summary) ||
hasNodes(wuxingBagua.value?.details?.nodes)
),
);
const hasZodiac = computed(
() =>
!!(
toText(zodiacSign.value?.animal) ||
toText(zodiacSign.value?.trait_summary) ||
toText(zodiacSign.value?.name_harmony) ||
hasNodes(zodiacSign.value?.details?.nodes)
),
);
const hasCareerPlan = computed(
() =>
!!(
toText(careerPlan.value?.summary) ||
arr(careerPlan.value?.milestones).length ||
hasNodes(careerPlan.value?.details?.nodes)
),
);
const hasLuckyNumbers = computed(
() =>
!!(toText(luckyNumbers.value?.primary) || arr(luckyNumbers.value?.numbers).length || toText(luckyNumbers.value?.meaning)),
);
const hasLuckyColors = computed(
() =>
!!(
toText(luckyColors.value?.primary) ||
arr(luckyColors.value?.colors).length ||
toText(luckyColors.value?.meaning)
),
);
const showModal = ref(false);
const modalTitle = ref("");
const modalNodes = ref<any[]>([]);
const openDetail = (title: string, nodes: any[]) => {
const list = arr(nodes);
if (!list.length) return;
modalTitle.value = toText(title) || "详情";
modalNodes.value = list;
showModal.value = true;
};
const closeModal = () => {
showModal.value = false;
};
</script>
<style scoped>
.company-desktop-detail {
min-height: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: linear-gradient(165deg, #070a12 0%, #12102a 42%, #0a1628 100%);
color: #e8e4dc;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(15, 23, 42, 0.72);
border-bottom: 1px solid rgba(212, 175, 55, 0.2);
flex-shrink: 0;
}
.detail-header-back {
display: inline-flex;
align-items: center;
gap: 6px;
color: #d4af37;
}
.detail-header-title {
font-size: 16px;
font-weight: 700;
color: #f2e6d8;
}
.detail-header-placeholder {
width: 48px;
}
.detail-content {
flex: 1;
min-height: 0;
box-sizing: border-box;
}
.report-body {
max-width: 820px;
margin: 0 auto;
padding: 16px 18px 20px;
box-sizing: border-box;
}
.section {
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 12px;
background: rgba(15, 23, 42, 0.52);
border: 1px solid rgba(212, 175, 55, 0.14);
border-left: 3px solid rgba(212, 175, 55, 0.45);
backdrop-filter: blur(8px);
}
.section--wide {
max-width: 100%;
}
.section--click {
cursor: pointer;
}
.section--click:active {
opacity: 0.92;
}
.section-label {
display: block;
font-size: 11px;
letter-spacing: 0.06em;
color: rgba(212, 175, 55, 0.75);
margin-bottom: 10px;
font-weight: 600;
}
/* 商业六维内嵌 ECharts 组件自带外边距,报告式布局里收紧 */
:deep(.sixdim-section) {
margin-bottom: 0 !important;
}
.score-row {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8px;
}
.name {
font-size: 22px;
font-weight: 800;
color: #f2e6d8;
}
.score {
font-size: 32px;
font-weight: 800;
color: #d4af37;
}
.tag-row,
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.pill {
font-size: 11px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(212, 175, 55, 0.12);
border: 1px solid rgba(212, 175, 55, 0.28);
color: #ede6d8;
}
.pill--soft {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(148, 163, 184, 0.28);
color: rgba(232, 228, 220, 0.9);
}
.body-text {
display: block;
font-size: 13px;
line-height: 1.55;
color: rgba(232, 228, 220, 0.92);
margin-bottom: 8px;
}
.body-text.small {
font-size: 12px;
color: rgba(232, 228, 220, 0.82);
}
.body-text.note {
font-style: italic;
color: rgba(212, 175, 55, 0.65);
}
.mono {
font-family: ui-monospace, monospace;
}
.strong-end {
font-weight: 600;
color: #f0dba9;
}
.headline {
display: block;
font-size: 15px;
font-weight: 700;
color: #f4e5c4;
margin-bottom: 8px;
}
.gua-bg {
display: block;
font-size: 12px;
color: rgba(212, 175, 55, 0.8);
margin-bottom: 6px;
}
.section-hint {
display: block;
margin-top: 8px;
font-size: 11px;
color: rgba(148, 163, 184, 0.85);
}
.section-link {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(212, 175, 55, 0.12);
font-size: 12px;
color: #d4af37;
}
.char-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.char-box {
padding: 10px;
border-radius: 10px;
background: rgba(2, 6, 23, 0.4);
border: 1px solid rgba(212, 175, 55, 0.12);
}
.char-single {
font-size: 22px;
font-weight: 800;
}
.char-meta {
display: block;
font-size: 11px;
color: rgba(203, 213, 225, 0.8);
margin: 4px 0 6px;
}
.kv-inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.summary-chip {
padding: 6px 10px;
border-radius: 8px;
background: rgba(2, 6, 23, 0.45);
border: 1px solid rgba(148, 163, 184, 0.22);
}
.chip-k {
font-size: 11px;
color: rgba(203, 213, 225, 0.85);
margin-right: 6px;
}
.chip-v {
font-size: 12px;
font-weight: 700;
color: #f0dba9;
}
.member-block {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.member-block:last-of-type {
border-bottom: none;
}
.member-line {
font-size: 13px;
font-weight: 600;
color: #f2e6d8;
}
.year-row {
display: grid;
grid-template-columns: 56px 52px 1fr;
gap: 8px;
align-items: start;
margin-bottom: 8px;
font-size: 12px;
}
.year-key {
color: #d4af37;
font-weight: 700;
}
.year-luck {
color: #a7f3d0;
}
.layout-line {
margin-bottom: 10px;
}
.layout-strong {
display: block;
font-size: 13px;
font-weight: 700;
color: #f0dba9;
margin-bottom: 4px;
}
.highlight {
color: #fde68a;
margin: 0 2px;
font-size: 12px;
}
.layout-flow {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 2px 6px;
}
.bars {
height: 96px;
display: flex;
align-items: flex-end;
gap: 6px;
margin: 12px 0;
}
.bar-wrap {
flex: 1;
height: 100%;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
overflow: hidden;
display: flex;
align-items: flex-end;
}
.bar {
width: 100%;
background: linear-gradient(180deg, rgba(255, 205, 96, 0.9), rgba(230, 129, 38, 0.78));
}
.milestone {
margin-top: 10px;
padding: 10px;
border-radius: 10px;
background: rgba(2, 6, 23, 0.35);
border: 1px solid rgba(212, 175, 55, 0.1);
}
.milestone-title {
display: block;
font-size: 13px;
font-weight: 700;
color: #f4e5c4;
margin-bottom: 6px;
}
.color-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 10px 0;
}
.color-item {
display: flex;
align-items: center;
gap: 8px;
}
.color-swatch {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
flex-shrink: 0;
}
.footer-action {
padding: 10px 18px 14px;
flex-shrink: 0;
max-width: 820px;
width: 100%;
margin: 0 auto;
box-sizing: border-box;
}
.fortune-btn {
width: 100%;
border-radius: 10px;
padding: 10px 12px;
background: linear-gradient(135deg, rgba(139, 35, 35, 0.95), rgba(90, 20, 20, 0.98));
color: #fdfbf7;
border: 1px solid rgba(212, 175, 55, 0.35);
}
.modal-mask {
position: fixed;
inset: 0;
z-index: 3200;
background: rgba(2, 6, 23, 0.72);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
box-sizing: border-box;
}
.detail-modal {
width: min(640px, 100%);
max-height: min(82vh, 760px);
background: rgba(10, 12, 20, 0.96);
border: 1px solid rgba(212, 175, 55, 0.2);
border-radius: 12px;
overflow: hidden;
}
.detail-modal-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid rgba(212, 175, 55, 0.16);
}
.modal-title {
color: #d4af37;
font-size: 14px;
font-weight: 700;
}
.close {
color: #d4af37;
font-size: 20px;
}
.detail-modal-body {
max-height: calc(min(82vh, 760px) - 48px);
padding: 12px;
box-sizing: border-box;
}
.node {
margin-bottom: 8px;
}
.line {
display: block;
font-size: 13px;
line-height: 1.5;
color: rgba(232, 228, 220, 0.9);
margin-bottom: 6px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,661 @@
<template>
<view class="login-screen">
<!-- 背景装饰 -->
<view class="login-bg">
<view class="login-bg-pattern"></view>
</view>
<!-- 主要内容 -->
<view class="login-content">
<!-- Logo/标题区域 -->
<view class="login-header">
<view class="login-logo">
<text class="login-logo-text">壹梵</text>
</view>
<text class="login-title">壹梵起名</text>
<text class="login-subtitle">传承千年文化 · 赋予美好寓意</text>
</view>
<!-- 登录/注册表单 -->
<view class="login-form-wrapper">
<!-- 标签切换 -->
<view class="login-tabs">
<view class="login-tab" :class="{ active: currentTab === 'login' }" @click="currentTab = 'login'">
<text class="login-tab-text">登录</text>
</view>
<view class="login-tab" :class="{ active: currentTab === 'register' }" @click="currentTab = 'register'">
<text class="login-tab-text">注册</text>
</view>
</view>
<!-- 登录表单 -->
<view v-if="currentTab === 'login'" class="login-form">
<view class="login-form-item">
<text class="login-form-label">手机号</text>
<input v-model="loginForm.mobile" type="tel" class="login-form-input" placeholder="请输入手机号" maxlength="11" />
</view>
<view class="login-form-item">
<text class="login-form-label">密码</text>
<input v-model="loginForm.password" :type="showPassword ? 'text' : 'password'" class="login-form-input"
placeholder="请输入密码" />
<view class="login-form-eye" @click="showPassword = !showPassword">
<text>{{ showPassword ? '👁️' : '👁️‍🗨️' }}</text>
</view>
</view>
<view class="login-forgot" @click="currentTab = 'forgot'">
<text class="login-forgot-text">忘记密码</text>
</view>
<button class="login-btn login-btn-primary" :disabled="!canLogin || loading" @click="handleLogin">
<text class="login-btn-text">{{ loading ? '登录中...' : '登录' }}</text>
</button>
</view>
<!-- 注册表单 -->
<view v-if="currentTab === 'register'" class="login-form">
<view class="login-form-item">
<text class="login-form-label">手机号</text>
<input v-model="registerForm.mobile" type="tel" class="login-form-input" placeholder="请输入手机号"
maxlength="11" />
</view>
<view class="login-form-item">
<text class="login-form-label">验证码</text>
<view class="login-form-code">
<input v-model="registerForm.code" type="tel" class="login-form-input" placeholder="请输入验证码"
maxlength="6" />
<button class="login-code-btn" :disabled="!canSendCode || countdown > 0"
@click="handleSendCode('register')">
<text class="login-code-text">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</text>
</button>
</view>
</view>
<view class="login-form-item">
<text class="login-form-label">密码</text>
<input v-model="registerForm.password" :type="showPassword ? 'text' : 'password'" class="login-form-input"
placeholder="请输入密码6-20位" />
<view class="login-form-eye" @click="showPassword = !showPassword">
<text>{{ showPassword ? '👁️' : '👁️‍🗨️' }}</text>
</view>
</view>
<view class="login-form-item">
<text class="login-form-label">确认密码</text>
<input v-model="registerForm.repassword" :type="showPassword ? 'text' : 'password'" class="login-form-input"
placeholder="请再次输入密码" />
</view>
<button class="login-btn login-btn-primary" :disabled="loading" @click="handleRegister">
<text class="login-btn-text">{{ loading ? '注册中...' : '注册' }}</text>
</button>
</view>
<!-- 忘记密码表单 -->
<view v-if="currentTab === 'forgot'" class="login-form">
<view class="login-form-item">
<text class="login-form-label">手机号</text>
<input v-model="forgotForm.mobile" type="tel" class="login-form-input" placeholder="请输入手机号"
maxlength="11" />
</view>
<view class="login-form-item">
<text class="login-form-label">验证码</text>
<view class="login-form-code">
<input v-model="forgotForm.code" type="tel" class="login-form-input" placeholder="请输入验证码" maxlength="6" />
<button class="login-code-btn" :disabled="!canSendCodeForgot || countdown > 0"
@click="handleSendCode('forgot')">
<text class="login-code-text">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</text>
</button>
</view>
</view>
<view class="login-form-item">
<text class="login-form-label">新密码</text>
<input v-model="forgotForm.password" :type="showPassword ? 'text' : 'password'" class="login-form-input"
placeholder="请输入新密码6-20位" />
<view class="login-form-eye" @click="showPassword = !showPassword">
<text>{{ showPassword ? '👁️' : '👁️‍🗨️' }}</text>
</view>
</view>
<view class="login-form-item">
<text class="login-form-label">确认密码</text>
<input v-model="forgotForm.repassword" :type="showPassword ? 'text' : 'password'" class="login-form-input"
placeholder="请再次输入新密码" />
</view>
<button class="login-btn login-btn-primary" :disabled="!canResetPassword || loading"
@click="handleResetPassword">
<text class="login-btn-text">{{ loading ? '重置中...' : '重置密码' }}</text>
</button>
<view class="login-back" @click="currentTab = 'login'">
<text class="login-back-text">返回登录</text>
</view>
</view>
</view>
<!-- 协议 -->
<view class="login-agreement">
<text class="login-agreement-text">
登录即表示同意
<text class="login-agreement-link" @click="handleNavigateToAgreement">用户协议</text>
<text class="login-agreement-link" @click="handleNavigateToPrivacy">隐私政策</text>
</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue';
import { useRouter } from 'vue-router';
import { userApi } from '@/api';
import type { MobileLoginResponse, MobileRegisterResponse, ForgotPasswordResponse } from '@/api/types';
import { showToast } from '@/utils/uni-compat';
const router = useRouter();
const emit = defineEmits<{
success: [data: MobileLoginResponse | MobileRegisterResponse | ForgotPasswordResponse];
}>();
const loading = ref(false);
const currentTab = ref<'login' | 'register' | 'forgot'>('login');
const showPassword = ref(false);
const countdown = ref(0);
let countdownTimer: number | null = null;
// 登录表单
const loginForm = reactive({
mobile: '',
password: '',
});
// 注册表单
const registerForm = reactive({
mobile: '',
code: '',
password: '',
repassword: '',
});
// 忘记密码表单
const forgotForm = reactive({
mobile: '',
code: '',
password: '',
repassword: '',
});
// 验证手机号
const isValidMobile = (mobile: string) => {
return /^1[3-9]\d{9}$/.test(mobile);
};
// 验证密码
const isValidPassword = (password: string) => {
return password.length >= 6 && password.length <= 20;
};
// 是否可以登录(仅根据 loading 控制按钮,去掉其他前置校验)
const canLogin = computed(() => {
return true;
});
// 是否可以注册
const canRegister = computed(() => {
return (
isValidMobile(registerForm.mobile) &&
registerForm.code.length === 6 &&
isValidPassword(registerForm.password) &&
registerForm.password === registerForm.repassword
);
});
// 是否可以发送验证码(注册)
const canSendCode = computed(() => {
return isValidMobile(registerForm.mobile);
});
// 是否可以发送验证码(忘记密码)
const canSendCodeForgot = computed(() => {
return isValidMobile(forgotForm.mobile);
});
// 是否可以重置密码
const canResetPassword = computed(() => {
return (
isValidMobile(forgotForm.mobile) &&
forgotForm.code.length === 6 &&
isValidPassword(forgotForm.password) &&
forgotForm.password === forgotForm.repassword
);
});
// 开始倒计时
const startCountdown = () => {
countdown.value = 60;
if (countdownTimer) {
clearInterval(countdownTimer);
}
countdownTimer = window.setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}
}, 1000);
};
// 发送验证码
const handleSendCode = async (type: 'register' | 'forgot') => {
const mobile = type === 'register' ? registerForm.mobile : forgotForm.mobile;
if (!isValidMobile(mobile)) {
showToast({ title: '请输入正确的手机号', icon: 'none' });
return;
}
try {
await userApi.sendSmsCode(mobile);
showToast({ title: '验证码已发送', icon: 'success' });
startCountdown();
} catch (error: any) {
showToast({ title: error.msg || '发送失败,请重试', icon: 'none' });
}
};
// 登录
const handleLogin = async () => {
if (!canLogin.value || loading.value) return;
try {
loading.value = true;
const result = await userApi.mobileLogin({
mobile: loginForm.mobile,
password: loginForm.password,
});
showToast({ title: '登录成功', icon: 'success' });
emit('success', result);
} catch (error: any) {
showToast({ title: error.msg || '登录失败,请重试', icon: 'none' });
} finally {
loading.value = false;
}
};
// 注册
const handleRegister = async () => {
if (!canRegister.value || loading.value) return;
if (registerForm.password !== registerForm.repassword) {
showToast({ title: '两次密码输入不一致', icon: 'none' });
return;
}
try {
loading.value = true;
const result = await userApi.mobileRegister({
mobile: registerForm.mobile,
password: registerForm.password,
repassword: registerForm.repassword,
verification_code: registerForm.code,
});
showToast({ title: '注册成功', icon: 'success' });
emit('success', result);
} catch (error: any) {
showToast({ title: error.msg || '注册失败,请重试', icon: 'none' });
} finally {
loading.value = false;
}
};
// 重置密码
const handleResetPassword = async () => {
if (!canResetPassword.value || loading.value) return;
if (forgotForm.password !== forgotForm.repassword) {
showToast({ title: '两次密码输入不一致', icon: 'none' });
return;
}
try {
loading.value = true;
const result = await userApi.forgotPassword({
mobile: forgotForm.mobile,
password: forgotForm.password,
repassword: forgotForm.repassword,
verification_code: forgotForm.code,
});
showToast({ title: '密码重置成功', icon: 'success' });
emit('success', result);
} catch (error: any) {
showToast({ title: error.msg || '重置失败,请重试', icon: 'none' });
} finally {
loading.value = false;
}
};
// 导航到用户协议
const handleNavigateToAgreement = () => {
router.push('/user-agreement');
};
// 导航到隐私政策
const handleNavigateToPrivacy = () => {
router.push('/privacy-policy');
};
</script>
<style scoped>
.login-screen {
position: relative;
width: 100%;
min-height: 100vh;
background: #fdfbf7 url("https://www.transparenttextures.com/patterns/rice-paper.png");
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.login-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
overflow: hidden;
}
.login-bg-pattern {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle at 30% 30%, rgba(139, 35, 35, 0.03) 0%, transparent 50%);
animation: rotate 60s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.login-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 30px;
width: 100%;
max-width: 500px;
}
.login-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 40px;
}
.login-logo {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #8b2323 0%, #9c2a2a 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
box-shadow: 0 4px 12px rgba(139, 35, 35, 0.3);
}
.login-logo-text {
font-size: 32px;
font-weight: bold;
color: #fdfbf7;
font-family: SimSun, "Songti SC", serif;
}
.login-title {
font-size: 28px;
font-weight: 500;
color: #2c2c2c;
margin-bottom: 8px;
font-family: SimSun, "Songti SC", serif;
letter-spacing: 0.2em;
}
.login-subtitle {
font-size: 14px;
color: #8a8a8a;
font-family: SimSun, "Songti SC", serif;
}
.login-form-wrapper {
width: 100%;
background: rgba(255, 255, 255, 0.8);
border-radius: 16px;
padding: 30px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border: 1px solid #eaddcf;
}
.login-tabs {
display: flex;
margin-bottom: 30px;
border-bottom: 2px solid #eaddcf;
}
.login-tab {
flex: 1;
padding: 12px 0;
text-align: center;
cursor: pointer;
position: relative;
transition: all 0.3s;
}
.login-tab.active {
color: #8b2323;
}
.login-tab.active::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 2px;
background: #8b2323;
}
.login-tab-text {
font-size: 16px;
font-weight: 500;
font-family: SimSun, "Songti SC", serif;
}
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.login-form-item {
display: flex;
flex-direction: column;
gap: 8px;
position: relative;
}
.login-form-label {
font-size: 14px;
color: #2c2c2c;
font-weight: 500;
font-family: SimSun, "Songti SC", serif;
}
.login-form-input {
width: 100%;
height: 44px;
padding: 0 16px;
background: #fff;
border: 1px solid #dcd3c9;
border-radius: 8px;
font-size: 14px;
color: #2c2c2c;
font-family: SimSun, "Songti SC", serif;
box-sizing: border-box;
}
.login-form-input:focus {
border-color: #8b2323;
outline: none;
}
.login-form-eye {
position: absolute;
right: 16px;
bottom: 12px;
cursor: pointer;
font-size: 18px;
}
.login-form-code {
display: flex;
gap: 10px;
align-items: center;
}
.login-form-code .login-form-input {
flex: 1;
}
.login-code-btn {
flex-shrink: 0;
height: 44px;
padding: 0 16px;
background: #8b2323;
color: #fff;
border: none;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
white-space: nowrap;
font-family: SimSun, "Songti SC", serif;
}
.login-code-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-code-text {
font-size: 13px;
}
.login-forgot {
text-align: right;
margin-top: -10px;
cursor: pointer;
}
.login-forgot-text {
font-size: 13px;
color: #8b2323;
font-family: SimSun, "Songti SC", serif;
}
.login-back {
text-align: center;
margin-top: 10px;
cursor: pointer;
}
.login-back-text {
font-size: 14px;
color: #8b2323;
font-family: SimSun, "Songti SC", serif;
}
.login-btn {
width: 100%;
height: 48px;
border-radius: 24px;
border: none;
font-size: 16px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
font-family: SimSun, "Songti SC", serif;
cursor: pointer;
margin-top: 10px;
}
.login-btn-primary {
background: linear-gradient(135deg, #8b2323 0%, #9c2a2a 100%);
color: #fff;
}
.login-btn-primary:active {
background: linear-gradient(135deg, #701c1c 0%, #8b2323 100%);
transform: scale(0.98);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-btn-text {
font-size: 16px;
}
.login-agreement {
margin-top: 30px;
text-align: center;
}
.login-agreement-text {
font-size: 12px;
color: #8a8a8a;
font-family: SimSun, "Songti SC", serif;
}
.login-agreement-link {
color: #8b2323;
cursor: pointer;
text-decoration: underline;
}
.login-agreement-link:hover {
opacity: 0.8;
}
</style>

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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
<template>
<view class="h-full flex flex-col items-center justify-center bg-[#fdfbf7] text-[#2c2c2c] font-serif relative overflow-hidden">
<view class="absolute inset-0 opacity-10 pointer-events-none bg-[url('https://www.transparenttextures.com/patterns/rice-paper.png')]"></view>
<view class="relative z-10 text-center px-8">
<text class="block text-4xl mb-3 text-[#8b2323]"></text>
<text class="block text-xl font-bold tracking-[0.3em] mb-2">智能起名模块</text>
<text class="block text-sm text-[#5a5a5a]">稍后将迁移完整表单与生成结果</text>
</view>
</view>
</template>

View File

@@ -0,0 +1,391 @@
<template>
<view class="solutions-screen">
<view class="solutions-bg"></view>
<view class="status-bar-placeholder"></view>
<view class="solutions-header">
<view class="solutions-back-btn" @click="$emit('back')">
<text class="solutions-back-icon"></text>
</view>
<view class="solutions-header-center">
<text class="solutions-title">起名方案列表</text>
</view>
<view class="solutions-header-placeholder"></view>
</view>
<scroll-view scroll-y class="solutions-scroll">
<view class="solutions-content">
<view v-if="!solutions.length" class="solutions-empty">
<text class="solutions-empty-icon"></text>
<text class="solutions-empty-text">暂无方案</text>
</view>
<view
v-for="(it, idx) in solutions"
:key="String(it?.id || it?.solution_id || idx)"
class="solutions-item"
:style="{ animationDelay: (idx * 0.04) + 's' }"
@click="open(it)"
>
<view class="solutions-item-main">
<view class="solutions-item-header">
<view>
<text class="solutions-item-name">{{ titleOf(it) }}</text>
<text class="solutions-item-pinyin">{{ pinyinOf(it) }}</text>
</view>
<view class="solutions-item-actions">
<view class="solutions-item-view-btn" @click.stop="open(it)">
<text class="solutions-item-view-icon"></text>
<text class="solutions-item-view-text">查看</text>
</view>
<view class="solutions-item-badge">
<text class="solutions-item-badge-text">{{ idx + 1 }}</text>
</view>
</view>
</view>
<view class="solutions-item-meta">
<text class="solutions-tag">{{ tagAOf(it) }}</text>
<text class="solutions-tag">{{ tagBOf(it) }}</text>
</view>
<view class="solutions-item-meta-sub">
<text v-if="scoreOf(it)" class="solutions-chip">评分 {{ scoreOf(it) }}</text>
</view>
<view v-if="poetryOf(it)" class="solutions-item-poetry">
<text class="solutions-item-poetry-label">出处</text>
<text class="solutions-item-poetry-text">{{ poetryOf(it) }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { namingApi } from "@/api/naming";
import { parseMaybeJson } from "@/utils/poll-test-solution-detail";
declare const uni: any;
const props = defineProps<{
reportId: number;
solutions: any[];
category?: string;
serviceType?: string;
}>();
const emit = defineEmits<{
back: [];
showDetail: [data: any, category?: string, serviceType?: string];
}>();
const solutions = computed(() => (Array.isArray(props.solutions) ? props.solutions : []));
const titleOf = (it: any) => {
const name = String(it?.name || it?.solution_name || it?.title || it?.label || "").trim();
if (name) return name;
const fallback = String(it?.given_name || it?.full_name || it?.company_name || "").trim();
return fallback || "方案";
};
const pinyinOf = (it: any) => String(it?.pinyin || it?.name_pinyin || "").trim() || "Pīn Yīn";
const tagAOf = (it: any) => String(it?.wuxing || it?.wuxing_tag || it?.element_tag || "五行均衡");
const tagBOf = (it: any) => String(it?.style || it?.style_tag || it?.feature_tag || "温文尔雅");
const poetryOf = (it: any) => String(it?.poetry_source || it?.poetry || it?.source || "").trim();
const scoreOf = (it: any) => {
const v = it?.total_score ?? it?.score;
if (v === null || v === undefined || v === "") return "";
return String(v);
};
const open = async (it: any) => {
const id = it?.id || it?.solution_id;
if (!id) {
uni.showToast({ title: "方案ID不存在", icon: "none" });
return;
}
try {
uni.showLoading({ title: "加载中..." });
const detailRaw: any = await namingApi.getSolutionDetail(id);
uni.hideLoading();
const parsed = parseMaybeJson(detailRaw);
if (!parsed || typeof parsed !== "object") {
uni.showToast({ title: "详情数据格式异常", icon: "none" });
return;
}
emit("showDetail", parsed, props.category, String(props.serviceType || ""));
} catch (e: any) {
uni.hideLoading();
uni.showToast({ title: e?.msg || e?.message || "加载失败", icon: "none" });
}
};
</script>
<style scoped>
.solutions-screen{
min-height: 100%;
position: relative;
overflow: hidden;
}
.solutions-bg{
position: fixed;
inset: 0;
background:
/* 纸张底色 */
radial-gradient(1200px 900px at 30% 20%, rgba(255,255,255,.92), rgba(245,241,232,.88) 45%, rgba(235,229,214,.92) 100%),
/* 纸纹颗粒 */
radial-gradient(2px 2px at 12% 18%, rgba(0,0,0,.06), transparent 55%),
radial-gradient(2px 2px at 48% 62%, rgba(0,0,0,.05), transparent 55%),
radial-gradient(2px 2px at 78% 34%, rgba(0,0,0,.04), transparent 55%),
radial-gradient(2px 2px at 32% 84%, rgba(0,0,0,.04), transparent 55%),
/* 墨韵晕染 */
radial-gradient(900px 520px at 12% 8%, rgba(25,28,33,.10), transparent 60%),
radial-gradient(760px 520px at 92% 22%, rgba(120,75,40,.08), transparent 62%),
radial-gradient(900px 640px at 40% 92%, rgba(90,35,35,.08), transparent 62%),
linear-gradient(180deg, #f6f2e8 0%, #efe7d6 60%, #f6f2e8 100%);
z-index: -1;
}
.status-bar-placeholder{
height: 24px;
}
.solutions-header{
display:flex;
align-items:center;
justify-content:space-between;
padding: 10px 14px 12px;
}
.solutions-back-btn{
width: 36px;
height: 36px;
border-radius: 12px;
border: 1px solid rgba(120,90,40,.26);
background: linear-gradient(180deg, rgba(255,255,255,.70), rgba(244,236,220,.65));
box-shadow: 0 6px 16px rgba(40,30,20,.10);
display:flex;
align-items:center;
justify-content:center;
}
.solutions-back-icon{
font-size: 22px;
color: rgba(82,60,28,.92);
line-height: 1;
}
.solutions-header-center{
display:flex;
flex-direction:column;
align-items:center;
gap: 3px;
}
.solutions-title{
font-size: 17px;
font-weight: 900;
color: rgba(28,24,20,.92);
letter-spacing: .08em;
font-family: "STSong","Songti SC","SimSun","STSong","Noto Serif SC",serif;
}
.solutions-subtitle{
font-size: 11px;
color: rgba(70,58,44,.72);
font-family: "STSong","Songti SC","SimSun","STSong","Noto Serif SC",serif;
}
.solutions-header-placeholder{
width: 36px;
height: 36px;
}
.solutions-scroll{
height: calc(100vh - 24px - 58px);
}
.solutions-content{
padding: 2px 14px 24px;
box-sizing:border-box;
}
.solutions-empty{
padding: 26px 10px;
border-radius: 18px;
border: 1px solid rgba(120,90,40,.18);
background: linear-gradient(180deg, rgba(255,255,255,.76), rgba(247,239,224,.66));
box-shadow: 0 10px 26px rgba(40,30,20,.10);
text-align:center;
}
.solutions-empty-icon{
display:block;
font-size: 18px;
color: rgba(132,98,52,.85);
margin-bottom: 6px;
}
.solutions-empty-text{
font-size: 13px;
color: rgba(50,40,28,.78);
font-family: "STSong","Songti SC","SimSun","STSong","Noto Serif SC",serif;
}
.solutions-item{
position: relative;
display:flex;
align-items:stretch;
justify-content:space-between;
border-radius: 18px;
border: 1px solid rgba(178,120,120,.45);
background:
linear-gradient(180deg, rgba(255,255,255,.84), rgba(249,243,232,.78));
overflow:hidden;
padding: 14px 14px 13px 14px;
margin-bottom: 14px;
animation: solFadeUp .22s ease both;
box-shadow:
0 14px 30px rgba(40,30,20,.14),
inset 0 1px 0 rgba(255,255,255,.55);
}
.solutions-item-main{
flex: 1;
}
.solutions-item-header{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap: 10px;
}
.solutions-item-name{
font-size: 18px;
font-weight: 900;
color: #20252e;
letter-spacing: .04em;
font-family: "STSong","Songti SC","SimSun","STSong","Noto Serif SC",serif;
display: block;
}
.solutions-item-pinyin{
display: block;
margin-top: 4px;
font-size: 12px;
color: rgba(32,37,46,.72);
letter-spacing: .08em;
}
.solutions-item-badge{
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px solid rgba(202,166,96,.62);
background:
linear-gradient(180deg, rgba(255,245,220,.90), rgba(248,229,184,.82));
display:flex;
align-items:center;
justify-content:center;
flex: 0 0 24px;
}
.solutions-item-badge-text{
font-size: 11px;
font-weight: 900;
color: rgba(128,96,42,.92);
}
.solutions-item-actions{
display: flex;
align-items: center;
gap: 8px;
}
.solutions-item-view-btn{
height: 26px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(188,170,136,.78);
background: rgba(255,255,255,.70);
display:flex;
align-items:center;
gap: 4px;
box-shadow: 0 2px 6px rgba(40,30,20,.08);
}
.solutions-item-view-icon{
font-size: 12px;
color: rgba(55,60,68,.75);
}
.solutions-item-view-text{
font-size: 12px;
font-weight: 700;
color: rgba(55,60,68,.85);
}
.solutions-item-meta{
display:flex;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.solutions-tag{
font-size: 11px;
padding: 3px 8px;
border-radius: 7px;
border: 1px solid rgba(188,114,114,.52);
color: rgba(130,58,58,.90);
background: rgba(255,250,248,.78);
font-family: "STSong","Songti SC","SimSun","STSong","Noto Serif SC",serif;
}
.solutions-item-meta-sub{
display:flex;
gap: 8px;
margin-top: 9px;
flex-wrap: wrap;
}
.solutions-chip{
font-size: 10px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid rgba(196,168,106,.42);
color: rgba(128,96,42,.88);
background: rgba(250,239,211,.65);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.solutions-item-poetry{
margin-top: 8px;
display:flex;
align-items:flex-start;
gap: 8px;
}
.solutions-item-poetry-label{
flex: 0 0 auto;
font-size: 11px;
line-height: 1.5;
color: rgba(130,58,58,.90);
border: 1px solid rgba(188,114,114,.50);
border-radius: 6px;
padding: 1px 6px;
background: rgba(255,250,248,.78);
}
.solutions-item-poetry-text{
flex: 1;
font-size: 12px;
line-height: 1.6;
color: rgba(40,34,26,.82);
font-family: "STSong","Songti SC","SimSun","STSong","Noto Serif SC",serif;
}
@keyframes solFadeUp{
from{opacity:0;transform:translateY(6px)}
to{opacity:1;transform:translateY(0)}
}
/* 细节:内侧金线与“卷轴边” */
.solutions-item::before{
content:"";
position:absolute;
inset: 9px 9px 9px 9px;
border-radius: 12px;
border: 1px solid rgba(188,170,136,.28);
pointer-events:none;
}
.solutions-item::after{
content:"";
position:absolute;
top:-40px;
right:-60px;
width: 160px;
height: 160px;
background: radial-gradient(closest-side, rgba(188,114,114,.08), transparent 70%);
transform: rotate(18deg);
pointer-events:none;
}
/* 轻微按压反馈 */
.solutions-item:active{
transform: translateY(1px);
box-shadow:
0 10px 22px rgba(40,30,20,.12),
inset 0 1px 0 rgba(255,255,255,.55);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,351 @@
<template>
<view class="privacy-screen">
<view class="privacy-header">
<view class="privacy-back" @click="handleBack">
<text class="privacy-back-icon"></text>
</view>
<text class="privacy-title">隐私政策</text>
</view>
<view v-if="loading" class="privacy-loading">
<text class="privacy-loading-text">加载中...</text>
</view>
<view v-else-if="error" class="privacy-error">
<text class="privacy-error-text">{{ error }}</text>
<button class="privacy-retry-btn" @click="loadPrivacyPolicy">
<text class="privacy-retry-text">重试</text>
</button>
</view>
<view v-else class="privacy-content">
<view v-if="policy" class="privacy-info">
<text class="privacy-version">版本{{ policy.version }}</text>
<text class="privacy-date">生效日期{{ policy.effective_date }}</text>
</view>
<view v-if="policy" class="privacy-body" v-html="policy.content"></view>
<view v-if="!policy" class="privacy-default">
<view class="privacy-section">
<text class="privacy-section-title">引言</text>
<text class="privacy-text">
壹梵起名以下简称"我们"非常重视用户的隐私保护本隐私政策旨在向您说明我们如何收集使用存储和保护您的个人信息
</text>
</view>
<view class="privacy-section">
<text class="privacy-section-title">我们收集的信息</text>
<text class="privacy-text">
1. 账号信息手机号码密码等注册信息
</text>
<text class="privacy-text">
2. 服务信息您在使用起名测名等服务时提供的姓名出生日期性别等信息
</text>
<text class="privacy-text">
3. 设备信息设备型号操作系统版本设备标识符等
</text>
<text class="privacy-text">
4. 日志信息IP地址访问时间浏览记录等
</text>
</view>
<view class="privacy-section">
<text class="privacy-section-title">信息的使用</text>
<text class="privacy-text">
我们收集的信息将用于
</text>
<text class="privacy-text">
1. 提供维护和改进我们的服务
</text>
<text class="privacy-text">
2. 处理您的订单和支付
</text>
<text class="privacy-text">
3. 向您发送服务通知和更新
</text>
<text class="privacy-text">
4. 保护服务安全防止欺诈
</text>
<text class="privacy-text">
5. 遵守法律法规要求
</text>
</view>
<view class="privacy-section">
<text class="privacy-section-title">信息的共享</text>
<text class="privacy-text">
我们不会向第三方出售出租或以其他方式披露您的个人信息除非
</text>
<text class="privacy-text">
1. 获得您的明确同意
</text>
<text class="privacy-text">
2. 法律法规要求
</text>
<text class="privacy-text">
3. 为提供服务所必需如支付服务提供商
</text>
<text class="privacy-text">
4. 保护我们或他人的合法权益
</text>
</view>
<view class="privacy-section">
<text class="privacy-section-title">信息的存储</text>
<text class="privacy-text">
您的个人信息将存储在中华人民共和国境内的服务器上我们将采取合理的安全措施保护您的信息包括加密存储访问控制等
</text>
</view>
<view class="privacy-section">
<text class="privacy-section-title">您的权利</text>
<text class="privacy-text">
您有权
</text>
<text class="privacy-text">
1. 访问更正或删除您的个人信息
</text>
<text class="privacy-text">
2. 撤回您的同意
</text>
<text class="privacy-text">
3. 注销您的账号
</text>
<text class="privacy-text">
4. 投诉或举报
</text>
</view>
<view class="privacy-section">
<text class="privacy-section-title">未成年人保护</text>
<text class="privacy-text">
我们非常重视未成年人的个人信息保护如果您是未成年人请在监护人的陪同下阅读本政策并在监护人同意的情况下使用我们的服务
</text>
</view>
<view class="privacy-section">
<text class="privacy-section-title">政策更新</text>
<text class="privacy-text">
我们可能会不时更新本隐私政策更新后的政策将在平台上公布并在您继续使用服务时生效
</text>
</view>
<view class="privacy-section">
<text class="privacy-section-title">联系我们</text>
<text class="privacy-text">
如果您对本隐私政策有任何疑问或建议请通过平台内的反馈功能联系我们
</text>
</view>
<view class="privacy-footer">
<text class="privacy-footer-text">壹梵起名</text>
<text class="privacy-footer-text">生效日期2024年1月1日</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { userApi } from '@/api';
import type { PrivacyPolicyResponse } from '@/api/types';
const router = useRouter();
const loading = ref(true);
const error = ref('');
const policy = ref<PrivacyPolicyResponse | null>(null);
const handleBack = () => {
router.back();
};
const loadPrivacyPolicy = async () => {
loading.value = true;
error.value = '';
try {
const result = await userApi.getPrivacyPolicy();
policy.value = result;
} catch (err: any) {
console.error('加载隐私政策失败:', err);
error.value = err.msg || '加载失败,请重试';
} finally {
loading.value = false;
}
};
onMounted(() => {
loadPrivacyPolicy();
});
</script>
<style scoped>
.privacy-screen {
min-height: 100vh;
width: 100%;
background: #fdfbf7 url("https://www.transparenttextures.com/patterns/rice-paper.png");
display: flex;
flex-direction: column;
overflow-x: hidden;
}
.privacy-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
padding: 16px 20px;
background: rgba(253, 251, 247, 0.95);
border-bottom: 1px solid #eaddcf;
backdrop-filter: blur(10px);
flex-shrink: 0;
}
.privacy-back {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-right: 12px;
flex-shrink: 0;
}
.privacy-back-icon {
font-size: 24px;
color: #8b2323;
font-weight: bold;
}
.privacy-title {
font-size: 18px;
font-weight: 500;
color: #2c2c2c;
font-family: SimSun, "Songti SC", serif;
}
.privacy-loading,
.privacy-error {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.privacy-loading-text,
.privacy-error-text {
font-size: 14px;
color: #8a8a8a;
font-family: SimSun, "Songti SC", serif;
margin-bottom: 16px;
}
.privacy-retry-btn {
padding: 8px 24px;
background: #8b2323;
color: #fff;
border: none;
border-radius: 20px;
font-size: 14px;
cursor: pointer;
font-family: SimSun, "Songti SC", serif;
}
.privacy-content {
flex: 1;
padding: 24px 20px 40px;
overflow-y: auto;
overflow-x: hidden;
width: 100%;
box-sizing: border-box;
}
.privacy-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(139, 35, 35, 0.05);
border-radius: 8px;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 8px;
}
.privacy-version,
.privacy-date {
font-size: 13px;
color: #8b2323;
font-family: SimSun, "Songti SC", serif;
}
.privacy-body {
font-size: 14px;
line-height: 1.8;
color: #4a4a4a;
font-family: SimSun, "Songti SC", serif;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
}
.privacy-default {
display: flex;
flex-direction: column;
width: 100%;
}
.privacy-section {
margin-bottom: 24px;
width: 100%;
}
.privacy-section-title {
display: block;
font-size: 16px;
font-weight: 600;
color: #8b2323;
margin-bottom: 12px;
font-family: SimSun, "Songti SC", serif;
word-wrap: break-word;
word-break: break-word;
}
.privacy-text {
display: block;
font-size: 14px;
line-height: 1.8;
color: #4a4a4a;
margin-bottom: 8px;
font-family: SimSun, "Songti SC", serif;
word-wrap: break-word;
word-break: break-word;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.privacy-footer {
margin-top: 40px;
padding-top: 24px;
border-top: 1px solid #eaddcf;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.privacy-footer-text {
display: block;
font-size: 13px;
color: #8a8a8a;
font-family: SimSun, "Songti SC", serif;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,416 @@
<template>
<view class="faq-screen">
<view class="faq-bg"></view>
<!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view>
<!-- Header -->
<view class="faq-header">
<view class="faq-back-btn" @click="handleBack">
<text class="faq-back-icon"></text>
</view>
<text class="faq-title">常见问题</text>
<view class="faq-header-placeholder"></view>
</view>
<!-- Content -->
<scroll-view scroll-y class="faq-content">
<view class="faq-content-inner">
<view v-if="loading" class="faq-loading">
<text class="faq-loading-text">加载中...</text>
</view>
<template v-else-if="groups.length > 0">
<view v-for="(group, groupIndex) in groups" :key="groupIndex" class="faq-group">
<view class="faq-group-header">
<text class="faq-group-title">{{ group.category_name }}</text>
</view>
<view class="faq-list">
<view v-for="(item, itemIndex) in group.items" :key="item.id" class="faq-item"
:class="{ 'faq-item-expanded': expandedItems[item.id] }" @click="toggleItem(item.id)">
<view class="faq-question">
<text class="faq-question-icon">Q</text>
<view class="faq-question-content">
<text class="faq-question-text">{{ item.question }}</text>
<text v-if="item.is_hot === 1" class="faq-hot-badge">HOT</text>
</view>
<text class="faq-question-arrow"
:class="{ 'faq-question-arrow-expanded': expandedItems[item.id] }"></text>
</view>
<view v-if="expandedItems[item.id]" class="faq-answer">
<text class="faq-answer-icon">A</text>
<text class="faq-answer-text">{{ item.answer }}</text>
</view>
</view>
</view>
</view>
</template>
<view v-else class="faq-empty">
<text class="faq-empty-icon">📋</text>
<text class="faq-empty-text">暂无常见问题</text>
</view>
<!-- 联系客服 -->
<view class="faq-contact">
<text class="faq-contact-title">没有找到答案</text>
<button class="faq-contact-btn" @click="handleContact">
<text class="faq-contact-btn-text">联系客服</text>
</button>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import { userApi } from "@/api";
import type { FAQGroup } from "@/api/types";
declare const uni: any;
const emit = defineEmits<{
back: [];
}>();
const loading = ref(false);
const groups = ref<FAQGroup[]>([]);
const expandedItems = reactive<Record<number, boolean>>({});
const loadFAQ = async () => {
loading.value = true;
try {
const res = await userApi.getFAQ();
console.log('getFAQ response:', res);
// API返回的是data数组不是groups
groups.value = res?.data || (Array.isArray(res) ? res : []);
} catch (e: any) {
console.error('loadFAQ error:', e);
uni.showToast({ title: e.msg || "加载失败", icon: "none" });
} finally {
loading.value = false;
}
};
const toggleItem = (id: number) => {
expandedItems[id] = !expandedItems[id];
};
const handleContact = () => {
// Web环境使用alertuni-app环境使用showModal
if (typeof uni?.showModal === 'function') {
uni.showModal({
title: '联系客服',
content: '客服微信yifan_service\n工作时间9:00-18:00',
showCancel: false,
});
} else {
// Web环境使用原生alert
alert('联系客服\n\n客服微信yifan_service\n工作时间9:00-18:00');
}
};
const handleBack = () => {
emit('back');
};
onMounted(() => loadFAQ());
</script>
<style scoped>
.faq-screen {
height: 100%;
display: flex;
flex-direction: column;
background-color: #f0efe9;
position: relative;
}
.status-bar-placeholder {
height: var(--status-bar-height, 0);
width: 100%;
flex-shrink: 0;
}
.faq-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");
}
/* Header */
.faq-header {
position: relative;
z-index: 10;
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.8);
backdrop-filter: blur(10rpx);
}
.faq-back-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
padding: 0;
margin-left: -16rpx;
}
.faq-back-icon {
font-size: 48rpx;
color: #5a5a5a;
font-weight: 300;
}
.faq-title {
font-size: 32rpx;
font-weight: 700;
color: #2c2c2c;
letter-spacing: 0.2em;
}
.faq-header-placeholder {
width: 64rpx;
}
/* Content */
.faq-content {
flex: 1;
height: 0;
position: relative;
z-index: 10;
}
.faq-content-inner {
padding: 32rpx;
}
/* Loading */
.faq-loading {
display: flex;
align-items: center;
justify-content: center;
height: 400rpx;
}
.faq-loading-text {
font-size: 28rpx;
color: #999;
}
/* Group */
.faq-group {
margin-bottom: 32rpx;
}
.faq-group-header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
padding: 0 8rpx;
}
.faq-group-icon {
font-size: 28rpx;
}
.faq-group-title {
font-size: 28rpx;
font-weight: 700;
color: #8b2323;
}
/* List */
.faq-list {
background-color: #fffdf9;
border-radius: 24rpx;
border: 1rpx solid #e5e5e5;
overflow: hidden;
}
.faq-item {
border-bottom: 1rpx solid #f0f0f0;
transition: background-color 0.2s;
}
.faq-item:last-child {
border-bottom: none;
}
.faq-item:active {
background-color: #fafafa;
}
.faq-item-expanded {
background-color: #fafafa;
}
/* Question */
.faq-question {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
gap: 16rpx;
}
.faq-question-icon {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background-color: #8b2323;
color: #fff;
font-size: 24rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.faq-question-text {
flex: 1;
font-size: 28rpx;
color: #2c2c2c;
font-weight: 500;
}
.faq-question-content {
flex: 1;
display: flex;
align-items: center;
gap: 12rpx;
}
.faq-hot-badge {
font-size: 18rpx;
background: linear-gradient(135deg, #ff6b6b 0%, #ff4757 100%);
color: #fff;
padding: 4rpx 12rpx;
border-radius: 8rpx;
font-weight: 700;
flex-shrink: 0;
}
.faq-question-arrow {
font-size: 32rpx;
color: #ccc;
transition: transform 0.3s;
flex-shrink: 0;
}
.faq-question-arrow-expanded {
transform: rotate(90deg);
}
/* Answer */
.faq-answer {
display: flex;
padding: 0 32rpx 24rpx 32rpx;
gap: 16rpx;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.faq-answer-icon {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background-color: #d4af37;
color: #fff;
font-size: 24rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.faq-answer-text {
flex: 1;
font-size: 26rpx;
color: #5a5a5a;
line-height: 1.8;
padding-top: 8rpx;
white-space: pre-line;
}
/* Empty */
.faq-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400rpx;
}
.faq-empty-icon {
font-size: 96rpx;
opacity: 0.3;
margin-bottom: 24rpx;
}
.faq-empty-text {
font-size: 28rpx;
color: #999;
}
/* Contact */
.faq-contact {
margin-top: 32rpx;
padding: 32rpx;
background-color: #fffdf9;
border-radius: 24rpx;
border: 1rpx solid #e5e5e5;
text-align: center;
}
.faq-contact-title {
font-size: 28rpx;
color: #2c2c2c;
margin-bottom: 24rpx;
display: block;
}
.faq-contact-btn {
width: 100%;
padding: 24rpx 0;
background-color: #8b2323;
border-radius: 16rpx;
border: none;
}
.faq-contact-btn-text {
font-size: 28rpx;
font-weight: 700;
color: #d4af37;
}
</style>

View File

@@ -0,0 +1,556 @@
<template>
<view class="favorites-screen">
<view class="favorites-bg"></view>
<!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view>
<!-- Header -->
<view class="favorites-header">
<view class="favorites-back-btn" @click="handleBack">
<text class="favorites-back-icon"></text>
</view>
<text class="favorites-title">我的收藏</text>
<view class="favorites-header-placeholder"></view>
</view>
<!-- Tabs -->
<view class="favorites-tabs">
<view class="favorites-tabs-container">
<view v-for="tab in tabs" :key="tab.value" class="favorites-tab-btn"
:class="{ 'favorites-tab-btn-active': activeTab === tab.value }" @click="switchTab(tab.value)">
<text class="favorites-tab-text" :class="{ 'favorites-tab-text-active': activeTab === tab.value }">
{{ tab.label }}
</text>
</view>
</view>
</view>
<!-- List -->
<scroll-view scroll-y class="favorites-list" @scrolltolower="loadMore">
<view class="favorites-list-inner">
<view v-if="loading && filteredList.length === 0" class="favorites-loading">
<text class="favorites-loading-text">加载中...</text>
</view>
<template v-else-if="filteredList.length > 0">
<view v-for="(item, index) in filteredList" :key="item.id" class="favorites-item"
:style="{ animationDelay: (index * 0.05) + 's' }" @click="viewDetail(item)">
<!-- Left Border Accent -->
<view class="favorites-item-border"
:class="item.category === 'company' ? 'favorites-item-border-gold' : 'favorites-item-border-red'"></view>
<view class="favorites-item-main">
<view class="favorites-item-header">
<text class="favorites-item-name">{{ item.name || '未命名' }}</text>
<view class="favorites-item-type-icon">
<text class="favorites-item-type-text">{{ item.category === 'company' ? '企' : '名' }}</text>
</view>
</view>
<text v-if="item.pinyin" class="favorites-item-pinyin">{{ item.pinyin }}</text>
<view class="favorites-item-bottom">
<view class="favorites-item-tags">
<text v-for="(tag, idx) in item.tags" :key="idx" class="favorites-item-tag"
:class="item.category === 'company' ? 'favorites-item-tag-gold' : 'favorites-item-tag-red'">
{{ tag }}
</text>
</view>
<text class="favorites-item-date-inline">{{ formatDate(item.created_time) }}</text>
</view>
</view>
<view class="favorites-item-right">
<view class="favorites-item-heart">
<text class="favorites-item-heart-icon"></text>
</view>
</view>
</view>
<view v-if="hasMore" class="favorites-load-more">
<text class="favorites-load-more-text">{{ loading ? '加载中...' : '上拉加载更多' }}</text>
</view>
<view v-else class="favorites-no-more">
<text class="favorites-no-more-text"> 已加载全部 </text>
</view>
</template>
<template v-else>
<view class="favorites-empty">
<text class="favorites-empty-icon"></text>
<text class="favorites-empty-text">暂无收藏</text>
</view>
</template>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { userApi } from "@/api";
import type { MyFavoriteItem } from "@/api/types";
declare const uni: any;
const emit = defineEmits<{
back: [];
navigate: [screen: string];
showDetail: [data: any, category?: string, serviceType?: string];
}>();
const loading = ref(false);
const list = ref<MyFavoriteItem[]>([]);
const pageNo = ref(1);
const pageSize = 10;
const total = ref(0);
const hasMore = ref(true);
const activeTab = ref<'all' | 'personal' | 'company'>('all');
const tabs = [
{ value: 'all' as const, label: '全部' },
{ value: 'personal' as const, label: '个人名' },
{ value: 'company' as const, label: '商号' },
];
// 根据tab过滤列表
const filteredList = computed(() => {
if (activeTab.value === 'all') return list.value;
return list.value.filter(item => item.category === activeTab.value);
});
const loadList = async (refresh = false) => {
if (loading.value) return;
if (!refresh && !hasMore.value) return;
loading.value = true;
try {
if (refresh) {
pageNo.value = 1;
list.value = [];
}
const res = await userApi.getMyFavorites({
page_no: pageNo.value,
page_size: pageSize,
category: activeTab.value === 'all' ? undefined : activeTab.value,
});
console.log('getMyFavorites response:', res);
const items = res?.items || (Array.isArray(res) ? res : []);
list.value = refresh ? items : [...list.value, ...items];
total.value = res?.total || items.length;
hasMore.value = list.value.length < total.value;
pageNo.value++;
} catch (e: any) {
console.error('loadList error:', e);
uni.showToast({ title: e.msg || "加载失败", icon: "none" });
} finally {
loading.value = false;
}
};
const switchTab = (tab: 'all' | 'personal' | 'company') => {
activeTab.value = tab;
loadList(true);
};
const loadMore = () => {
if (!loading.value && hasMore.value) loadList();
};
const parseMaybeJson = (value: any): 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;
}
};
const viewDetail = async (item: MyFavoriteItem) => {
if (!item.solution_id) {
uni.showToast({ title: "方案ID不存在", icon: "none" });
return;
}
try {
const detailData = await userApi.getSolutionDetail(item.solution_id);
const parsedData = parseMaybeJson(detailData);
const serviceType = String((item as any)?.service_type || '');
emit('showDetail', parsedData, item.category, serviceType);
} catch (e: any) {
uni.showToast({ title: e.msg || "加载详情失败", icon: "none" });
}
};
const handleBack = () => {
emit('back');
};
const formatDate = (dateStr: string) => {
if (!dateStr) return "";
// 处理 created_time 格式 "2026-01-13"
return dateStr.replace(/-/g, ".");
};
onMounted(() => loadList(true));
</script>
<style scoped>
.favorites-screen {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
background-color: #f0efe9;
position: relative;
font-family: SimSun, "Songti SC", "Songti TC", "Noto Serif SC", STSong, serif;
}
/* 强制所有容器全宽 */
:deep(*) {
box-sizing: border-box;
}
:deep(.shell) {
width: 100% !important;
max-width: 100% !important;
padding: 0 !important;
margin: 0 !important;
}
.status-bar-placeholder {
height: var(--status-bar-height, 0);
width: 100%;
flex-shrink: 0;
}
.favorites-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");
}
/* Header */
.favorites-header {
position: relative;
z-index: 10;
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid #dcd3c9;
background-color: rgba(253, 251, 247, 0.8);
backdrop-filter: blur(5px);
}
.favorites-back-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
padding: 0;
margin-left: -8px;
}
.favorites-back-icon {
font-size: 24px;
color: #5a5a5a;
font-weight: 300;
}
.favorites-title {
font-size: 16px;
font-weight: 700;
color: #2c2c2c;
letter-spacing: 0.2em;
}
.favorites-header-placeholder {
width: 32px;
}
/* Tabs */
.favorites-tabs {
position: relative;
z-index: 10;
padding: 12px 16px;
background-color: #fdfbf7;
}
.favorites-tabs-container {
display: flex;
background-color: #f0efe9;
padding: 4px;
border-radius: 8px;
}
.favorites-tab-btn {
flex: 1;
padding: 8px 0;
text-align: center;
border-radius: 6px;
transition: all 0.3s;
}
.favorites-tab-btn-active {
background-color: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.favorites-tab-text {
font-size: 12px;
font-weight: 700;
color: #999;
}
.favorites-tab-text-active {
color: #8b2323;
}
/* List */
.favorites-list {
flex: 1;
height: 0;
position: relative;
z-index: 10;
margin-top: 8px;
}
.favorites-list-inner {
padding: 16px;
padding-top: 0;
}
.favorites-item {
position: relative;
display: flex;
align-items: center;
background-color: #fffdf9;
padding: 16px;
border-radius: 12px;
border: 1px solid #e5e5e5;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
margin-bottom: 12px;
animation: fadeInUp 0.3s ease-out forwards;
opacity: 0;
overflow: hidden;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.favorites-item-border {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
}
.favorites-item-border-red {
background-color: #8b2323;
}
.favorites-item-border-gold {
background-color: #d4af37;
}
.favorites-item-main {
flex: 1;
min-width: 0;
padding-left: 8px;
}
.favorites-item-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.favorites-item-name {
font-size: 18px;
font-weight: 700;
color: #2c2c2c;
font-family: "SimSun", "Songti SC", serif;
}
.favorites-item-type-icon {
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
}
.favorites-item-type-text {
font-size: 9px;
color: #999;
}
.favorites-item-pinyin {
font-size: 11px;
color: #999;
margin-bottom: 8px;
display: block;
font-style: italic;
}
.favorites-item-bottom {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.favorites-item-category {
font-size: 11px;
color: #999;
margin-bottom: 8px;
display: block;
}
.favorites-item-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
flex: 1;
}
.favorites-item-tag {
font-size: 9px;
padding: 3px 8px;
border-radius: 4px;
background-color: #f5f5f5;
color: #5a5a5a;
white-space: nowrap;
}
.favorites-item-tag-red {
background-color: rgba(139, 35, 35, 0.05);
color: #8b2323;
}
.favorites-item-tag-gold {
background-color: rgba(212, 175, 55, 0.05);
color: #d4af37;
}
.favorites-item-date-inline {
font-size: 9px;
color: #ccc;
display: flex;
align-items: center;
white-space: nowrap;
flex-shrink: 0;
margin-left: 8px;
}
.favorites-item-right {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
margin-left: 12px;
}
.favorites-item-date {
font-size: 9px;
color: #ccc;
display: flex;
align-items: center;
}
.favorites-item-heart {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.favorites-item-heart-icon {
font-size: 16px;
color: #8b2323;
}
.favorites-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
}
.favorites-loading-text {
font-size: 14px;
color: #999;
}
.favorites-load-more,
.favorites-no-more {
text-align: center;
padding: 16px 0;
}
.favorites-load-more-text,
.favorites-no-more-text {
font-size: 12px;
color: #999;
}
.favorites-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 250px;
}
.favorites-empty-icon {
font-size: 48px;
opacity: 0.3;
margin-bottom: 12px;
}
.favorites-empty-text {
font-size: 14px;
color: #999;
margin-bottom: 16px;
}
.favorites-empty-btn {
background-color: #8b2323;
padding: 10px 32px;
border-radius: 4px;
border: none;
}
.favorites-empty-btn-text {
font-size: 14px;
color: #d4af37;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,477 @@
<template>
<view class="feedback-screen">
<view class="feedback-bg"></view>
<!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view>
<!-- Header -->
<view class="feedback-header">
<view class="feedback-back-btn" @click="handleBack">
<text class="feedback-back-icon"></text>
</view>
<text class="feedback-title">意见反馈</text>
<view class="feedback-header-placeholder"></view>
</view>
<!-- Content -->
<scroll-view scroll-y class="feedback-content">
<view class="feedback-content-inner">
<!-- 反馈类型 -->
<view class="feedback-section">
<text class="feedback-section-title">反馈类型</text>
<view class="feedback-type-list">
<view v-for="type in feedbackTypes" :key="type.value" class="feedback-type-item"
:class="{ 'feedback-type-item-active': selectedType === type.value }"
@click="selectType(type.value)">
<text class="feedback-type-label">{{ type.label }}</text>
</view>
</view>
</view>
<!-- 反馈内容 -->
<view class="feedback-section">
<text class="feedback-section-title">反馈内容</text>
<textarea class="feedback-textarea" v-model="feedbackContent"
placeholder="请详细描述您遇到的问题或建议,我们会认真对待每一条反馈..." :maxlength="500"
placeholder-class="feedback-textarea-placeholder" />
<view class="feedback-textarea-counter">
<text class="feedback-textarea-counter-text">{{ feedbackContent.length }}/500</text>
</view>
</view>
<!-- 上传图片 -->
<view class="feedback-section">
<text class="feedback-section-title">上传图片选填</text>
<view class="feedback-images">
<view v-for="(img, index) in uploadedImages" :key="index" class="feedback-image-item">
<image :src="img" class="feedback-image" mode="aspectFill" />
<view class="feedback-image-delete" @click="deleteImage(index)">
<text class="feedback-image-delete-icon">×</text>
</view>
</view>
<view v-if="uploadedImages.length < 3" class="feedback-image-upload" @click="chooseImage">
<text class="feedback-image-upload-icon">+</text>
<text class="feedback-image-upload-text">添加图片</text>
</view>
</view>
<text class="feedback-images-tip">最多上传3张图片每张不超过5MB</text>
</view>
<!-- 联系方式 -->
<view class="feedback-section">
<text class="feedback-section-title">联系方式选填</text>
<input class="feedback-input" v-model="contactInfo" placeholder="请输入手机号或微信号,方便我们联系您"
placeholder-class="feedback-input-placeholder" />
</view>
<!-- 提交按钮 -->
<view class="feedback-submit-btn" @click="submitFeedback">
<text class="feedback-submit-btn-text">提交反馈</text>
</view>
<!-- 温馨提示 -->
<view class="feedback-tips">
<text class="feedback-tips-title">温馨提示</text>
<text class="feedback-tips-text"> 我们会在1-3个工作日内处理您的反馈</text>
<text class="feedback-tips-text"> 如需回复请留下您的联系方式</text>
<text class="feedback-tips-text"> 感谢您对壹梵起名的支持与建议</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { userApi } from "@/api";
declare const uni: any;
const emit = defineEmits<{
back: [];
}>();
// 反馈类型
const feedbackTypes = [
{ value: 'suggestion', label: '功能建议', icon: '💡' },
{ value: 'bug', label: '问题反馈', icon: '🐛' },
{ value: 'complaint', label: '投诉建议', icon: '📢' },
{ value: 'other', label: '其他', icon: '💬' },
];
const selectedType = ref<'suggestion' | 'bug' | 'complaint' | 'other'>('suggestion');
const feedbackContent = ref("");
const uploadedImages = ref<string[]>([]);
const contactInfo = ref("");
const selectType = (type: 'suggestion' | 'bug' | 'complaint' | 'other') => {
selectedType.value = type;
};
const chooseImage = async () => {
uni.chooseImage({
count: 3 - uploadedImages.value.length,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res: any) => {
const tempFilePaths = res.tempFilePaths;
uni.showLoading({ title: '上传中...' });
try {
// 逐个上传图片到服务器
for (const filePath of tempFilePaths) {
const result = await userApi.uploadImage(filePath);
// 使用服务器返回的file_url
uploadedImages.value.push(result.file_url);
}
uni.hideLoading();
} catch (error: any) {
uni.hideLoading();
uni.showToast({
title: error.msg || '图片上传失败',
icon: 'none'
});
}
}
});
};
const deleteImage = (index: number) => {
uploadedImages.value.splice(index, 1);
};
const submitFeedback = async () => {
if (!feedbackContent.value.trim()) {
uni.showToast({ title: "请输入反馈内容", icon: "none" });
return;
}
try {
await userApi.submitFeedback({
content: feedbackContent.value.trim(),
images: uploadedImages.value.join(','),
contact: contactInfo.value.trim(),
feedback_type: selectedType.value
});
uni.showToast({
title: "提交成功,感谢您的反馈!",
icon: "success",
duration: 2000
});
// 延迟返回,让用户看到成功提示
setTimeout(() => {
handleBack();
}, 2000);
} catch (error: any) {
uni.showToast({
title: error.msg || "提交失败,请稍后重试",
icon: "none"
});
}
};
const handleBack = () => {
emit('back');
};
</script>
<style scoped>
.feedback-screen {
height: 100%;
display: flex;
flex-direction: column;
background-color: #f0efe9;
position: relative;
}
.status-bar-placeholder {
height: var(--status-bar-height, 0);
width: 100%;
flex-shrink: 0;
}
.feedback-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");
}
/* Header */
.feedback-header {
position: relative;
z-index: 10;
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.8);
backdrop-filter: blur(10rpx);
}
.feedback-back-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
padding: 0;
margin-left: -16rpx;
}
.feedback-back-icon {
font-size: 48rpx;
color: #5a5a5a;
font-weight: 300;
}
.feedback-title {
font-size: 32rpx;
font-weight: 700;
color: #2c2c2c;
letter-spacing: 0.2em;
}
.feedback-header-placeholder {
width: 64rpx;
}
/* Content */
.feedback-content {
flex: 1;
height: 0;
position: relative;
z-index: 10;
}
.feedback-content-inner {
padding: 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom, 0px));
}
/* Section */
.feedback-section {
margin-bottom: 32rpx;
}
.feedback-section-title {
font-size: 28rpx;
font-weight: 700;
color: #2c2c2c;
margin-bottom: 16rpx;
display: block;
}
/* Type List */
.feedback-type-list {
display: flex;
gap: 16rpx;
flex-wrap: wrap;
}
.feedback-type-item {
flex: 1;
min-width: 150rpx;
padding: 20rpx 16rpx;
background-color: #fffdf9;
border: 2rpx solid #e5e5e5;
border-radius: 16rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
transition: all 0.3s;
}
.feedback-type-item-active {
background-color: #8b2323;
border-color: #8b2323;
}
.feedback-type-icon {
font-size: 32rpx;
}
.feedback-type-label {
font-size: 24rpx;
color: #2c2c2c;
}
.feedback-type-item-active .feedback-type-label {
color: #f2e6d8;
}
/* Textarea */
.feedback-textarea {
width: 100%;
min-height: 240rpx;
background-color: #fffdf9;
border: 1rpx solid #e5e5e5;
border-radius: 16rpx;
padding: 24rpx;
font-size: 28rpx;
color: #2c2c2c;
box-sizing: border-box;
line-height: 1.6;
}
.feedback-textarea-placeholder {
color: #ccc;
}
.feedback-textarea-counter {
margin-top: 8rpx;
text-align: right;
}
.feedback-textarea-counter-text {
font-size: 20rpx;
color: #999;
}
/* Images */
.feedback-images {
display: flex;
gap: 16rpx;
flex-wrap: wrap;
margin-bottom: 8rpx;
}
.feedback-image-item {
position: relative;
width: 160rpx;
height: 160rpx;
}
.feedback-image {
width: 100%;
height: 100%;
border-radius: 16rpx;
border: 1rpx solid #e5e5e5;
}
.feedback-image-delete {
position: absolute;
top: -8rpx;
right: -8rpx;
width: 40rpx;
height: 40rpx;
background-color: #8b2323;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.2);
}
.feedback-image-delete-icon {
font-size: 32rpx;
color: #fff;
line-height: 1;
}
.feedback-image-upload {
width: 160rpx;
height: 160rpx;
background-color: #fffdf9;
border: 2rpx dashed #e5e5e5;
border-radius: 16rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
}
.feedback-image-upload-icon {
font-size: 48rpx;
color: #ccc;
line-height: 1;
}
.feedback-image-upload-text {
font-size: 20rpx;
color: #999;
}
.feedback-images-tip {
font-size: 20rpx;
color: #999;
display: block;
}
/* Input */
.feedback-input {
width: 100%;
height: 88rpx;
background-color: #fffdf9;
border: 1rpx solid #e5e5e5;
border-radius: 16rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #2c2c2c;
box-sizing: border-box;
}
.feedback-input-placeholder {
color: #ccc;
}
/* Submit Button */
.feedback-submit-btn {
width: 100%;
padding: 28rpx 0;
background-color: #8b2323;
border-radius: 16rpx;
display: flex;
justify-content: center;
margin-bottom: 32rpx;
transition: opacity 0.3s;
}
.feedback-submit-btn:active {
opacity: 0.8;
}
.feedback-submit-btn-text {
font-size: 32rpx;
font-weight: 700;
color: #f2e6d8;
}
/* Tips */
.feedback-tips {
background-color: rgba(255, 253, 249, 0.6);
border-radius: 16rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.feedback-tips-title {
font-size: 24rpx;
font-weight: 700;
color: #8b2323;
margin-bottom: 4rpx;
}
.feedback-tips-text {
font-size: 20rpx;
color: #666;
line-height: 1.6;
}
</style>

View File

@@ -0,0 +1,470 @@
<template>
<view class="order-detail-screen">
<view class="order-detail-bg"></view>
<view class="status-bar-placeholder"></view>
<view class="order-detail-header">
<view class="order-detail-back-btn" @click="emit('back')">
<text class="order-detail-back-icon"></text>
</view>
<text class="order-detail-title">订单详情</text>
<view class="order-detail-header-placeholder"></view>
</view>
<scroll-view scroll-y class="order-detail-content">
<view class="order-detail-inner">
<view class="detail-card detail-card-highlight">
<view class="detail-row">
<text class="detail-label">订单状态</text>
<text class="detail-status" :class="`status-${order.status}`">{{ getStatusText(order.status) }}</text>
</view>
<view class="detail-price-wrap">
<text class="detail-price-symbol">¥</text>
<text class="detail-price">{{ displayAmount }}</text>
</view>
<text class="detail-desc">{{ order.description || getBusinessTypeName(order.business_type) }}</text>
</view>
<view class="detail-card">
<view class="section-title">订单信息</view>
<view class="detail-item">
<text class="detail-item-label">订单号</text>
<text class="detail-item-value detail-item-mono">{{ order.out_trade_no || '-' }}</text>
</view>
<view class="detail-item">
<text class="detail-item-label">微信订单号</text>
<text class="detail-item-value detail-item-mono">{{ order.transaction_id || '-' }}</text>
</view>
<view class="detail-item">
<text class="detail-item-label">业务类型</text>
<text class="detail-item-value">{{ getBusinessTypeName(order.business_type) }}</text>
</view>
<view class="detail-item">
<text class="detail-item-label">业务ID</text>
<text class="detail-item-value">{{ order.business_id ?? '-' }}</text>
</view>
</view>
<view class="detail-card">
<view class="section-title">支付信息</view>
<view class="detail-item">
<text class="detail-item-label">应付金额</text>
<text class="detail-item-value">¥{{ order.total_amount ?? '-' }}</text>
</view>
<view class="detail-item">
<text class="detail-item-label">实付金额</text>
<text class="detail-item-value">¥{{ order.paid_amount ?? order.total_amount ?? '-' }}</text>
</view>
<view class="detail-item">
<text class="detail-item-label">支付时间</text>
<text class="detail-item-value">{{ order.paid_at || '待支付' }}</text>
</view>
</view>
</view>
</scroll-view>
<view class="order-action-bar" v-if="showActionBar">
<view v-if="isPending" class="order-action-btn order-action-cancel" @click="handleCancelOrder">
<text class="order-action-text">取消订单</text>
</view>
<view v-if="isPending" class="order-action-btn order-action-pay" @click="handlePayOrder">
<text class="order-action-text order-action-text-light">{{ actionLoading ? '处理中...' : '继续支付' }}</text>
</view>
<view v-if="isPending" class="order-action-btn order-action-plain" @click="refreshOrderStatus()">
<text class="order-action-text">刷新状态</text>
</view>
<template v-else-if="order.status === 'paid'">
<view class="order-action-btn order-action-pay" @click="handleOpenBusiness">
<text class="order-action-text order-action-text-light">查看对应业务</text>
</view>
<view class="order-action-btn order-action-plain" @click="emit('back')">
<text class="order-action-text">返回订单列表</text>
</view>
<view class="order-action-btn order-action-pay" @click="refreshOrderStatus()">
<text class="order-action-text order-action-text-light">刷新状态</text>
</view>
</template>
<view v-else class="order-action-btn order-action-plain" @click="emit('back')">
<text class="order-action-text">返回订单列表</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue';
import type { QueryOrderResponse } from '@/api/types';
import { closeOrder, wxPay } from '@/utils/payment';
import { paymentApi } from '@/api/payment';
declare const uni: any;
const props = defineProps<{
data?: QueryOrderResponse | null;
}>();
const emit = defineEmits<{
back: [];
openBusiness: [order: QueryOrderResponse];
}>();
const buildFallbackOrder = (): QueryOrderResponse => ({
out_trade_no: '',
status: 'pending',
total_amount: 0,
business_type: '',
business_id: 0,
});
const localOrder = ref<QueryOrderResponse>(props.data || buildFallbackOrder());
watch(
() => props.data,
(next) => {
localOrder.value = next || buildFallbackOrder();
},
{ immediate: true }
);
const order = computed(() => localOrder.value);
const displayAmount = computed(() => order.value.paid_amount ?? order.value.total_amount ?? 0);
const isPending = computed(() => order.value.status === 'pending');
const showActionBar = computed(() => Boolean(order.value.out_trade_no));
const actionLoading = ref(false);
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
pending: '待支付',
paid: '已支付',
cancelled: '已关闭',
refunded: '已退款',
};
return statusMap[status] || status;
};
const getBusinessTypeName = (type: string) => {
const typeMap: Record<string, string> = {
naming_report: '命名报告',
partner_apply: '推广合伙人',
test_report: '测名报告',
fortune_report: '财运报告',
test: '测试商品',
};
return typeMap[type] || type || '-';
};
const refreshOrderStatus = async (silent = false) => {
if (!order.value.out_trade_no || actionLoading.value) return;
actionLoading.value = true;
try {
const latest = await paymentApi.queryOrder(order.value.out_trade_no);
if (latest) {
localOrder.value = latest;
if (!silent) {
uni.showToast({ title: `状态已更新:${getStatusText(latest.status)}`, icon: 'none' });
}
}
} catch (e: any) {
if (!silent) {
uni.showToast({ title: e?.msg || '刷新失败', icon: 'none' });
}
} finally {
actionLoading.value = false;
}
};
const handleCancelOrder = () => {
if (!order.value.out_trade_no || actionLoading.value) return;
uni.showModal({
title: '提示',
content: '确定要取消该订单吗?',
success: async (res: any) => {
if (!res.confirm) return;
actionLoading.value = true;
try {
const success = await closeOrder(order.value.out_trade_no);
if (success) {
uni.showToast({ title: '订单已取消', icon: 'success' });
await refreshOrderStatus(true);
}
} finally {
actionLoading.value = false;
}
},
});
};
const handlePayOrder = async () => {
if (actionLoading.value) return;
actionLoading.value = true;
try {
const result = await wxPay({
description: order.value.description || getBusinessTypeName(order.value.business_type),
total_amount: order.value.total_amount,
business_type: order.value.business_type,
business_id: order.value.business_id,
});
if (result.success) {
uni.showToast({ title: '支付成功', icon: 'success' });
await refreshOrderStatus(true);
if (localOrder.value.status === 'paid') {
handleOpenBusiness();
}
return;
}
uni.showToast({ title: result.msg || '支付失败', icon: 'none' });
} catch (e: any) {
uni.showToast({ title: e?.msg || '支付失败', icon: 'none' });
} finally {
actionLoading.value = false;
}
};
const handleOpenBusiness = () => {
emit('openBusiness', order.value);
};
onMounted(() => {
if (order.value.out_trade_no && order.value.status === 'pending') {
refreshOrderStatus(true);
}
});
</script>
<style scoped>
.order-detail-screen {
height: 100%;
display: flex;
flex-direction: column;
background-color: #f0efe9;
position: relative;
}
.status-bar-placeholder {
height: var(--status-bar-height, 0);
width: 100%;
flex-shrink: 0;
}
.order-detail-bg {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.3;
background-image: url('https://www.transparenttextures.com/patterns/rice-paper.png');
}
.order-detail-header {
position: relative;
z-index: 10;
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.8);
backdrop-filter: blur(10rpx);
}
.order-detail-back-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
margin-left: -16rpx;
}
.order-detail-back-icon {
font-size: 48rpx;
color: #5a5a5a;
font-weight: 300;
}
.order-detail-title {
font-size: 32rpx;
font-weight: 700;
color: #2c2c2c;
letter-spacing: 0.2em;
}
.order-detail-header-placeholder {
width: 64rpx;
}
.order-detail-content {
flex: 1;
height: 0;
position: relative;
z-index: 10;
}
.order-detail-inner {
padding: 32rpx;
padding-bottom: calc(180rpx + env(safe-area-inset-bottom, 0px));
display: flex;
flex-direction: column;
gap: 24rpx;
}
.detail-card {
background-color: #fffdf9;
border-radius: 16rpx;
border: 1rpx solid #e5e5e5;
padding: 24rpx;
}
.detail-card-highlight {
border-color: #e5d6c4;
background: linear-gradient(135deg, #fffdf9 0%, #f9f4ee 100%);
}
.section-title {
font-size: 28rpx;
font-weight: 700;
color: #2c2c2c;
margin-bottom: 16rpx;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-label {
font-size: 24rpx;
color: #999;
}
.detail-status {
font-size: 24rpx;
padding: 4rpx 16rpx;
border-radius: 8rpx;
}
.status-paid {
background-color: #e8f5e9;
color: #4caf50;
}
.status-pending {
background-color: #fff3e0;
color: #ff9800;
}
.status-cancelled {
background-color: #f5f5f5;
color: #999;
}
.status-refunded {
background-color: #ffebee;
color: #f44336;
}
.detail-price-wrap {
margin-top: 16rpx;
display: flex;
align-items: baseline;
}
.detail-price-symbol {
font-size: 28rpx;
color: #8b2323;
font-weight: 700;
}
.detail-price {
font-size: 56rpx;
color: #8b2323;
font-weight: 700;
}
.detail-desc {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
color: #666;
}
.detail-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16rpx;
padding: 16rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.detail-item:last-child {
border-bottom: none;
}
.detail-item-label {
font-size: 24rpx;
color: #999;
}
.detail-item-value {
flex: 1;
text-align: right;
font-size: 24rpx;
color: #333;
word-break: break-all;
}
.detail-item-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
.order-action-bar {
position: relative;
z-index: 12;
display: flex;
flex-wrap: wrap;
gap: 16rpx;
padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom, 0px));
border-top: 1rpx solid #e5e5e5;
background-color: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(8rpx);
}
.order-action-btn {
flex: 1 1 220rpx;
height: 80rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
border: 1rpx solid #e5e5e5;
}
.order-action-text {
font-size: 26rpx;
color: #666;
}
.order-action-text-light {
color: #fff;
}
.order-action-cancel,
.order-action-plain {
background-color: #fff;
}
.order-action-pay {
background-color: #8b2323;
border-color: #8b2323;
}
</style>

View File

@@ -0,0 +1,576 @@
<template>
<view class="orders-screen">
<view class="orders-bg"></view>
<!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view>
<!-- Header -->
<view class="orders-header">
<view class="orders-back-btn" @click="handleBack">
<text class="orders-back-icon"></text>
</view>
<text class="orders-title">我的订单</text>
<view class="orders-header-placeholder"></view>
</view>
<!-- Tabs -->
<view class="orders-tabs">
<view v-for="tab in tabs" :key="tab.value" class="orders-tab"
:class="{ 'orders-tab-active': currentTab === tab.value }" @click="switchTab(tab.value)">
<text class="orders-tab-text">{{ tab.label }}</text>
</view>
</view>
<!-- Content -->
<scroll-view scroll-y class="orders-content" refresher-enabled :refresher-triggered="refreshing"
@refresherrefresh="onRefresh" @scrolltolower="onLoadMore">
<view class="orders-content-inner">
<!-- Loading -->
<view v-if="loading && orders.length === 0" class="orders-loading">
<text class="orders-loading-text">加载中...</text>
</view>
<!-- Empty -->
<view v-else-if="orders.length === 0" class="orders-empty">
<text class="orders-empty-icon">📦</text>
<text class="orders-empty-text">暂无订单</text>
</view>
<!-- List -->
<view v-else class="orders-list">
<view v-for="order in orders" :key="order.out_trade_no" class="order-item">
<!-- Header -->
<view class="order-header">
<text class="order-time">{{ order.paid_at || '待支付' }}</text>
<text class="order-status" :class="`status-${order.status}`">
{{ getStatusText(order.status) }}
</text>
</view>
<!-- Content -->
<view class="order-content">
<view class="order-info">
<text class="order-name">{{ order.description ||
getBusinessTypeName(order.business_type) }}</text>
<text class="order-no">订单号{{ order.out_trade_no }}</text>
</view>
<view class="order-price">
<text class="order-price-symbol">¥</text>
<text class="order-price-amount">{{ order.total_amount }}</text>
</view>
</view>
<!-- Actions -->
<view class="order-actions">
<view v-if="order.status === 'pending'" class="order-action-btn order-action-cancel"
@click="handleCancelOrder(order.out_trade_no)">
<text class="order-action-text">取消订单</text>
</view>
<view v-if="order.status === 'pending'" class="order-action-btn order-action-pay"
@click="handlePayOrder(order)">
<text class="order-action-text">继续支付</text>
</view>
<view v-if="order.status === 'paid'" class="order-action-btn order-action-view"
@click="handleViewOrder(order)">
<text class="order-action-text">查看详情</text>
</view>
</view>
</view>
</view>
<!-- Load More -->
<view v-if="hasMore && !loading" class="orders-loadmore">
<text class="orders-loadmore-text">加载更多...</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { closeOrder, wxPay } from "@/utils/payment";
import type { QueryOrderResponse } from "@/api/types";
import { paymentApi } from "@/api/payment";
declare const uni: any;
const emit = defineEmits<{
back: [];
showOrderDetail: [order: QueryOrderResponse];
}>();
const tabs = [
{ label: '全部', value: 'all' },
{ label: '待支付', value: 'pending' },
{ label: '已支付', value: 'paid' },
{ label: '已关闭', value: 'cancelled' },
];
const currentTab = ref('all');
const loading = ref(false);
const refreshing = ref(false);
const orders = ref<QueryOrderResponse[]>([]);
const page = ref(1);
const pageSize = 10;
const hasMore = ref(true);
const mapTradeStateToStatus = (tradeState: string): QueryOrderResponse['status'] => {
const state = (tradeState || '').toUpperCase();
if (state === 'SUCCESS') return 'paid';
if (state === 'NOTPAY') return 'pending';
if (state === 'CLOSED') return 'cancelled';
if (state === 'REFUND') return 'refunded';
return 'pending';
};
// 切换标签
const switchTab = (tab: string) => {
currentTab.value = tab;
page.value = 1;
orders.value = [];
hasMore.value = true;
loadOrders();
};
// 加载订单列表
const loadOrders = async () => {
if (loading.value) return;
loading.value = true;
try {
const res = await paymentApi.listOrders({
page_no: page.value,
page_size: pageSize,
});
const mapped: QueryOrderResponse[] = (res?.items || []).map((item: any) => ({
out_trade_no: item.out_trade_no,
transaction_id: item.transaction_id,
status: mapTradeStateToStatus(item.trade_state),
total_amount: item.total_amount,
paid_amount: item.total_amount,
paid_at: item.success_time,
business_type: item.business_type,
business_id: item.id,
description: item.description,
}));
const filtered = currentTab.value === 'all'
? mapped
: mapped.filter(o => o.status === currentTab.value);
if (page.value === 1) {
orders.value = filtered;
} else {
orders.value.push(...filtered);
}
hasMore.value = (res?.items || []).length >= pageSize;
} catch (error: any) {
console.error('加载订单失败:', error);
uni.showToast({ title: '加载失败', icon: 'none' });
} finally {
loading.value = false;
refreshing.value = false;
}
};
// 下拉刷新
const onRefresh = () => {
refreshing.value = true;
page.value = 1;
orders.value = [];
hasMore.value = true;
loadOrders();
};
// 加载更多
const onLoadMore = () => {
if (!hasMore.value || loading.value) return;
page.value++;
loadOrders();
};
// 取消订单
const handleCancelOrder = (outTradeNo: string) => {
// Web环境使用confirmuni-app环境使用showModal
if (typeof uni?.showModal === 'function') {
uni.showModal({
title: '提示',
content: '确定要取消该订单吗?',
success: async (res: any) => {
if (res.confirm) {
const success = await closeOrder(outTradeNo);
if (success) {
// 刷新列表
onRefresh();
}
}
}
});
} else {
// Web环境使用原生confirm
const confirmed = confirm('确定要取消该订单吗?');
if (confirmed) {
(async () => {
const success = await closeOrder(outTradeNo);
if (success) {
// 刷新列表
onRefresh();
}
})();
}
}
};
// 继续支付
const handlePayOrder = (order: QueryOrderResponse) => {
(async () => {
try {
const result = await wxPay({
description: order.description || getBusinessTypeName(order.business_type),
total_amount: order.total_amount,
business_type: order.business_type as any,
business_id: order.business_id,
pay_type: 'jsapi',
});
if (result.success) {
uni.showToast({ title: '支付成功', icon: 'success' });
onRefresh();
return;
}
uni.showToast({ title: result.msg || '支付失败', icon: 'none' });
} catch (e: any) {
uni.showToast({ title: e?.msg || '支付失败', icon: 'none' });
}
})();
};
// 查看订单详情
const handleViewOrder = (order: QueryOrderResponse) => {
emit('showOrderDetail', order);
};
// 获取状态文本
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
pending: '待支付',
paid: '已支付',
cancelled: '已关闭',
refunded: '已退款'
};
return statusMap[status] || status;
};
// 获取业务类型名称
const getBusinessTypeName = (type: string) => {
const typeMap: Record<string, string> = {
naming_report: '命名报告',
partner_apply: '推广合伙人',
test_report: '测名报告',
fortune_report: '财运报告',
test: '测试商品'
};
return typeMap[type] || type;
};
// 返回
const handleBack = () => {
emit('back');
};
onMounted(() => {
loadOrders();
});
</script>
<style scoped>
.orders-screen {
height: 100%;
display: flex;
flex-direction: column;
background-color: #f0efe9;
position: relative;
}
.status-bar-placeholder {
height: var(--status-bar-height, 0);
width: 100%;
flex-shrink: 0;
}
.orders-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");
}
/* Header */
.orders-header {
position: relative;
z-index: 10;
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.8);
backdrop-filter: blur(10rpx);
}
.orders-back-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
padding: 0;
margin-left: -16rpx;
}
.orders-back-icon {
font-size: 48rpx;
color: #5a5a5a;
font-weight: 300;
}
.orders-title {
font-size: 32rpx;
font-weight: 700;
color: #2c2c2c;
letter-spacing: 0.2em;
}
.orders-header-placeholder {
width: 64rpx;
}
/* Tabs */
.orders-tabs {
display: flex;
background-color: #fff;
border-bottom: 1rpx solid #e5e5e5;
position: relative;
z-index: 10;
}
.orders-tab {
flex: 1;
padding: 24rpx 0;
text-align: center;
position: relative;
}
.orders-tab-text {
font-size: 28rpx;
color: #666;
transition: all 0.3s;
}
.orders-tab-active .orders-tab-text {
color: #8b2323;
font-weight: 700;
}
.orders-tab-active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60rpx;
height: 4rpx;
background-color: #8b2323;
border-radius: 2rpx;
}
/* Content */
.orders-content {
flex: 1;
height: 0;
position: relative;
z-index: 10;
}
.orders-content-inner {
padding: 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom, 0px));
}
/* Loading & Empty */
.orders-loading,
.orders-empty {
text-align: center;
padding: 120rpx 0;
}
.orders-loading-text {
font-size: 28rpx;
color: #999;
}
.orders-empty-icon {
font-size: 120rpx;
display: block;
margin-bottom: 24rpx;
}
.orders-empty-text {
font-size: 28rpx;
color: #999;
}
/* Order List */
.orders-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.order-item {
background-color: #fffdf9;
border-radius: 16rpx;
border: 1rpx solid #e5e5e5;
overflow: hidden;
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 24rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.order-time {
font-size: 24rpx;
color: #999;
}
.order-status {
font-size: 24rpx;
padding: 4rpx 16rpx;
border-radius: 8rpx;
}
.status-paid {
background-color: #e8f5e9;
color: #4caf50;
}
.status-pending {
background-color: #fff3e0;
color: #ff9800;
}
.status-cancelled {
background-color: #f5f5f5;
color: #999;
}
.status-refunded {
background-color: #ffebee;
color: #f44336;
}
.order-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx;
}
.order-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.order-name {
font-size: 28rpx;
color: #2c2c2c;
font-weight: 500;
}
.order-no {
font-size: 20rpx;
color: #999;
}
.order-price {
display: flex;
align-items: baseline;
}
.order-price-symbol {
font-size: 24rpx;
color: #8b2323;
font-weight: 700;
}
.order-price-amount {
font-size: 36rpx;
color: #8b2323;
font-weight: 700;
}
/* Actions */
.order-actions {
display: flex;
justify-content: flex-end;
gap: 16rpx;
padding: 0 24rpx 24rpx;
}
.order-action-btn {
padding: 12rpx 32rpx;
border-radius: 8rpx;
border: 1rpx solid #e5e5e5;
}
.order-action-text {
font-size: 24rpx;
color: #666;
}
.order-action-cancel {
background-color: #fff;
}
.order-action-pay {
background-color: #8b2323;
border-color: #8b2323;
}
.order-action-pay .order-action-text {
color: #fff;
}
.order-action-view {
background-color: #fff;
}
/* Load More */
.orders-loadmore {
text-align: center;
padding: 32rpx 0;
}
.orders-loadmore-text {
font-size: 24rpx;
color: #999;
}
</style>

View File

@@ -0,0 +1,6 @@
<template>
<view class="h-full flex items-center justify-center text-[#5a5a5a]">
<text>已替换为 ProfileScreen.vue</text>
</view>
</template>

View File

@@ -0,0 +1,284 @@
<template>
<view class="privacy-screen">
<view class="privacy-bg"></view>
<!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view>
<!-- Header -->
<view class="privacy-header">
<view class="privacy-back-btn" @click="handleBack">
<text class="privacy-back-icon"></text>
</view>
<text class="privacy-title">隐私政策</text>
<view class="privacy-header-placeholder"></view>
</view>
<!-- Content -->
<scroll-view scroll-y class="privacy-content">
<view class="privacy-content-inner">
<view v-if="loading" class="privacy-loading">
<text class="privacy-loading-text">加载中...</text>
</view>
<template v-else-if="policy">
<!-- 标题和版本信息 -->
<view class="privacy-header-info">
<text class="privacy-doc-title">{{ policy.title }}</text>
<view class="privacy-meta">
<text class="privacy-meta-item">版本{{ policy.version }}</text>
<text class="privacy-meta-item">生效日期{{ formatDate(policy.effective_date) }}</text>
<text class="privacy-meta-item">更新时间{{ formatDate(policy.updated_at) }}</text>
</view>
</view>
<!-- 内容 -->
<view class="privacy-body">
<rich-text :nodes="formattedContent" class="privacy-rich-text"></rich-text>
</view>
</template>
<view v-else class="privacy-empty">
<text class="privacy-empty-icon">📄</text>
<text class="privacy-empty-text">暂无隐私政策</text>
</view>
<!-- 底部提示 -->
<view v-if="policy" class="privacy-footer">
<text class="privacy-footer-text">如有疑问请联系客服</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { userApi } from "@/api";
declare const uni: any;
const emit = defineEmits<{
back: [];
}>();
const loading = ref(false);
const policy = ref<any>(null);
// 格式化内容将换行符转换为HTML
const formattedContent = computed(() => {
if (!policy.value?.content) return '';
// 简单的文本格式化:将换行符转换为<br>,段落添加样式
let content = policy.value.content;
// 如果内容包含HTML标签直接返回
if (content.includes('<')) {
return content;
}
// 否则进行简单格式化
content = content
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>')
.replace(/^(.+)$/, '<p>$1</p>');
return content;
});
const loadPrivacyPolicy = async () => {
loading.value = true;
try {
const res = await userApi.getPrivacyPolicy();
console.log('getPrivacyPolicy response:', res);
policy.value = res;
} catch (e: any) {
console.error('loadPrivacyPolicy error:', e);
uni.showToast({ title: e.msg || "加载失败", icon: "none" });
} finally {
loading.value = false;
}
};
const formatDate = (dateStr: string) => {
if (!dateStr) return "";
return dateStr.split("T")[0].replace(/-/g, ".");
};
const handleBack = () => {
emit('back');
};
onMounted(() => loadPrivacyPolicy());
</script>
<style scoped>
.privacy-screen {
height: 100%;
display: flex;
flex-direction: column;
background-color: #f0efe9;
position: relative;
}
.status-bar-placeholder {
height: var(--status-bar-height, 0);
width: 100%;
flex-shrink: 0;
}
.privacy-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");
}
/* Header */
.privacy-header {
position: relative;
z-index: 10;
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.8);
backdrop-filter: blur(10rpx);
}
.privacy-back-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
padding: 0;
margin-left: -16rpx;
}
.privacy-back-icon {
font-size: 48rpx;
color: #5a5a5a;
font-weight: 300;
}
.privacy-title {
font-size: 32rpx;
font-weight: 700;
color: #2c2c2c;
letter-spacing: 0.2em;
}
.privacy-header-placeholder {
width: 64rpx;
}
/* Content */
.privacy-content {
flex: 1;
height: 0;
position: relative;
z-index: 10;
}
.privacy-content-inner {
padding: 32rpx;
}
/* Loading */
.privacy-loading {
display: flex;
align-items: center;
justify-content: center;
height: 400rpx;
}
.privacy-loading-text {
font-size: 28rpx;
color: #999;
}
/* Header Info */
.privacy-header-info {
background-color: #fffdf9;
border-radius: 24rpx;
border: 1rpx solid #e5e5e5;
padding: 32rpx;
margin-bottom: 24rpx;
}
.privacy-doc-title {
font-size: 36rpx;
font-weight: 700;
color: #2c2c2c;
display: block;
margin-bottom: 24rpx;
text-align: center;
}
.privacy-meta {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.privacy-meta-item {
font-size: 24rpx;
color: #999;
text-align: center;
}
/* Body */
.privacy-body {
background-color: #fffdf9;
border-radius: 24rpx;
border: 1rpx solid #e5e5e5;
padding: 32rpx;
margin-bottom: 24rpx;
}
.privacy-rich-text {
font-size: 28rpx;
color: #2c2c2c;
line-height: 1.8;
}
/* Empty */
.privacy-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400rpx;
}
.privacy-empty-icon {
font-size: 96rpx;
opacity: 0.3;
margin-bottom: 24rpx;
}
.privacy-empty-text {
font-size: 28rpx;
color: #999;
}
/* Footer */
.privacy-footer {
text-align: center;
padding: 32rpx 0;
}
.privacy-footer-text {
font-size: 24rpx;
color: #999;
}
</style>

View File

@@ -0,0 +1,415 @@
<template>
<view class="reports-screen">
<view class="reports-bg"></view>
<!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view>
<!-- Header -->
<view class="reports-header">
<view class="reports-back-btn" @click="$emit('back')">
<text class="reports-back-icon"></text>
</view>
<text class="reports-title">已解锁报告</text>
<view class="reports-header-placeholder"></view>
</view>
<!-- Content -->
<scroll-view scroll-y class="reports-list">
<template v-if="reports.length > 0">
<view v-for="(item, index) in reports" :key="item.id" class="reports-item"
:style="{ animationDelay: index * 0.1 + 's' }">
<!-- Card Header accent -->
<view class="reports-item-accent"
:class="item.type === 'fortune' ? 'reports-item-accent-gold' : 'reports-item-accent-red'"></view>
<view class="reports-item-body">
<!-- Icon -->
<view class="reports-item-icon"
:class="item.type === 'fortune' ? 'reports-item-icon-gold' : 'reports-item-icon-red'">
<text class="reports-item-icon-text"></text>
</view>
<view class="reports-item-content">
<text class="reports-item-title">{{ item.title }}</text>
<view class="reports-item-meta">
<text class="reports-item-date">{{ item.date }}</text>
<view class="reports-item-divider"></view>
<text class="reports-item-price">已支付 {{ item.price }}</text>
</view>
<view class="reports-item-actions">
<view class="reports-btn-download">
<text class="reports-btn-download-icon"></text>
<text class="reports-btn-download-text">下载PDF</text>
</view>
<view class="reports-btn-preview">
<text class="reports-btn-preview-text">在线预览</text>
</view>
</view>
</view>
</view>
</view>
</template>
<template v-else>
<view class="reports-empty">
<text class="reports-empty-icon">🔒</text>
<text class="reports-empty-text">暂无解锁报告</text>
</view>
</template>
<!-- Upsell Banner -->
<view class="reports-upsell">
<view class="reports-upsell-glow"></view>
<view class="reports-upsell-content">
<view class="reports-upsell-left">
<view class="reports-upsell-title-row">
<text class="reports-upsell-crown">👑</text>
<text class="reports-upsell-title">解锁更多财运玄机</text>
</view>
<text class="reports-upsell-desc">助您掌握流年运势趋吉避凶</text>
</view>
<view class="reports-upsell-btn" @click="$emit('navigate', 'test')">
<text class="reports-upsell-btn-text">去测算</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
interface ReportItem {
id: string;
title: string;
type: 'fortune' | 'naming' | 'renaming';
date: string;
price: string;
}
defineEmits<{
back: [];
navigate: [screen: string];
}>();
const reports: ReportItem[] = [
{ id: '1', title: '2024年度个人财运深度解析', type: 'fortune', date: '2023-12-12', price: '¥666' },
{ id: '2', title: '"宏图科技"品牌改名运势报告', type: 'renaming', date: '2023-11-20', price: '¥888' },
];
</script>
<style scoped>
.reports-screen {
height: 100%;
display: flex;
flex-direction: column;
background-color: #f0efe9;
position: relative;
}
.status-bar-placeholder {
height: var(--status-bar-height, 0);
width: 100%;
flex-shrink: 0;
}
.reports-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");
}
/* Header */
.reports-header {
position: relative;
z-index: 10;
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.8);
}
.reports-back-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
padding: 0;
margin-left: -16rpx;
}
.reports-back-icon {
font-size: 48rpx;
color: #5a5a5a;
font-weight: 300;
}
.reports-title {
font-size: 32rpx;
font-weight: 700;
color: #2c2c2c;
letter-spacing: 0.2em;
}
.reports-header-placeholder {
width: 64rpx;
}
/* List */
.reports-list {
flex: 1;
height: 0;
position: relative;
z-index: 10;
}
.reports-item {
background-color: #fffdf9;
border-radius: 24rpx;
border: 1rpx solid #e5e5e5;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
margin-bottom: 24rpx;
overflow: hidden;
animation: fadeInUp 0.3s ease-out forwards;
opacity: 0;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.reports-item-accent {
height: 8rpx;
width: 100%;
}
.reports-item-accent-gold {
background-color: #d4af37;
}
.reports-item-accent-red {
background-color: #8b2323;
}
.reports-item-body {
padding: 32rpx;
display: flex;
align-items: flex-start;
gap: 24rpx;
}
.reports-item-icon {
width: 96rpx;
height: 112rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.reports-item-icon-gold {
background: linear-gradient(180deg, #d4af37 0%, #c4a130 100%);
}
.reports-item-icon-red {
background: linear-gradient(180deg, #8b2323 0%, #701c1c 100%);
}
.reports-item-icon-text {
font-size: 40rpx;
color: #fff;
font-weight: 700;
font-family: SimSun, "Songti SC", serif;
}
.reports-item-content {
flex: 1;
min-width: 0;
}
.reports-item-title {
font-size: 28rpx;
font-weight: 700;
color: #2c2c2c;
line-height: 1.4;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reports-item-meta {
display: flex;
align-items: center;
gap: 16rpx;
margin-top: 8rpx;
margin-bottom: 24rpx;
}
.reports-item-date,
.reports-item-price {
font-size: 20rpx;
color: #999;
}
.reports-item-divider {
width: 2rpx;
height: 20rpx;
background-color: #e5e5e5;
}
.reports-item-actions {
display: flex;
gap: 16rpx;
}
.reports-btn-download {
flex: 1;
background-color: #2c2c2c;
color: #f2e6d8;
padding: 16rpx 0;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
border: none;
}
.reports-btn-download-icon {
font-size: 24rpx;
}
.reports-btn-download-text {
font-size: 24rpx;
}
.reports-btn-preview {
flex: 1;
background-color: transparent;
border: 1rpx solid #e5e5e5;
padding: 16rpx 0;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
}
.reports-btn-preview-text {
font-size: 24rpx;
color: #5a5a5a;
}
/* Empty State */
.reports-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400rpx;
color: #999;
}
.reports-empty-icon {
font-size: 96rpx;
opacity: 0.2;
margin-bottom: 24rpx;
}
.reports-empty-text {
font-size: 28rpx;
}
/* Upsell Banner */
.reports-upsell {
margin-top: 48rpx;
padding: 32rpx;
background-color: #2c2c2c;
border-radius: 24rpx;
position: relative;
overflow: hidden;
}
.reports-upsell-glow {
position: absolute;
top: 0;
right: 0;
width: 200rpx;
height: 200rpx;
background-color: #d4af37;
border-radius: 50%;
filter: blur(100rpx);
opacity: 0.2;
pointer-events: none;
}
.reports-upsell-content {
position: relative;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
}
.reports-upsell-left {
flex: 1;
}
.reports-upsell-title-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 8rpx;
}
.reports-upsell-crown {
font-size: 32rpx;
}
.reports-upsell-title {
font-size: 28rpx;
font-weight: 700;
color: #d4af37;
}
.reports-upsell-desc {
font-size: 24rpx;
color: rgba(242, 230, 216, 0.7);
}
.reports-upsell-btn {
background-color: #d4af37;
padding: 16rpx 32rpx;
border-radius: 8rpx;
border: none;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.2);
}
.reports-upsell-btn-text {
font-size: 24rpx;
font-weight: 700;
color: #2c2c2c;
}
</style>

View File

@@ -0,0 +1,433 @@
<template>
<view class="settings-screen">
<view class="settings-bg"></view>
<!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view>
<!-- Header -->
<view class="settings-header">
<view class="settings-back-btn" @click="handleBack">
<text class="settings-back-icon"></text>
</view>
<text class="settings-title">设置与反馈</text>
<view class="settings-header-placeholder"></view>
</view>
<!-- Content -->
<scroll-view scroll-y class="settings-content">
<view class="settings-content-inner">
<!-- Section 1: 偏好设置 -->
<!-- <view class="settings-section">
<view class="settings-item settings-item-border">
<view class="settings-item-left">
<text class="settings-item-icon">🔔</text>
<text class="settings-item-label">推送通知</text>
</view>
<view class="settings-switch" :class="{ 'settings-switch-active': pushEnabled }"
@click="togglePush">
<view class="settings-switch-thumb"
:class="{ 'settings-switch-thumb-active': pushEnabled }"></view>
</view>
</view>
<view class="settings-item">
<view class="settings-item-left">
<text class="settings-item-icon">🔊</text>
<text class="settings-item-label">音效反馈</text>
</view>
<view class="settings-switch" :class="{ 'settings-switch-active': soundEnabled }"
@click="toggleSound">
<view class="settings-switch-thumb"
:class="{ 'settings-switch-thumb-active': soundEnabled }"></view>
</view>
</view>
</view> -->
<!-- Section 2: 支持 -->
<view class="settings-section">
<view class="settings-item settings-item-border settings-item-clickable" @click="handleFeedback">
<view class="settings-item-left">
<text class="settings-item-icon">💬</text>
<text class="settings-item-label">意见反馈</text>
</view>
<text class="settings-item-arrow"></text>
</view>
<view class="settings-item settings-item-border settings-item-clickable" @click="handleFAQ">
<view class="settings-item-left">
<text class="settings-item-icon"></text>
<text class="settings-item-label">常见问题</text>
</view>
<text class="settings-item-arrow"></text>
</view>
<view class="settings-item settings-item-clickable" @click="handlePrivacy">
<view class="settings-item-left">
<text class="settings-item-icon">🛡</text>
<text class="settings-item-label">隐私政策</text>
</view>
<text class="settings-item-arrow"></text>
</view>
</view>
<!-- Section 3: 快速反馈 -->
<view class="settings-feedback-section">
<text class="settings-feedback-title">快速反馈</text>
<textarea class="settings-feedback-textarea" v-model="feedbackText" placeholder="您遇到的问题或建议..."
:maxlength="500" />
<view class="settings-feedback-btn" @click="submitFeedback">
<text class="settings-feedback-btn-text">提交反馈</text>
</view>
</view>
<!-- 退出登录 -->
<view class="settings-logout-btn" @click="handleLogout">
<text class="settings-logout-text">退出登录</text>
</view>
<!-- 版本信息 -->
<view class="settings-version">
<text class="settings-version-text">当前版本 v1.0.2</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { userApi } from "@/api";
import { logout } from "@/utils/auth";
declare const uni: any;
const emit = defineEmits<{
back: [];
navigate: [screen: string];
}>();
const pushEnabled = ref(true);
const soundEnabled = ref(true);
const feedbackText = ref("");
const togglePush = () => {
pushEnabled.value = !pushEnabled.value;
uni.showToast({
title: pushEnabled.value ? "已开启推送通知" : "已关闭推送通知",
icon: "none"
});
};
const toggleSound = () => {
soundEnabled.value = !soundEnabled.value;
uni.showToast({
title: soundEnabled.value ? "已开启音效反馈" : "已关闭音效反馈",
icon: "none"
});
};
const handleFeedback = () => {
emit('navigate', 'feedback');
};
const handleFAQ = () => {
emit('navigate', 'faq');
};
const handlePrivacy = () => {
emit('navigate', 'privacy');
};
const submitFeedback = async () => {
if (!feedbackText.value.trim()) {
uni.showToast({ title: "请输入反馈内容", icon: "none" });
return;
}
try {
await userApi.submitFeedback({
content: feedbackText.value.trim(),
feedback_type: 'other'
});
uni.showToast({ title: "感谢您的反馈!", icon: "success" });
feedbackText.value = "";
} catch (error: any) {
uni.showToast({
title: error.msg || "提交失败,请稍后重试",
icon: "none"
});
}
};
const handleLogout = () => {
// Web 环境使用 confirmuni-app 环境使用 showModal
if (typeof uni?.showModal === "function") {
uni.showModal({
title: "提示",
content: "确定要退出登录吗?",
success: (res: any) => {
if (res.confirm) {
logout();
uni.showToast({ title: "已退出登录", icon: "success" });
}
},
});
} else {
const confirmed = confirm("确定要退出登录吗?");
if (confirmed) {
logout();
if (typeof uni?.showToast === "function") {
uni.showToast({ title: "已退出登录", icon: "success" });
}
}
}
};
const handleBack = () => {
emit('back');
};
</script>
<style scoped>
.settings-screen {
height: 100%;
display: flex;
flex-direction: column;
background-color: #f0efe9;
position: relative;
}
.status-bar-placeholder {
height: var(--status-bar-height, 0);
width: 100%;
flex-shrink: 0;
}
.settings-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");
}
/* Header */
.settings-header {
position: relative;
z-index: 10;
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.8);
backdrop-filter: blur(10rpx);
}
.settings-back-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
padding: 0;
margin-left: -16rpx;
}
.settings-back-icon {
font-size: 48rpx;
color: #5a5a5a;
font-weight: 300;
}
.settings-title {
font-size: 32rpx;
font-weight: 700;
color: #2c2c2c;
letter-spacing: 0.2em;
}
.settings-header-placeholder {
width: 64rpx;
}
/* Content */
.settings-content {
flex: 1;
height: 0;
position: relative;
z-index: 10;
}
.settings-content-inner {
padding: 32rpx;
}
/* Section */
.settings-section {
background-color: #fffdf9;
border-radius: 24rpx;
border: 1rpx solid #e5e5e5;
overflow: hidden;
margin-bottom: 32rpx;
}
.settings-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx;
}
.settings-item-border {
border-bottom: 1rpx solid #f0f0f0;
}
.settings-item-clickable {
transition: background-color 0.2s;
}
.settings-item-clickable:active {
background-color: #fafafa;
}
.settings-item-left {
display: flex;
align-items: center;
gap: 24rpx;
}
.settings-item-icon {
font-size: 32rpx;
}
.settings-item-label {
font-size: 28rpx;
color: #2c2c2c;
}
.settings-item-arrow {
font-size: 32rpx;
color: #ccc;
}
/* Switch */
.settings-switch {
width: 80rpx;
height: 40rpx;
border-radius: 40rpx;
background-color: #ccc;
position: relative;
transition: background-color 0.3s;
}
.settings-switch-active {
background-color: #8b2323;
}
.settings-switch-thumb {
position: absolute;
top: 4rpx;
left: 4rpx;
width: 32rpx;
height: 32rpx;
border-radius: 50%;
background-color: #fff;
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.2);
transition: left 0.3s;
}
.settings-switch-thumb-active {
left: 44rpx;
}
/* Feedback Section */
.settings-feedback-section {
margin-bottom: 32rpx;
}
.settings-feedback-title {
font-size: 24rpx;
font-weight: 700;
color: #8b2323;
margin-bottom: 16rpx;
display: block;
padding: 0 8rpx;
}
.settings-feedback-textarea {
width: 100%;
height: 192rpx;
background-color: #fffdf9;
border: 1rpx solid #e5e5e5;
border-radius: 24rpx;
padding: 24rpx;
font-size: 28rpx;
color: #2c2c2c;
box-sizing: border-box;
}
.settings-feedback-btn {
width: 100%;
margin-top: 16rpx;
padding: 24rpx 0;
background-color: #2c2c2c;
border-radius: 16rpx;
border: none;
display: flex;
justify-content: center;
}
.settings-feedback-btn-text {
font-size: 24rpx;
font-weight: 700;
color: #f2e6d8;
}
/* Logout Button */
.settings-logout-btn {
width: 100%;
padding: 24rpx 0;
background-color: #fffdf9;
border: 1rpx solid #e5e5e5;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
margin-bottom: 32rpx;
transition: background-color 0.2s;
}
.settings-logout-btn:active {
background-color: #fff5f5;
}
.settings-logout-icon {
font-size: 28rpx;
}
.settings-logout-text {
font-size: 28rpx;
font-weight: 700;
color: #8b2323;
}
/* Version */
.settings-version {
text-align: center;
padding: 32rpx 0;
}
.settings-version-text {
font-size: 20rpx;
color: #ccc;
}
</style>

View File

@@ -0,0 +1,248 @@
<template>
<view class="userinfo-screen">
<view class="userinfo-bg"></view>
<view class="status-bar-placeholder"></view>
<view class="userinfo-header">
<view class="userinfo-back-btn" @click="handleBack">
<text class="userinfo-back-icon"></text>
</view>
<text class="userinfo-title">我的信息</text>
<view class="userinfo-header-placeholder"></view>
</view>
<scroll-view scroll-y class="userinfo-content">
<view class="userinfo-inner">
<view class="userinfo-section">
<text class="userinfo-label">用户名</text>
<input
v-model="username"
class="userinfo-input"
type="text"
maxlength="32"
placeholder="请输入用户名"
/>
</view>
<view class="userinfo-section userinfo-section-mt">
<text class="userinfo-label">手机号</text>
<input
v-model="mobile"
class="userinfo-input"
type="tel"
maxlength="11"
placeholder="请输入手机号"
/>
</view>
<view class="userinfo-save" @click="handleSave">
<text class="userinfo-save-text">{{ saving ? "保存中…" : "保存" }}</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { getUserInfo, setUserInfo } from "@/utils/auth";
import { userApi } from "@/api";
declare const uni: any;
const emit = defineEmits<{
back: [];
}>();
const username = ref("");
const mobile = ref("");
const saving = ref(false);
const isValidPhone = (phone: string) => /^1[3-9]\d{9}$/.test(phone);
const loadFromStorage = () => {
const info = getUserInfo();
if (!info || typeof info !== "object") return;
const u = info as Record<string, unknown>;
username.value = String(
u.username ?? u.name ?? ""
).trim();
mobile.value = String(u.mobile ?? u.phone ?? "").trim();
};
onMounted(() => {
loadFromStorage();
});
const handleBack = () => {
emit("back");
};
const handleSave = async () => {
const name = username.value.trim();
const tel = mobile.value.trim();
if (!name) {
uni.showToast({ title: "请输入用户名", icon: "none" });
return;
}
if (!tel) {
uni.showToast({ title: "请输入手机号", icon: "none" });
return;
}
if (!isValidPhone(tel)) {
uni.showToast({ title: "请输入正确的手机号", icon: "none" });
return;
}
if (saving.value) return;
saving.value = true;
try {
await userApi.updateCurrentUserUsernameMobile({
username: name,
mobile: tel,
});
// 仅把表单结果合并进已有 userInfo不把接口 data 整包写入,避免覆盖/清空头像、id 等字段
const prev = getUserInfo();
if (!prev || typeof prev !== "object") {
uni.showToast({ title: "登录状态异常,请重新登录", icon: "none" });
return;
}
setUserInfo({
...prev,
nickname: name,
name: name,
username: name,
mobile: tel,
phone: tel,
});
uni.showToast({ title: "已保存", icon: "success" });
} catch (e: any) {
uni.showToast({
title: e?.msg || e?.message || "保存失败,请稍后重试",
icon: "none",
});
} finally {
saving.value = false;
}
};
</script>
<style scoped>
.userinfo-screen {
height: 100%;
display: flex;
flex-direction: column;
background-color: #f0efe9;
position: relative;
}
.status-bar-placeholder {
height: var(--status-bar-height, 0);
width: 100%;
flex-shrink: 0;
}
.userinfo-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");
}
.userinfo-header {
position: relative;
z-index: 10;
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.8);
backdrop-filter: blur(10rpx);
}
.userinfo-back-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
margin-left: -16rpx;
}
.userinfo-back-icon {
font-size: 48rpx;
color: #5a5a5a;
font-weight: 300;
}
.userinfo-title {
font-size: 32rpx;
font-weight: 700;
color: #2c2c2c;
letter-spacing: 0.2em;
}
.userinfo-header-placeholder {
width: 64rpx;
}
.userinfo-content {
flex: 1;
height: 0;
position: relative;
z-index: 10;
}
.userinfo-inner {
padding: 32rpx;
}
.userinfo-section {
background-color: #fffdf9;
border-radius: 24rpx;
border: 1rpx solid #e5e5e5;
padding: 24rpx 32rpx;
}
.userinfo-section-mt {
margin-top: 24rpx;
}
.userinfo-label {
display: block;
font-size: 24rpx;
color: #888;
margin-bottom: 16rpx;
}
.userinfo-input {
width: 100%;
font-size: 30rpx;
color: #2c2c2c;
border: none;
background: transparent;
padding: 0;
}
.userinfo-save {
margin-top: 48rpx;
height: 88rpx;
border-radius: 44rpx;
background: linear-gradient(135deg, #8b7355, #6b5344);
display: flex;
align-items: center;
justify-content: center;
}
.userinfo-save-text {
font-size: 30rpx;
color: #fffdf9;
font-weight: 600;
letter-spacing: 0.15em;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,190 @@
<template>
<view class="renaming-detail">
<view class="detail-header">
<view class="detail-header-back" @click="$emit('back')">
<text class="back-icon"></text>
<text class="back-text">返回</text>
</view>
<text class="detail-header-title">改名详解</text>
<view class="detail-header-placeholder"></view>
</view>
<scroll-view scroll-y class="detail-content">
<view class="hero-card">
<text class="hero-name">{{ data?.name || '新名' }}</text>
<text class="hero-pinyin">{{ data?.pinyin || '' }}</text>
</view>
<view class="section">
<view class="section-title">
<text class="section-icon">📖</text>
<text class="section-text">寓意</text>
</view>
<view class="card">
<text class="card-text">{{ data?.meaning || '' }}</text>
</view>
</view>
<view class="section">
<view class="section-title">
<text class="section-icon">🪶</text>
<text class="section-text">出处</text>
</view>
<view class="card">
<text class="card-text">{{ data?.source || '' }}</text>
</view>
</view>
<view class="section" v-if="data?.tags?.length">
<view class="section-title">
<text class="section-icon">🏷</text>
<text class="section-text">标签</text>
</view>
<view class="tag-row">
<text v-for="(t, i) in data.tags" :key="i" class="tag">{{ t }}</text>
</view>
</view>
<view class="bottom-spacer"></view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
type Mode = 'personal' | 'company';
defineProps<{
data: any;
mode?: Mode;
}>();
defineEmits<{
back: [];
}>();
</script>
<style scoped>
.renaming-detail {
height: 100%;
display: flex;
flex-direction: column;
background: #f0efe9;
font-family: SimSun, "Songti SC", "Songti TC", "Noto Serif SC", STSong, serif;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx;
border-bottom: 1px solid #eaddcf;
background: rgba(253, 251, 247, 0.9);
}
.detail-header-back {
display: flex;
align-items: center;
gap: 10rpx;
color: #8b2323;
}
.back-icon {
font-size: 22px;
line-height: 1;
}
.back-text {
font-size: 14px;
font-weight: 700;
}
.detail-header-title {
font-size: 16px;
font-weight: 700;
color: #2c2c2c;
letter-spacing: 0.2em;
}
.detail-header-placeholder {
width: 80rpx;
}
.detail-content {
flex: 1;
height: 0;
}
.hero-card {
margin: 32rpx;
padding: 32rpx;
border-radius: 16rpx;
border: 1px solid #dcd3c9;
background: #fdfbf7;
}
.hero-name {
font-size: 28px;
font-weight: 700;
color: #8b2323;
display: block;
margin-bottom: 8rpx;
}
.hero-pinyin {
font-size: 12px;
color: #5a5a5a;
}
.section {
margin: 0 32rpx 28rpx;
}
.section-title {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 12rpx;
}
.section-icon {
font-size: 14px;
}
.section-text {
font-size: 14px;
font-weight: 700;
color: #2c2c2c;
}
.card {
padding: 24rpx;
border-radius: 16rpx;
border: 1px solid #dcd3c9;
background: #f9f7f2;
}
.card-text {
font-size: 14px;
color: #2c2c2c;
line-height: 1.7;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.tag {
font-size: 10px;
padding: 6rpx 10rpx;
border-radius: 8rpx;
border: 1px solid rgba(139, 35, 35, 0.2);
color: #8b2323;
background: rgba(139, 35, 35, 0.04);
}
.bottom-spacer {
height: 80rpx;
}
</style>

View File

@@ -0,0 +1,11 @@
<template>
<view class="h-full flex flex-col items-center justify-center bg-[#fdfbf7] text-[#2c2c2c] font-serif relative overflow-hidden">
<view class="absolute inset-0 opacity-10 pointer-events-none bg-[url('https://www.transparenttextures.com/patterns/rice-paper.png')]"></view>
<view class="relative z-10 text-center px-8">
<text class="block text-4xl mb-3 text-[#d4af37]"></text>
<text class="block text-xl font-bold tracking-[0.3em] mb-2">改名/重塑模块</text>
<text class="block text-sm text-[#5a5a5a]">稍后将迁移完整改名流程与报告</text>
</view>
</view>
</template>

View File

@@ -0,0 +1,917 @@
<template>
<view class="testname-screen">
<!-- 背景纹理 -->
<view class="testname-bg-texture"></view>
<!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view>
<!-- 顶部装饰 -->
<view class="testname-top-bar"></view>
<view class="testname-container">
<!-- 标题区 -->
<view class="testname-header">
<text class="testname-title">八字测名</text>
<!-- Mode Toggle -->
<view class="testname-mode-toggle">
<view class="testname-mode-toggle-bg">
<!-- Active Indicator -->
<view class="testname-mode-toggle-slider"
:style="{ left: mode === 'personal' ? '4px' : 'calc(50% + 2px)', width: 'calc(50% - 6px)' }" />
<view class="testname-mode-toggle-btn" :class="{ 'testname-mode-toggle-btn-active': mode === 'personal' }"
@click="mode = 'personal'">
<text>个人</text>
</view>
<view class="testname-mode-toggle-btn" :class="{ 'testname-mode-toggle-btn-active': mode === 'company' }"
@click="mode = 'company'">
<text>公司</text>
</view>
</view>
</view>
</view>
<!-- 主表单卡片 -->
<view class="testname-form-card" :class="{ 'testname-form-card-company': mode === 'company' }">
<!-- 四角装饰纹样 -->
<view v-for="(corner, i) in corners" :key="i" class="testname-corner" :style="corner"></view>
<!-- 个人表单 -->
<view v-if="mode === 'personal'" class="testname-form-personal">
<view class="testname-name-row">
<view class="testname-name-group">
<text class="testname-label">姓氏</text>
<view class="testname-input-wrapper testname-input-wrapper-focus">
<input v-model="personalData.lastName" type="text" class="testname-input-name" placeholder="李" />
</view>
</view>
<view class="testname-name-group">
<text class="testname-label">名字</text>
<view class="testname-input-wrapper testname-input-wrapper-focus">
<input v-model="personalData.firstName" type="text" class="testname-input-name" placeholder="逍遥" />
</view>
</view>
</view>
<view class="testname-gender-section">
<text class="testname-label-center">性别</text>
<view class="testname-gender-group">
<view class="testname-gender-btn"
:class="{ 'testname-gender-btn-active': personalData.gender === 'male' }"
@click="personalData.gender = 'male'">
<text class="testname-gender-symbol"></text>
<text class="testname-gender-label"></text>
</view>
<view class="testname-gender-btn"
:class="{ 'testname-gender-btn-active': personalData.gender === 'female' }"
@click="personalData.gender = 'female'">
<text class="testname-gender-symbol"></text>
<text class="testname-gender-label"></text>
</view>
</view>
</view>
<view class="testname-date-section">
<view class="testname-label-with-icon">
<CalendarIcon :size="14" class="testname-icon" />
<text class="testname-label" style="margin-bottom: 0px;">生辰</text>
</view>
<view class="testname-date-picker-trigger" @click="activeDateField = 'personal'">
<text class="testname-date-picker-text"
:class="{ 'testname-date-picker-text-filled': personalData.birthDateDisplay }">
{{ personalData.birthDateDisplay || '请择生辰' }}
</text>
<view class="testname-date-picker-arrow">
<ChevronDownIcon :size="16" />
</view>
</view>
</view>
</view>
<!-- 公司表单 -->
<view v-else class="testname-form-company">
<!-- 基础信息 -->
<view class="testname-company-basic">
<view class="testname-company-field">
<view class="testname-label-with-icon">
<HomeIcon :size="14" class="testname-icon" />
<text class="testname-label" style="margin-bottom: 0px;">公司名称</text>
</view>
<input v-model="companyData.companyName" type="text" class="testname-input-company"
placeholder="例:鼎盛科技" />
</view>
<view class="testname-company-field">
<view class="testname-label-with-icon">
<HomeIcon :size="14" class="testname-icon" />
<text class="testname-label" style="margin-bottom: 0px;">主营业务 / 行业</text>
</view>
<input v-model="companyData.industry" type="text" class="testname-input-company"
placeholder="例:科技、餐饮、文化..." />
</view>
<view class="testname-company-row">
<view class="testname-company-field testname-company-field-half">
<text class="testname-label">经营地址</text>
<input v-model="companyData.address" type="text" class="testname-input-company" placeholder="城市/方位" />
</view>
<view class="testname-company-field testname-company-field-half">
<text class="testname-label">服务群体</text>
<input v-model="companyData.targetAudience" type="text" class="testname-input-company"
placeholder="年轻人、高端..." />
</view>
</view>
</view>
<view class="testname-divider"></view>
<!-- 核心成员 -->
<view class="testname-members-section">
<view class="testname-members-header">
<view class="testname-label-with-icon">
<ProfileIcon :size="14" class="testname-icon" />
<text class="testname-label" style="margin-bottom: 0px;">核心成员 (五行匹配)</text>
</view>
<text class="testname-members-tip">至少需填一位</text>
</view>
<scroll-view scroll-y class="testname-members-list">
<view v-for="(member, idx) in companyData.members" :key="idx" class="testname-member-item">
<view class="testname-member-number">{{ chNum[Number(idx) + 1] }}</view>
<input v-model="member.name" type="text" class="testname-member-name" placeholder="姓名" />
<view class="testname-member-divider"></view>
<view class="testname-member-date" :class="{ 'testname-member-date-filled': member.birthDate }"
@click="activeDateField = `member-${idx}`">
<text>{{ member.birthDate ? member.birthDate.split('年')[0] + '年...' : '选择诞辰' }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
<!-- 底部按钮 -->
<view class="testname-submit-section">
<button class="testname-submit-btn" :class="{ 'testname-submit-btn-disabled': !isValid }" @click="handleStart">
<view class="testname-submit-btn-content">
<SearchIcon :size="18" class="testname-submit-icon" />
<text class="testname-submit-text">立即排盘</text>
</view>
<view class="testname-submit-btn-border"></view>
</button>
</view>
<view class="testname-footer-tip">
<text class="testname-footer-text">
易经数理 · 五行生克 · {{ mode === 'personal' ? '三才五格' : '商号吉凶' }}
<text class="testname-footer-subtext">隐私保护您的信息仅用于本次测算不做留存</text>
</text>
</view>
</view>
<!-- 自定义日期选择器 Modal -->
<MysticDatePicker :is-open="!!activeDateField" :title="activeDateField === 'personal' ? '请择良辰' : '核心成员诞辰'"
:default-value="getDefaultValue()" @close="activeDateField = null" @confirm="handleDateConfirm" />
<!-- 加载界面 -->
<MysticCompass
v-if="isLoading"
:title="mode === 'personal' ? '正在推演命盘' : '正在测算商号'"
:subtitle="mode === 'personal' ? '易经数理 · 五行生克 · 三才五格' : '易经数理 · 五行生克 · 商号吉凶'"
:desktop="isDesktopLayout"
@back="handleLoadingBack"
/>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
import { getIsDesktopLayout } from '../../utils/device-layout';
import MysticDatePicker from '../MysticDatePicker.vue';
import MysticCompass from '../MysticCompass.vue';
import CalendarIcon from '../icons/CalendarIcon.vue';
import ProfileIcon from '../icons/ProfileIcon.vue';
import HomeIcon from '../icons/HomeIcon.vue';
import ChevronDownIcon from '../icons/ChevronDownIcon.vue';
import SearchIcon from '../icons/SearchIcon.vue';
interface CoreMember {
name: string;
birthDate: string;
birthDateApi: string;
}
interface PersonalTestParams {
lastName: string;
firstName: string;
gender: 'male' | 'female';
birthDate: string;
}
interface CompanyTestParams {
industry: string;
address: string;
target_audience: string;
members: Array<{ name: string; birth_date: string }>;
}
const emit = defineEmits<{
test: [mode: 'personal' | 'company', params: PersonalTestParams | CompanyTestParams];
}>();
type TestMode = 'personal' | 'company';
const mode = ref<TestMode>('personal');
const isLoading = ref(false);
const isDesktopLayout = ref(
typeof window !== 'undefined' ? getIsDesktopLayout() : false,
);
const syncDesktopLayout = () => {
if (typeof window === 'undefined') return;
isDesktopLayout.value = getIsDesktopLayout();
};
onMounted(() => {
syncDesktopLayout();
if (typeof window !== 'undefined') {
window.addEventListener('resize', syncDesktopLayout, { passive: true });
}
});
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', syncDesktopLayout);
}
});
// 个人表单数据
const personalData = reactive({
lastName: '',
firstName: '',
gender: 'male' as 'male' | 'female',
birthDateDisplay: '',
birthDateApi: '' // 接口格式
});
// 公司表单数据
const companyData = reactive({
companyName: '',
industry: '',
address: '',
targetAudience: '',
members: Array(5).fill(null).map(() => ({ name: '', birthDate: '', birthDateApi: '' } as CoreMember))
});
// 日期选择器状态
const activeDateField = ref<string | null>(null);
// 中文数字
const chNum = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
// 四角装饰样式
const corners = [
{ top: '8px', left: '8px', borderTopWidth: '1px', borderLeftWidth: '1px', borderRightWidth: '0', borderBottomWidth: '0' },
{ top: '8px', right: '8px', borderTopWidth: '1px', borderRightWidth: '1px', borderLeftWidth: '0', borderBottomWidth: '0' },
{ bottom: '8px', left: '8px', borderBottomWidth: '1px', borderLeftWidth: '1px', borderTopWidth: '0', borderRightWidth: '0' },
{ bottom: '8px', right: '8px', borderBottomWidth: '1px', borderRightWidth: '1px', borderTopWidth: '0', borderLeftWidth: '0' }
];
const handleDateConfirm = (displayVal: string, apiVal: string) => {
if (!activeDateField.value) return;
if (activeDateField.value === 'personal') {
personalData.birthDateDisplay = displayVal;
personalData.birthDateApi = apiVal;
} else if (activeDateField.value.startsWith('member-')) {
const index = parseInt(activeDateField.value.split('-')[1]);
companyData.members[index].birthDate = displayVal;
companyData.members[index].birthDateApi = apiVal;
}
activeDateField.value = null;
};
const getDefaultValue = () => {
if (!activeDateField.value) return '';
if (activeDateField.value === 'personal') {
return personalData.birthDateDisplay || '';
} else if (activeDateField.value.startsWith('member-')) {
const index = parseInt(activeDateField.value.split('-')[1]);
return companyData.members[index].birthDate || '';
}
return '';
};
const isValid = computed(() => {
if (mode.value === 'personal') {
return personalData.lastName && personalData.firstName && personalData.birthDateDisplay;
} else {
return companyData.industry && companyData.address &&
companyData.members.some((m: CoreMember) => m.name && m.birthDate);
}
});
const handleStart = () => {
if (!isValid.value) {
uni.showToast({
title: mode.value === 'personal' ? '请填写完整个人信息以获取准确命盘' : '请至少填写主营业务、地址及一位核心成员信息',
icon: 'none'
});
return;
}
// 显示加载界面
isLoading.value = true;
// 触发提交事件,由父组件处理接口调用
if (mode.value === 'personal') {
emit('test', 'personal', {
lastName: personalData.lastName,
firstName: personalData.firstName,
gender: personalData.gender,
birthDate: personalData.birthDateApi
});
} else {
emit('test', 'company', {
companyName: companyData.companyName,
industry: companyData.industry,
address: companyData.address,
target_audience: companyData.targetAudience,
members: companyData.members
.filter((m: CoreMember) => m.name && m.birthDateApi)
.map((m: CoreMember) => ({
name: m.name,
birth_date: m.birthDateApi
}))
});
}
};
// 暴露方法供父组件调用
defineExpose({
closeLoading: () => {
isLoading.value = false;
}
});
// 处理 loading 页面的返回按钮
const handleLoadingBack = () => {
isLoading.value = false;
uni.showToast({
title: '测算结果可在"我的方案"中查看',
icon: 'none',
duration: 2000
});
};
</script>
<style scoped>
.testname-screen {
min-height: 100vh;
width: 100%;
font-family: SimSun, "Songti SC", "Songti TC", "Noto Serif SC", STSong, serif;
display: flex;
flex-direction: column;
align-items: center;
background: #fdfbf7;
position: relative;
overflow-x: hidden;
overflow-y: auto;
}
/* 状态栏占位 */
.status-bar-placeholder {
height: var(--status-bar-height, 0);
width: 100%;
flex-shrink: 0;
}
.testname-bg-texture {
position: absolute;
inset: 0;
opacity: 0.1;
pointer-events: none;
background-image: url("https://www.transparenttextures.com/patterns/rice-paper.png");
}
.testname-top-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: #8b2323;
opacity: 0.8;
}
.testname-container {
width: 100%;
max-width: 100%;
flex: 1;
display: flex;
flex-direction: column;
padding: 40px 20px 32px;
z-index: 10;
overflow-y: auto;
box-sizing: border-box;
}
/* Header */
.testname-header {
text-align: center;
margin-bottom: 32px;
}
.testname-title {
font-size: 28px;
font-weight: bold;
color: #2c2c2c;
letter-spacing: 0.3em;
margin-bottom: 20px;
font-family: SimSun, serif;
display: block;
}
.testname-mode-toggle {
display: flex;
justify-content: center;
margin-top: 0;
margin-bottom: 0;
}
.testname-mode-toggle-bg {
background: rgba(234, 221, 207, 0.5);
padding: 4px;
border-radius: 999px;
display: flex;
align-items: center;
position: relative;
border: 1px solid #dcd3c9;
}
.testname-mode-toggle-slider {
position: absolute;
top: 4px;
bottom: 4px;
background: #fffdf9;
border-radius: 999px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #dcd3c9;
transition: left 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.testname-mode-toggle-btn {
position: relative;
z-index: 10;
padding: 8px 32px;
font-size: 14px;
font-weight: bold;
letter-spacing: 0.24em;
color: #8a8a8a;
transition: color 0.3s;
cursor: pointer;
}
.testname-mode-toggle-btn-active {
color: #8b2323;
}
/* Form Card */
.testname-form-card {
background: #fffdf9;
padding: 24px;
border: 1px solid #eaddcf;
box-shadow: 0 4px 20px -10px rgba(0, 0, 0, 0.1);
position: relative;
margin-bottom: 32px;
}
.testname-corner {
position: absolute;
width: 16px;
height: 16px;
border-color: #8b2323;
opacity: 0.4;
border-style: solid;
}
/* Personal Form */
.testname-form-personal {
display: flex;
flex-direction: column;
gap: 32px;
margin-top: 8px;
}
.testname-name-row {
display: flex;
gap: 16px;
}
.testname-name-group {
flex: 1;
}
.testname-label {
display: block;
font-size: 13px;
color: #8a8a8a;
letter-spacing: 0.24em;
margin-bottom: 8px;
text-align: center;
}
.testname-label-center {
text-align: center;
font-size: 13px;
color: #8a8a8a;
letter-spacing: 0.24em;
margin-bottom: 12px;
}
.testname-label-with-icon {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 12px;
color: #8a8a8a;
letter-spacing: 0.2em;
margin-bottom: 8px;
}
.testname-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.testname-input-wrapper {
position: relative;
border-bottom: 2px solid #e5e5e5;
padding-bottom: 4px;
transition: border-color 0.3s;
}
.testname-input-wrapper-focus {
border-bottom-color: #8b2323;
}
.testname-input-name {
width: 100%;
background: transparent;
text-align: center;
font-size: 24px;
color: #2c2c2c;
font-family: SimSun, serif;
border: none;
outline: none;
}
.testname-input-name::placeholder {
color: #dcd3c9;
}
/* Gender Section */
.testname-gender-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.testname-gender-group {
display: flex;
align-items: center;
gap: 32px;
}
.testname-gender-btn {
width: 80px;
height: 80px;
border-radius: 50%;
border: 1px solid #dcd3c9;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: all 0.3s;
color: #5a5a5a;
cursor: pointer;
}
.testname-gender-btn-active {
border-color: #8b2323;
background: #8b2323;
color: #fdfbf7;
box-shadow: 0 2px 8px rgba(139, 35, 35, 0.2);
transform: scale(1.05);
}
.testname-gender-symbol {
font-size: 24px;
font-family: SimSun, serif;
font-weight: bold;
margin-bottom: 4px;
}
.testname-gender-label {
font-size: 14px;
letter-spacing: 0.24em;
opacity: 0.8;
}
/* Date Section */
.testname-date-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.testname-date-picker-trigger {
position: relative;
border: 1px solid #eaddcf;
background: #fcfaf5;
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.3s;
}
.testname-date-picker-trigger:active {
border-color: rgba(139, 35, 35, 0.5);
}
.testname-date-picker-text {
font-family: SimSun, serif;
font-size: 15px;
letter-spacing: 0.1em;
color: #dcd3c9;
}
.testname-date-picker-text-filled {
color: #2c2c2c;
font-weight: bold;
}
.testname-date-picker-arrow {
position: absolute;
right: 12px;
opacity: 0.5;
transition: opacity 0.3s;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.testname-date-picker-trigger:active .testname-date-picker-arrow {
opacity: 1;
}
/* Company Form */
.testname-form-company {
display: flex;
flex-direction: column;
gap: 24px;
margin-top: 8px;
}
.testname-company-basic {
display: flex;
flex-direction: column;
gap: 16px;
}
.testname-company-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.testname-company-row {
display: flex;
gap: 12px;
}
.testname-company-field-half {
flex: 1;
}
.testname-input-company {
width: 100%;
background: #fcfaf5;
border: 1px solid #eaddcf;
padding: 10px;
font-size: 15px;
color: #2c2c2c;
outline: none;
transition: border-color 0.3s;
box-sizing: border-box;
}
.testname-input-company:focus {
border-color: #8b2323;
}
.testname-input-company::placeholder {
color: #dcd3c9;
}
.testname-divider {
width: 100%;
height: 1px;
background: #eaddcf;
opacity: 0.5;
margin: 8px 0;
}
/* Members Section */
.testname-members-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.testname-members-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.testname-members-tip {
font-size: 11px;
color: rgba(139, 35, 35, 0.6);
}
.testname-members-list {
max-height: 200px;
overflow-y: auto;
}
.testname-member-item {
display: flex;
align-items: center;
gap: 8px;
background: #fcfaf5;
border: 1px solid #eaddcf;
padding: 8px;
border-radius: 4px;
margin-bottom: 8px;
}
.testname-member-number {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(234, 221, 207, 0.3);
border-radius: 50%;
font-size: 11px;
color: #8a8a8a;
font-family: SimSun, serif;
flex-shrink: 0;
}
.testname-member-name {
flex: 1;
background: transparent;
font-size: 14px;
color: #2c2c2c;
border: none;
outline: none;
}
.testname-member-name::placeholder {
color: #dcd3c9;
}
.testname-member-divider {
height: 16px;
width: 1px;
background: #eaddcf;
flex-shrink: 0;
}
.testname-member-date {
cursor: pointer;
font-size: 12px;
padding: 4px 8px;
border-radius: 2px;
color: #dcd3c9;
transition: all 0.3s;
flex-shrink: 0;
}
.testname-member-date:active {
background: rgba(234, 221, 207, 0.3);
}
.testname-member-date-filled {
color: #2c2c2c;
}
/* Submit Button */
.testname-submit-section {
margin-top: 24px;
}
.testname-submit-btn {
width: 100%;
padding: 16px 0;
background: #2c2c2c;
color: #fdfbf7;
letter-spacing: 0.4em;
font-weight: bold;
font-size: 18px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
position: relative;
overflow: hidden;
border: none;
border-radius: 4px;
transition: all 0.3s;
cursor: pointer;
}
.testname-submit-btn:active:not(.testname-submit-btn-disabled) {
background: #1a1a1a;
transform: scale(0.98);
}
.testname-submit-btn-disabled {
background: #dcd3c9;
cursor: not-allowed;
opacity: 0.7;
}
.testname-submit-btn-content {
position: relative;
z-index: 10;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 12px;
}
.testname-submit-icon {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fdfbf7;
}
.testname-submit-btn-disabled .testname-submit-icon {
color: #fdfbf7;
}
.testname-submit-text {
font-size: 18px;
display: inline-block;
vertical-align: middle;
}
.testname-submit-btn-border {
position: absolute;
top: 4px;
bottom: 4px;
left: 4px;
right: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
pointer-events: none;
}
/* Footer Tip */
.testname-footer-tip {
margin-top: 32px;
text-align: center;
padding: 0 16px;
}
.testname-footer-text {
font-size: 12px;
color: rgba(138, 138, 138, 0.8);
line-height: 1.8;
font-family: SimSun, serif;
display: block;
margin-bottom: 8px;
}
.testname-footer-subtext {
display: block;
font-size: 11px;
color: rgba(138, 138, 138, 0.6);
line-height: 1.6;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
<template>
<view class="test-result-screen">
<!-- 背景 -->
<view class="test-result-bg"></view>
<!-- 头部 -->
<view class="test-result-header">
<view class="header-left" @click="emit('back')">
<text class="header-back"></text>
</view>
<view class="header-title-wrap">
<text class="header-title">{{ mode === 'personal' ? '个人测名结果' : '公司测名结果' }}</text>
<text class="header-subtitle">接口原始数据展示无任何模拟与加工</text>
</view>
<view class="header-right" />
</view>
<!-- 内容 -->
<scroll-view scroll-y class="test-result-body">
<view class="test-result-card">
<text class="section-title">原始返回 JSON</text>
<view class="json-box">
<text class="json-text">
{{ formattedJson }}
</text>
</view>
<text class="tip-text">
当前为后端返回数据的直接展示用于确保与接口保持 100% 一致后续可以在此基础上做更精细的可视化拆解
</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
mode: 'personal' | 'company';
data: any;
}>();
const emit = defineEmits<{
back: [];
}>();
const formattedJson = computed(() => {
try {
return JSON.stringify(props.data ?? {}, null, 2);
} catch (e) {
return String(props.data ?? '');
}
});
</script>
<style scoped>
.test-result-screen {
height: 100%;
display: flex;
flex-direction: column;
position: relative;
background-color: #050508;
color: #f5f5f5;
}
.test-result-bg {
position: absolute;
inset: 0;
background: radial-gradient(circle at top, #1a1a2e 0, #050508 40%, #000 100%);
opacity: 0.9;
}
.test-result-header {
position: relative;
z-index: 10;
padding: 24rpx 32rpx;
padding-top: calc(24rpx + env(safe-area-inset-top, 0px));
display: flex;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(5, 5, 8, 0.9);
backdrop-filter: blur(10px);
}
.header-left,
.header-right {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
}
.header-back {
font-size: 40rpx;
color: #a0a0a0;
}
.header-title-wrap {
flex: 1;
align-items: center;
justify-content: center;
text-align: center;
}
.header-title {
font-size: 30rpx;
font-weight: 700;
letter-spacing: 0.2em;
color: #f2e6d8;
}
.header-subtitle {
margin-top: 6rpx;
font-size: 20rpx;
color: rgba(226, 232, 240, 0.7);
}
.test-result-body {
position: relative;
z-index: 10;
flex: 1;
padding: 32rpx;
}
.test-result-card {
background: rgba(15, 23, 42, 0.9);
border-radius: 20rpx;
padding: 28rpx;
border: 1px solid rgba(148, 163, 184, 0.4);
box-shadow: 0 20rpx 40rpx rgba(0, 0, 0, 0.5);
}
.section-title {
font-size: 26rpx;
font-weight: 700;
letter-spacing: 0.16em;
margin-bottom: 20rpx;
color: #e5e7eb;
}
.json-box {
background: #020617;
border-radius: 16rpx;
padding: 20rpx;
border: 1px solid rgba(15, 23, 42, 0.9);
}
.json-text {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 22rpx;
color: #e5e7eb;
line-height: 1.7;
white-space: pre-wrap;
}
.tip-text {
margin-top: 20rpx;
font-size: 20rpx;
color: #9ca3af;
}
</style>

View File

@@ -0,0 +1,211 @@
<template>
<view class="agreement-screen">
<view class="agreement-header">
<view class="agreement-back" @click="handleBack">
<text class="agreement-back-icon"></text>
</view>
<text class="agreement-title">用户协议</text>
</view>
<view class="agreement-content">
<view class="agreement-section">
<text class="agreement-section-title">协议的接受与修改</text>
<text class="agreement-text">
欢迎使用壹梵起名服务本协议是您与壹梵起名之间关于使用壹梵起名服务所订立的协议请您仔细阅读本协议您点击"同意"按钮后本协议即构成对双方有约束力的法律文件
</text>
</view>
<view class="agreement-section">
<text class="agreement-section-title">服务说明</text>
<text class="agreement-text">
壹梵起名向用户提供包括但不限于宝宝起名姓名测试公司起名改名建议择吉日等服务具体服务内容以平台实际提供为准
</text>
</view>
<view class="agreement-section">
<text class="agreement-section-title">用户账号</text>
<text class="agreement-text">
1. 用户需要注册账号才能使用本服务用户应当提供真实准确完整的个人信息
</text>
<text class="agreement-text">
2. 用户应妥善保管账号和密码对账号下的所有行为负责
</text>
<text class="agreement-text">
3. 用户不得将账号转让出借或以其他方式提供给第三方使用
</text>
</view>
<view class="agreement-section">
<text class="agreement-section-title">用户行为规范</text>
<text class="agreement-text">
用户在使用本服务时应遵守国家法律法规不得利用本服务从事违法违规活动包括但不限于
</text>
<text class="agreement-text">
1. 发布传播违法违规信息
</text>
<text class="agreement-text">
2. 侵犯他人知识产权或其他合法权益
</text>
<text class="agreement-text">
3. 干扰或破坏服务的正常运行
</text>
<text class="agreement-text">
4. 其他违反法律法规或本协议的行为
</text>
</view>
<view class="agreement-section">
<text class="agreement-section-title">知识产权</text>
<text class="agreement-text">
本服务中的所有内容包括但不限于文字图片软件程序等其知识产权均归壹梵起名或相关权利人所有未经授权用户不得擅自使用
</text>
</view>
<view class="agreement-section">
<text class="agreement-section-title">免责声明</text>
<text class="agreement-text">
1. 本服务提供的起名测名等内容仅供参考不构成任何承诺或保证
</text>
<text class="agreement-text">
2. 因不可抗力网络故障等原因导致的服务中断或数据丢失壹梵起名不承担责任
</text>
<text class="agreement-text">
3. 用户因使用本服务产生的任何纠纷应依法解决
</text>
</view>
<view class="agreement-section">
<text class="agreement-section-title">协议的变更与终止</text>
<text class="agreement-text">
壹梵起名有权根据需要修改本协议修改后的协议将在平台上公布用户继续使用服务即视为接受修改后的协议
</text>
</view>
<view class="agreement-section">
<text class="agreement-section-title">其他</text>
<text class="agreement-text">
本协议的解释效力及纠纷的解决适用中华人民共和国法律如有争议双方应友好协商解决协商不成的任何一方均可向壹梵起名所在地人民法院提起诉讼
</text>
</view>
<view class="agreement-footer">
<text class="agreement-footer-text">壹梵起名</text>
<text class="agreement-footer-text">生效日期2024年1月1日</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
const handleBack = () => {
router.back();
};
</script>
<style scoped>
.agreement-screen {
min-height: 100vh;
width: 100%;
background: #fdfbf7 url("https://www.transparenttextures.com/patterns/rice-paper.png");
display: flex;
flex-direction: column;
overflow-x: hidden;
}
.agreement-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
padding: 16px 20px;
background: rgba(253, 251, 247, 0.95);
border-bottom: 1px solid #eaddcf;
backdrop-filter: blur(10px);
flex-shrink: 0;
}
.agreement-back {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-right: 12px;
flex-shrink: 0;
}
.agreement-back-icon {
font-size: 24px;
color: #8b2323;
font-weight: bold;
}
.agreement-title {
font-size: 18px;
font-weight: 500;
color: #2c2c2c;
font-family: SimSun, "Songti SC", serif;
}
.agreement-content {
flex: 1;
padding: 24px 20px 40px;
overflow-y: auto;
overflow-x: hidden;
width: 100%;
box-sizing: border-box;
}
.agreement-section {
margin-bottom: 24px;
width: 100%;
}
.agreement-section-title {
display: block;
font-size: 16px;
font-weight: 600;
color: #8b2323;
margin-bottom: 12px;
font-family: SimSun, "Songti SC", serif;
word-wrap: break-word;
word-break: break-word;
}
.agreement-text {
display: block;
font-size: 14px;
line-height: 1.8;
color: #4a4a4a;
margin-bottom: 8px;
font-family: SimSun, "Songti SC", serif;
word-wrap: break-word;
word-break: break-word;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.agreement-footer {
margin-top: 40px;
padding-top: 24px;
border-top: 1px solid #eaddcf;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.agreement-footer-text {
display: block;
font-size: 13px;
color: #8a8a8a;
font-family: SimSun, "Songti SC", serif;
}
</style>