- 新增商业化模块:Stripe/Binance Pay 支付、邮箱登录、订单管理 - 新增数据模型:ReportProduct, ReportEntitlement, ReportSnapshot, PaymentEvent, EmailLoginCode - 新增 API:/api/commerce/checkout, /api/commerce/webhooks/*, /api/commerce/orders/* - 新增管理后台:订单列表、统计图表、统一 AdminHeader 组件 - 新增中间件测试:RateLimit, Auth, CORS 共 7 个测试用例 - 新增 AGENTS.md 项目代理指令文件 - 新增 REMINDER.md 商业化功能开发规范 - 修复 JWT 解析:添加 WithValidMethods 限制 HS256 - 修复 GetPayStatus:增加用户权限校验 - 更新 .gitignore 和 README.md
This commit is contained in:
@@ -0,0 +1,708 @@
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowRight,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Clock3,
|
||||
Eye,
|
||||
Fingerprint,
|
||||
HeartHandshake,
|
||||
LockKeyhole,
|
||||
MapPin,
|
||||
Menu,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
WalletCards,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
analyzeElementBalance,
|
||||
analyzeFortuneGanzhi,
|
||||
analyzeShensha,
|
||||
birthInfoToBazi,
|
||||
checkStemCombine,
|
||||
checkStemOpposite,
|
||||
getBranchRelationship,
|
||||
} from '@lunar/core';
|
||||
import type { BaziFullResult, ElementProfile, ShenshaInfo } from '@lunar/core';
|
||||
import {
|
||||
authEmailKey,
|
||||
authTokenKey,
|
||||
checkReportAccess,
|
||||
createCheckout,
|
||||
isDemoPayment,
|
||||
listPurchasedReports,
|
||||
recoverReport,
|
||||
requestEmailCode,
|
||||
saveChartRecord,
|
||||
verifyEmailCode,
|
||||
waitForPaidOrder,
|
||||
type PaymentChannel,
|
||||
} from './payment';
|
||||
import { createReportId, type PremiumSection } from './report';
|
||||
|
||||
interface BirthForm {
|
||||
name: string;
|
||||
date: string;
|
||||
time: string;
|
||||
gender: 'male' | 'female';
|
||||
city: string;
|
||||
}
|
||||
|
||||
interface ReportState {
|
||||
id: string;
|
||||
result: BaziFullResult;
|
||||
elements: ElementProfile;
|
||||
shensha: ShenshaInfo[];
|
||||
sections: PremiumSection[];
|
||||
}
|
||||
|
||||
const CITIES = [
|
||||
['不校正(标准时间)', 120],
|
||||
['北京', 116.4], ['上海', 121.5], ['广州', 113.3], ['深圳', 114.1], ['杭州', 120.2],
|
||||
['成都', 104.1], ['重庆', 106.5], ['武汉', 114.3], ['西安', 108.9], ['南京', 118.8],
|
||||
['长沙', 113], ['郑州', 113.7], ['天津', 117.2], ['昆明', 102.7], ['乌鲁木齐', 87.6],
|
||||
] as const;
|
||||
|
||||
const ELEMENTS = [
|
||||
['木', 'wood'], ['火', 'fire'], ['土', 'earth'], ['金', 'metal'], ['水', 'water'],
|
||||
] as const;
|
||||
|
||||
const PILLARS = [
|
||||
['年柱', 'yearPillar'], ['月柱', 'monthPillar'], ['日柱', 'dayPillar'], ['时柱', 'hourPillar'],
|
||||
] as const;
|
||||
|
||||
const DEFAULT_FORM: BirthForm = {
|
||||
name: '',
|
||||
date: '1990-08-08',
|
||||
time: '10:30',
|
||||
gender: 'female',
|
||||
city: '不校正(标准时间)',
|
||||
};
|
||||
|
||||
const LOCKED_SECTIONS: PremiumSection[] = [
|
||||
['壹', '性格底色'], ['贰', '事业天赋'], ['叁', '财富节奏'],
|
||||
['肆', '关系模式'], ['伍', '身心能量'], ['陆', '大运路线'],
|
||||
].map(([index, title]) => ({
|
||||
index,
|
||||
title,
|
||||
kicker: '付费深度解读',
|
||||
summary: '完成购买后查看与你的命盘对应的个性化分析。',
|
||||
details: ['结合你的命盘结构生成', '提供针对性的观察与建议', '支付后永久回看'],
|
||||
}));
|
||||
|
||||
function getElementValue(elements: ElementProfile, key: string): number {
|
||||
const map: Record<string, number> = {
|
||||
wood: elements.wood,
|
||||
fire: elements.fire,
|
||||
earth: elements.earth,
|
||||
metal: elements.metal,
|
||||
water: elements.water,
|
||||
};
|
||||
return map[key] ?? 0;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [form, setForm] = useState<BirthForm>(DEFAULT_FORM);
|
||||
const [report, setReport] = useState<ReportState | null>(null);
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
const [isUnlocked, setIsUnlocked] = useState(false);
|
||||
const [showCheckout, setShowCheckout] = useState(false);
|
||||
const [showRecovery, setShowRecovery] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [paymentNotice, setPaymentNotice] = useState('');
|
||||
const resultRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const orderNo = params.get('order') ?? sessionStorage.getItem('lunar-pending-order');
|
||||
const reportId = sessionStorage.getItem('lunar-pending-report');
|
||||
const token = localStorage.getItem(authTokenKey);
|
||||
if (params.get('payment') !== 'success' || !orderNo || !reportId || !token) return;
|
||||
setPaymentNotice('正在确认支付结果,请稍候…');
|
||||
waitForPaidOrder(orderNo, token).then(async accessCode => {
|
||||
if (!accessCode) {
|
||||
setPaymentNotice('支付仍在确认中,你可以稍后重新打开报告。');
|
||||
return;
|
||||
}
|
||||
const recovered = await recoverReport<ReportState>(accessCode, token);
|
||||
localStorage.setItem(`lunar-report:${reportId}`, 'unlocked');
|
||||
setReport(recovered.snapshot);
|
||||
setIsUnlocked(true);
|
||||
setPaymentNotice(`支付已确认。你的报告恢复码是 ${accessCode},请妥善保存。`);
|
||||
}).catch(() => setPaymentNotice('暂时无法读取付费报告,请稍后重新打开报告。'));
|
||||
}, []);
|
||||
|
||||
const calculate = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsCalculating(true);
|
||||
const [year, month, day] = form.date.split('-').map(Number);
|
||||
const [hour, minute] = form.time.split(':').map(Number);
|
||||
const longitude = CITIES.find(([name]) => name === form.city)?.[1] ?? 120;
|
||||
const offsetMinutes = Math.round((longitude - 120) * 4);
|
||||
const adjusted = new Date(Date.UTC(year, month - 1, day, hour, minute) + offsetMinutes * 60_000);
|
||||
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
const result = birthInfoToBazi({
|
||||
year: adjusted.getUTCFullYear(),
|
||||
month: adjusted.getUTCMonth() + 1,
|
||||
day: adjusted.getUTCDate(),
|
||||
hour: adjusted.getUTCHours(),
|
||||
minute: adjusted.getUTCMinutes(),
|
||||
gender: form.gender,
|
||||
ziSect: 'lateZiNextDay',
|
||||
});
|
||||
result.solarAdjusted = longitude !== 120;
|
||||
const elements = analyzeElementBalance(result.eightChar);
|
||||
const shensha = analyzeShensha(result.eightChar);
|
||||
const id = createReportId(`${form.date}|${form.time}|${form.gender}|${form.city}`);
|
||||
const chartSnapshot: ReportState = { id, result, elements, shensha, sections: [] };
|
||||
setReport(chartSnapshot);
|
||||
void saveChartRecord({
|
||||
reportId: id,
|
||||
name: form.name,
|
||||
birthDate: form.date,
|
||||
birthTime: form.time,
|
||||
gender: form.gender,
|
||||
city: form.city,
|
||||
snapshot: chartSnapshot,
|
||||
}).catch(() => undefined);
|
||||
const localAccess = localStorage.getItem(`lunar-report:${id}`) === 'unlocked';
|
||||
setIsUnlocked(localAccess);
|
||||
const token = localStorage.getItem(authTokenKey);
|
||||
if (!isDemoPayment && token && !localAccess) {
|
||||
checkReportAccess(id, token).then(setIsUnlocked).catch(() => undefined);
|
||||
}
|
||||
setIsCalculating(false);
|
||||
window.setTimeout(() => resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80);
|
||||
} catch {
|
||||
setIsCalculating(false);
|
||||
window.alert('日期或时间无法完成排盘,请检查后重试。');
|
||||
}
|
||||
}, 900);
|
||||
};
|
||||
|
||||
const updateForm = <K extends keyof BirthForm>(key: K, value: BirthForm[K]) => {
|
||||
setForm(current => ({ ...current, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="site-shell">
|
||||
<Header showMenu={showMenu} onToggleMenu={() => setShowMenu(value => !value)} onRecovery={() => setShowRecovery(true)} />
|
||||
|
||||
<main>
|
||||
{paymentNotice && <div className="payment-notice"><CheckCircle2 />{paymentNotice}<button onClick={() => setPaymentNotice('')} aria-label="关闭提示"><X /></button></div>}
|
||||
<section className="hero" id="top">
|
||||
<div className="hero-orbit orbit-one">甲</div>
|
||||
<div className="hero-orbit orbit-two">子</div>
|
||||
<div className="hero-copy reveal-one">
|
||||
<div className="eyebrow"><span />东方命理 · 私人解读</div>
|
||||
<h1>看懂命盘,<br /><em>更懂自己。</em></h1>
|
||||
<p className="hero-lead">输入出生信息,免费查看四柱与五行。完整报告将性格、事业、财富、关系与大运,整理成一份可以反复阅读的人生说明书。</p>
|
||||
<div className="trust-row">
|
||||
<span><ShieldCheck size={17} /> 本地计算保护隐私</span>
|
||||
<span><WalletCards size={17} /> 一次购买永久回看</span>
|
||||
<span><HeartHandshake size={17} /> 无自动续费</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="birth-card reveal-two" onSubmit={calculate}>
|
||||
<div className="card-heading">
|
||||
<div><span className="step-number">01</span><p>填写你的出生信息</p></div>
|
||||
<span className="free-chip">基础排盘免费</span>
|
||||
</div>
|
||||
<label className="field full-field">
|
||||
<span>怎么称呼你</span>
|
||||
<input value={form.name} onChange={event => updateForm('name', event.target.value)} placeholder="可不填,仅用于报告称呼" maxLength={12} />
|
||||
</label>
|
||||
<div className="field-grid">
|
||||
<label className="field">
|
||||
<span>出生日期</span>
|
||||
<input required type="date" min="1901-01-01" max="2100-12-31" value={form.date} onChange={event => updateForm('date', event.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>出生时间</span>
|
||||
<input required type="time" value={form.time} onChange={event => updateForm('time', event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="field-grid">
|
||||
<fieldset className="field segmented-field">
|
||||
<legend>性别</legend>
|
||||
<div className="segment-control">
|
||||
<button type="button" className={form.gender === 'female' ? 'active' : ''} onClick={() => updateForm('gender', 'female')}>女</button>
|
||||
<button type="button" className={form.gender === 'male' ? 'active' : ''} onClick={() => updateForm('gender', 'male')}>男</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<label className="field select-field">
|
||||
<span>出生城市</span>
|
||||
<MapPin size={16} />
|
||||
<select value={form.city} onChange={event => updateForm('city', event.target.value)}>
|
||||
{CITIES.map(([city]) => <option value={city} key={city}>{city}</option>)}
|
||||
</select>
|
||||
<ChevronDown size={15} />
|
||||
</label>
|
||||
</div>
|
||||
<button className="primary-action" type="submit" disabled={isCalculating}>
|
||||
{isCalculating ? <><span className="loading-mark" /> 正在推演命盘...</> : <>免费开始排盘 <ArrowRight size={19} /></>}
|
||||
</button>
|
||||
<p className="form-note"><Fingerprint size={14} /> 出生资料仅在当前设备参与计算,不会公开展示</p>
|
||||
</form>
|
||||
|
||||
<a className="scroll-cue" href="#how"><span>了解报告</span><ArrowDown size={16} /></a>
|
||||
</section>
|
||||
|
||||
<ValueStrip />
|
||||
|
||||
{report && (
|
||||
<Report
|
||||
refElement={resultRef}
|
||||
report={report}
|
||||
name={form.name}
|
||||
isUnlocked={isUnlocked}
|
||||
onUnlock={() => setShowCheckout(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<HowItWorks />
|
||||
<Offer onStart={() => document.querySelector<HTMLButtonElement>('.primary-action')?.scrollIntoView({ behavior: 'smooth', block: 'center' })} />
|
||||
<Faq />
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div className="seal small-seal">知命</div>
|
||||
<p>知命 · 东方命理研习所</p>
|
||||
<span>传统文化研究与娱乐参考,不构成医疗、法律、投资或人生决策建议。</span>
|
||||
</footer>
|
||||
|
||||
{report && showCheckout && (
|
||||
<Checkout
|
||||
reportId={report.id}
|
||||
snapshot={report}
|
||||
onClose={() => setShowCheckout(false)}
|
||||
onPaid={(paidReport) => {
|
||||
if (paidReport) setReport(paidReport);
|
||||
localStorage.setItem(`lunar-report:${report.id}`, 'unlocked');
|
||||
setIsUnlocked(true);
|
||||
setShowCheckout(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showRecovery && (
|
||||
<RecoveryModal
|
||||
onClose={() => setShowRecovery(false)}
|
||||
onRecovered={recovered => {
|
||||
setReport(recovered);
|
||||
setIsUnlocked(true);
|
||||
setShowRecovery(false);
|
||||
window.setTimeout(() => resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{report && (!isUnlocked || report.sections.length === 0) && <MobilePayBar onClick={() => setShowCheckout(true)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ showMenu, onToggleMenu, onRecovery }: { showMenu: boolean; onToggleMenu: () => void; onRecovery: () => void }) {
|
||||
return (
|
||||
<header>
|
||||
<a className="brand" href="#top"><span className="seal">知命</span><span><b>知命</b><small>东方命理研习所</small></span></a>
|
||||
<nav className={showMenu ? 'open' : ''}>
|
||||
<a href="#top">首页</a><a href="#how">解读方式</a><a href="#offer">完整报告</a><a href="#faq">常见问题</a>
|
||||
</nav>
|
||||
<button className="recovery-link" onClick={onRecovery}>找回已购报告</button>
|
||||
<a className="header-cta" href="#top">免费排盘</a>
|
||||
<button className="menu-button" onClick={onToggleMenu} aria-label="切换导航">{showMenu ? <X /> : <Menu />}</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function ValueStrip() {
|
||||
return (
|
||||
<section className="value-strip" aria-label="服务特点">
|
||||
<div><b>四柱</b><span>年月日时精准排盘</span></div>
|
||||
<div><b>五行</b><span>强弱结构清晰呈现</span></div>
|
||||
<div><b>六章</b><span>完整人生主题解读</span></div>
|
||||
<div><b>十运</b><span>长期阶段节奏梳理</span></div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Report({ refElement, report, name, isUnlocked, onUnlock }: { refElement: React.RefObject<HTMLElement | null>; report: ReportState; name: string; isUnlocked: boolean; onUnlock: () => void }) {
|
||||
const chart = report.result.eightChar;
|
||||
const freeInsights = useMemo(() => [
|
||||
`你的日主是${chart.dayMaster},这是理解整张命盘的中心。`,
|
||||
`命局中${report.elements.dominant}较突出,${report.elements.weakest}相对偏弱。`,
|
||||
report.shensha[0] ? `盘中可见「${report.shensha[0].name}」,落在${report.shensha[0].foundIn.join('、')}。` : '命盘结构更适合从五行流通与阶段节奏综合观察。',
|
||||
], [chart, report]);
|
||||
const premiumLoaded = isUnlocked && report.sections.length > 0;
|
||||
const visibleSections = premiumLoaded ? report.sections : LOCKED_SECTIONS;
|
||||
|
||||
return (
|
||||
<section className="report-section" ref={refElement}>
|
||||
<div className="section-heading centered">
|
||||
<span>YOUR CHART</span>
|
||||
<h2>{name ? `${name}的` : '你的'}先天命盘</h2>
|
||||
<p>报告编号 {report.id} · {report.result.solarAdjusted ? '已按出生城市经度校正时间' : '按输入的标准时间排盘'}</p>
|
||||
</div>
|
||||
|
||||
<div className="chart-paper">
|
||||
<div className="paper-topline"><span>乾坤有序 · 四时有信</span><strong>日主 {chart.dayMaster}</strong></div>
|
||||
<div className="pillars-grid">
|
||||
{PILLARS.map(([label, key], index) => {
|
||||
const pillar = chart[key];
|
||||
return (
|
||||
<div className={`pillar ${index === 2 ? 'day-pillar' : ''}`} key={key}>
|
||||
<span>{label}</span>
|
||||
<small>{pillar.tenStar ?? '日主'}</small>
|
||||
<b className={`element-${pillar.elementStem}`}>{pillar.heavenStem}</b>
|
||||
<b className={`element-${pillar.elementBranch}`}>{pillar.earthBranch}</b>
|
||||
<i>{pillar.nayin}</i>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="chart-meta">
|
||||
<span>命宫 <b>{chart.ownSign}</b></span><span>身宫 <b>{chart.bodySign}</b></span><span>胎元 <b>{chart.fetalOrigin}</b></span><span>空亡 <b>{chart.emptyBranches.join('')}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="preview-grid">
|
||||
<article className="elements-card">
|
||||
<div className="mini-heading"><span>02</span><h3>五行能量</h3><i>免费查看</i></div>
|
||||
<div className="element-list">
|
||||
{ELEMENTS.map(([label, key]) => {
|
||||
const value = getElementValue(report.elements, key);
|
||||
const percent = Math.max(8, Math.round((value / report.elements.total) * 100));
|
||||
return <div className="element-row" key={key}><span className={`element-dot ${key}`}>{label}</span><div><i style={{ width: `${percent}%` }} /></div><b>{value.toFixed(1)}</b></div>;
|
||||
})}
|
||||
</div>
|
||||
<p>五行计算包含四柱天干、地支与藏干权重,结果用于观察结构倾向。</p>
|
||||
</article>
|
||||
|
||||
<article className="insight-card">
|
||||
<div className="mini-heading light"><span>03</span><h3>命盘初读</h3><i>已解锁</i></div>
|
||||
<div className="insight-list">
|
||||
{freeInsights.map((insight, index) => <div key={insight}><b>0{index + 1}</b><p>{insight}</p></div>)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<TechnicalChart key={report.id} report={report} />
|
||||
|
||||
<div className="premium-report">
|
||||
<div className="section-heading centered light-heading">
|
||||
<span>FULL READING</span>
|
||||
<h2>{premiumLoaded ? '完整命理解读' : '还有更重要的部分'}</h2>
|
||||
<p>{premiumLoaded ? '六大人生主题已全部解锁,可随时回来阅读。' : '命盘告诉你“是什么”,完整解读帮你理解“如何用”。'}</p>
|
||||
</div>
|
||||
|
||||
<div className="chapter-grid">
|
||||
{visibleSections.map((section, index) => (
|
||||
<article className={premiumLoaded ? 'chapter unlocked' : 'chapter locked'} key={section.index}>
|
||||
<div className="chapter-title"><span>{section.index}</span><div><small>{section.kicker}</small><h3>{section.title}</h3></div>{!premiumLoaded && <LockKeyhole size={18} />}</div>
|
||||
<p className="chapter-summary">{section.summary}</p>
|
||||
<ul>{section.details.map(detail => <li key={detail}><Check size={14} />{detail}</li>)}</ul>
|
||||
{!premiumLoaded && <div className="veil" style={{ opacity: Math.min(0.92, 0.58 + index * 0.055) }} />}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!premiumLoaded && (
|
||||
<div className="unlock-panel">
|
||||
<div className="unlock-icon"><Eye size={23} /></div>
|
||||
<div><span>一次解锁 · 永久回看</span><h3>你的完整命理精批报告</h3><p>性格、事业、财富、感情、身心与十步大运,共 6 大主题。</p></div>
|
||||
<div className="price"><small>完整报告</small><b><sup>$</sup>4.99</b><del>$9.99</del></div>
|
||||
<button onClick={onUnlock}>立即解锁完整报告 <ArrowRight size={18} /></button>
|
||||
<p className="safe-pay"><ShieldCheck size={14} /> 无订阅、无自动续费 · 支付后当前设备永久查看</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TechnicalChart({ report }: { report: ReportState }) {
|
||||
const chart = report.result.eightChar;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const relationships = useMemo(() => {
|
||||
const items: { label: string; detail: string; tone: string }[] = [];
|
||||
for (let first = 0; first < PILLARS.length; first += 1) {
|
||||
for (let second = first + 1; second < PILLARS.length; second += 1) {
|
||||
const [firstLabel, firstKey] = PILLARS[first];
|
||||
const [secondLabel, secondKey] = PILLARS[second];
|
||||
const firstPillar = chart[firstKey];
|
||||
const secondPillar = chart[secondKey];
|
||||
const position = `${firstLabel} · ${secondLabel}`;
|
||||
if (checkStemCombine(firstPillar.heavenStem, secondPillar.heavenStem)) items.push({ label: '天干五合', detail: `${firstPillar.heavenStem}${secondPillar.heavenStem}合 · ${position}`, tone: 'good' });
|
||||
if (checkStemOpposite(firstPillar.heavenStem, secondPillar.heavenStem)) items.push({ label: '天干相冲', detail: `${firstPillar.heavenStem}${secondPillar.heavenStem}冲 · ${position}`, tone: 'alert' });
|
||||
const branch = getBranchRelationship(firstPillar.earthBranch, secondPillar.earthBranch);
|
||||
if (branch.combine) items.push({ label: '地支六合', detail: `${firstPillar.earthBranch}${secondPillar.earthBranch}合 · ${position}`, tone: 'good' });
|
||||
if (branch.opposite) items.push({ label: '地支六冲', detail: `${firstPillar.earthBranch}${secondPillar.earthBranch}冲 · ${position}`, tone: 'alert' });
|
||||
if (branch.harm) items.push({ label: '地支六害', detail: `${firstPillar.earthBranch}${secondPillar.earthBranch}害 · ${position}`, tone: 'warn' });
|
||||
if (branch.punish) items.push({ label: '地支相刑', detail: `${firstPillar.earthBranch}${secondPillar.earthBranch}刑 · ${position}`, tone: 'warn' });
|
||||
if (branch.threeCombine) items.push({ label: '三合局成员', detail: `${firstPillar.earthBranch}${secondPillar.earthBranch} · ${branch.formation ?? ''}`, tone: 'good' });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [chart]);
|
||||
const fortunes = useMemo(() => report.result.decadeFortunes.map(item => ({
|
||||
...item,
|
||||
analysis: analyzeFortuneGanzhi(item.ganzhi, chart.dayMasterStem, chart.dayPillar.earthBranch),
|
||||
})), [report, chart]);
|
||||
const currentDecadeIndex = fortunes.findIndex(item => currentYear >= item.startYear && currentYear <= item.endYear);
|
||||
const [selectedIndex, setSelectedIndex] = useState(currentDecadeIndex >= 0 ? currentDecadeIndex : 0);
|
||||
const selectedFortune = fortunes[selectedIndex] ?? fortunes[0];
|
||||
const childLimit = report.result.childLimit;
|
||||
const approxStartAge = childLimit.yearCount + ((childLimit.monthCount || childLimit.dayCount || childLimit.hourCount || childLimit.minuteCount) ? 1 : 0);
|
||||
|
||||
return (
|
||||
<section className="technical-chart">
|
||||
<div className="section-heading centered"><span>TRADITIONAL CHART</span><h2>命盘细节与运程</h2><p>排盘数据免费完整展示,付费部分只负责深度解释。</p></div>
|
||||
<div className="luck-header">
|
||||
<div><small>起运年龄</small><b>约 {approxStartAge} 岁</b><i>{childLimit.yearCount}年{childLimit.monthCount}月{childLimit.dayCount}天后</i></div>
|
||||
<div><small>顺逆</small><b>{childLimit.forward ? '顺行' : '逆行'}</b></div>
|
||||
<div><small>交运时间</small><b>{childLimit.startTime}</b></div>
|
||||
<div><small>当前日主</small><b>{chart.dayMaster}</b></div>
|
||||
</div>
|
||||
|
||||
<article className="luck-panel">
|
||||
<div className="technical-title"><div><span>04</span><h3>大运 · 十年一步</h3></div><small>点击大运查看对应流年</small></div>
|
||||
<div className="decade-track">
|
||||
{fortunes.map((item, index) => {
|
||||
const current = currentYear >= item.startYear && currentYear <= item.endYear;
|
||||
const selected = index === selectedIndex;
|
||||
return <button type="button" className={`decade-card${selected ? ' selected' : ''}${current ? ' current' : ''}`} key={item.index} onClick={() => setSelectedIndex(index)}><small>{item.startAge}—{item.endAge}岁</small><b>{item.ganzhi}</b><span>{item.analysis.tenStar || '比肩'} · {item.analysis.elementRelation}</span><i>{item.startYear}—{item.endYear}</i>{current && <em>当前</em>}</button>;
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="luck-panel annual-panel">
|
||||
<div className="technical-title"><div><span>05</span><h3>流年 · {selectedFortune.ganzhi}({selectedFortune.startYear}—{selectedFortune.endYear})</h3></div><small>小运以出生后逐年干支观察</small></div>
|
||||
<div className="annual-track">
|
||||
{selectedFortune.annualFortunes.map(item => <div className={item.year === currentYear ? 'active' : ''} key={item.year}><small>{item.age}岁</small><b>{item.ganzhi}</b><span>{item.year}</span></div>)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="technical-grid">
|
||||
<article className="relation-panel">
|
||||
<div className="technical-title"><div><span>06</span><h3>刑冲合害</h3></div><small>四柱内部关系</small></div>
|
||||
<div className="relation-list">
|
||||
{relationships.length ? relationships.map(item => <div className={item.tone} key={`${item.label}-${item.detail}`}><b>{item.label}</b><span>{item.detail}</span></div>) : <p className="empty-technical">四柱之间未见主要的合、冲、刑、害关系。</p>}
|
||||
</div>
|
||||
</article>
|
||||
<article className="shensha-panel">
|
||||
<div className="technical-title"><div><span>07</span><h3>神煞</h3></div><small>共命中 {report.shensha.length} 项</small></div>
|
||||
<div className="shensha-list">
|
||||
{report.shensha.map(item => <div className={`star-${item.type}`} key={item.name}><span>{item.type}</span><div><b>{item.name}</b><small>{item.foundIn.join('、')} · {item.description}</small></div></div>)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
<p className="technical-note">神煞流派版本较多,本页面采用常见排盘规则,仅作辅助观察,不单独作为判断依据。</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HowItWorks() {
|
||||
return (
|
||||
<section className="how-section" id="how">
|
||||
<div className="section-heading"><span>THE METHOD</span><h2>不是一句判词,<br />而是一套理解自己的方法。</h2></div>
|
||||
<div className="method-grid">
|
||||
<article><span>01</span><Clock3 /><h3>校准出生时刻</h3><p>结合出生城市经度进行真太阳时校正,避免地区时差影响时柱。</p></article>
|
||||
<article><span>02</span><Sparkles /><h3>拆解命盘结构</h3><p>从四柱、十神、藏干、五行与神煞,多层观察先天倾向。</p></article>
|
||||
<article><span>03</span><Eye /><h3>翻译成行动建议</h3><p>不制造恐惧,不下绝对结论,把传统术语整理成可理解的生活语言。</p></article>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Offer({ onStart }: { onStart: () => void }) {
|
||||
return (
|
||||
<section className="offer-section" id="offer">
|
||||
<div className="offer-copy"><span className="vertical-mark">知 命 而 行</span><div className="section-heading light-heading"><span>YOUR PRIVATE REPORT</span><h2>人生没有标准答案,<br />但可以多一份清醒。</h2><p>先免费查看自己的命盘。确认内容对你有价值,再决定是否解锁完整报告。</p></div></div>
|
||||
<div className="offer-card">
|
||||
<span className="offer-tag">单份完整报告</span><div className="offer-price"><b><sup>$</sup>4.99</b><span><del>$9.99</del><small>或使用 5 USDT · 永久回看</small></span></div>
|
||||
<ul><li><CheckCircle2 /> 四柱、十神、藏干与五行详盘</li><li><CheckCircle2 /> 性格、事业、财富、关系、身心</li><li><CheckCircle2 /> 十步大运阶段路线</li><li><CheckCircle2 /> 无订阅,不自动续费</li></ul>
|
||||
<button onClick={onStart}>先免费排盘,再决定 <ArrowRight /></button>
|
||||
<small>传统文化研究与娱乐参考,请理性看待</small>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Faq() {
|
||||
const items = [
|
||||
['不知道准确出生时间怎么办?', '可以先选择大致时辰查看,但时柱会影响结果。若完全不确定,建议向家人核实出生证明后再购买完整报告。'],
|
||||
['购买后会自动续费吗?', '不会。完整报告为单次购买,不包含订阅或自动续费。'],
|
||||
['八字报告能预测具体事件吗?', '不能。报告用于传统文化研究与自我观察,不对具体事件作确定性承诺,也不能代替现实中的专业建议。'],
|
||||
['我的出生信息会被公开吗?', '不会。当前版本在浏览器本地完成排盘;正式支付接入时也应只保存订单与报告权限所需的最少信息。'],
|
||||
];
|
||||
return <section className="faq-section" id="faq"><div className="section-heading"><span>QUESTIONS</span><h2>你可能还想知道</h2></div><div className="faq-list">{items.map(([question, answer]) => <details key={question}><summary>{question}<span>+</span></summary><p>{answer}</p></details>)}</div></section>;
|
||||
}
|
||||
|
||||
function Checkout({ reportId, snapshot, onClose, onPaid }: { reportId: string; snapshot: ReportState; onClose: () => void; onPaid: (paidReport?: ReportState) => void }) {
|
||||
const [channel, setChannel] = useState<PaymentChannel>('stripe');
|
||||
const [token, setToken] = useState(() => localStorage.getItem(authTokenKey) ?? '');
|
||||
const [email, setEmail] = useState(() => localStorage.getItem(authEmailKey) ?? '');
|
||||
const [code, setCode] = useState('');
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [isPaying, setIsPaying] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const sendCode = async () => {
|
||||
setIsPaying(true);
|
||||
setError('');
|
||||
try {
|
||||
await requestEmailCode(email);
|
||||
setCodeSent(true);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '验证码发送失败');
|
||||
} finally {
|
||||
setIsPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async () => {
|
||||
setIsPaying(true);
|
||||
setError('');
|
||||
try {
|
||||
setToken(await verifyEmailCode(email, code));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '验证码验证失败');
|
||||
} finally {
|
||||
setIsPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pay = async () => {
|
||||
setIsPaying(true);
|
||||
setError('');
|
||||
try {
|
||||
const checkout = await createCheckout({ reportId, channel, token, snapshot });
|
||||
if (checkout.demo) {
|
||||
const accessCode = await waitForPaidOrder(checkout.orderNo, token, 1);
|
||||
if (!accessCode) throw new Error('模拟支付完成,但报告权益尚未生成');
|
||||
const recovered = await recoverReport<ReportState>(accessCode, token);
|
||||
onPaid(recovered.snapshot);
|
||||
return;
|
||||
}
|
||||
if (checkout.checkoutUrl) {
|
||||
sessionStorage.setItem('lunar-pending-order', checkout.orderNo);
|
||||
sessionStorage.setItem('lunar-pending-report', reportId);
|
||||
window.location.assign(checkout.checkoutUrl);
|
||||
return;
|
||||
}
|
||||
setError(`订单 ${checkout.orderNo} 已创建,请完成支付。`);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '暂时无法发起支付');
|
||||
} finally {
|
||||
setIsPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const needsLogin = !isDemoPayment && !token;
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation" onMouseDown={event => event.currentTarget === event.target && onClose()}>
|
||||
<div className="checkout-modal" role="dialog" aria-modal="true" aria-labelledby="checkout-title">
|
||||
<button className="modal-close" onClick={onClose} aria-label="关闭"><X /></button>
|
||||
<div className="seal checkout-seal">知命</div><span className="modal-kicker">解锁完整报告</span><h2 id="checkout-title">一次支付,永久回看</h2>
|
||||
{needsLogin ? (
|
||||
<div className="email-login">
|
||||
<p>先验证邮箱,用于保存订单并跨设备找回报告。</p>
|
||||
<input type="email" value={email} onChange={event => setEmail(event.target.value)} placeholder="你的邮箱" autoComplete="email" />
|
||||
{codeSent && <input value={code} onChange={event => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="6 位验证码" inputMode="numeric" autoComplete="one-time-code" />}
|
||||
<button className="pay-button" onClick={codeSent ? login : sendCode} disabled={isPaying || !email || (codeSent && code.length !== 6)}>{isPaying ? '请稍候...' : codeSent ? '验证并继续支付' : '发送邮箱验证码'}</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="checkout-price"><span>应付金额</span><b>{channel === 'stripe' ? <><sup>$</sup>4.99</> : <>5 <sup>USDT</sup></>}</b></div>
|
||||
<div className="payment-options">
|
||||
<button className={channel === 'stripe' ? 'selected stripe' : ''} onClick={() => setChannel('stripe')}><span>银行卡</span>Stripe<CheckCircle2 /></button>
|
||||
<button className={channel === 'binance_pay' ? 'selected binance' : ''} onClick={() => setChannel('binance_pay')}><span>USDT</span>Binance Pay<CheckCircle2 /></button>
|
||||
</div>
|
||||
{isDemoPayment && <p className="demo-notice">当前为产品演示模式,点击后模拟支付成功,不会产生真实扣款。</p>}
|
||||
<button className="pay-button" onClick={pay} disabled={isPaying}>{isPaying ? '正在创建安全订单...' : isDemoPayment ? '模拟支付并解锁' : channel === 'stripe' ? '前往 Stripe 支付 $4.99' : '前往 Binance Pay 支付 5 USDT'}</button>
|
||||
</>
|
||||
)}
|
||||
{error && <p className="payment-error">{error}</p>}
|
||||
<p className="checkout-note"><ShieldCheck /> 服务商签名回调确认到账 · 无自动续费</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecoveryModal({ onClose, onRecovered }: { onClose: () => void; onRecovered: (report: ReportState) => void }) {
|
||||
const [token, setToken] = useState(() => localStorage.getItem(authTokenKey) ?? '');
|
||||
const [email, setEmail] = useState(() => localStorage.getItem(authEmailKey) ?? '');
|
||||
const [code, setCode] = useState('');
|
||||
const [accessCode, setAccessCode] = useState('');
|
||||
const [purchasedReports, setPurchasedReports] = useState<Array<{ reportId: string; accessCode: string; productName: string; paidAt: string }>>([]);
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const sendCode = async () => {
|
||||
setBusy(true); setError('');
|
||||
try { await requestEmailCode(email); setCodeSent(true); }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : '验证码发送失败'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
listPurchasedReports(token).then(setPurchasedReports).catch(() => setPurchasedReports([]));
|
||||
}, [token]);
|
||||
|
||||
const login = async () => {
|
||||
setBusy(true); setError('');
|
||||
try { setToken(await verifyEmailCode(email, code)); }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : '验证码验证失败'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const recover = async () => {
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
const recovered = await recoverReport<ReportState>(accessCode, token);
|
||||
localStorage.setItem(`lunar-report:${recovered.reportId}`, 'unlocked');
|
||||
onRecovered(recovered.snapshot);
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : '没有找到这份报告'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation" onMouseDown={event => event.currentTarget === event.target && onClose()}>
|
||||
<div className="checkout-modal" role="dialog" aria-modal="true" aria-labelledby="recovery-title">
|
||||
<button className="modal-close" onClick={onClose} aria-label="关闭"><X /></button>
|
||||
<div className="seal checkout-seal">再阅</div><span className="modal-kicker">已购报告恢复</span><h2 id="recovery-title">找回当时的完整报告</h2>
|
||||
{!token ? (
|
||||
<div className="email-login">
|
||||
<p>先验证购买时使用的邮箱,再输入报告恢复码。</p>
|
||||
<input type="email" value={email} onChange={event => setEmail(event.target.value)} placeholder="购买邮箱" autoComplete="email" />
|
||||
{codeSent && <input value={code} onChange={event => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="6 位验证码" inputMode="numeric" />}
|
||||
<button className="pay-button" onClick={codeSent ? login : sendCode} disabled={busy || !email || (codeSent && code.length !== 6)}>{busy ? '请稍候...' : codeSent ? '验证邮箱' : '发送邮箱验证码'}</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="email-login">
|
||||
{purchasedReports.length > 0 && <div className="purchased-report-list"><p>你的历史报告</p>{purchasedReports.map(item => <button type="button" className="history-report" key={item.accessCode} onClick={() => setAccessCode(item.accessCode)}><b>{item.reportId}</b><span>{new Date(item.paidAt).toLocaleDateString('zh-CN')} · {item.productName}</span></button>)}</div>}
|
||||
<p>恢复码格式示例:ZM-ABCD-EFGH-JKLM</p>
|
||||
<input value={accessCode} onChange={event => setAccessCode(event.target.value.toUpperCase().replace(/[^A-Z2-7-]/g, '').slice(0, 20))} placeholder="输入报告恢复码" autoComplete="off" />
|
||||
<button className="pay-button" onClick={recover} disabled={busy || accessCode.length < 17}>{busy ? '正在读取报告...' : '打开已购报告'}</button>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="payment-error">{error}</p>}
|
||||
<p className="checkout-note"><ShieldCheck /> 恢复码与购买邮箱必须同时匹配</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobilePayBar({ onClick }: { onClick: () => void }) {
|
||||
return <div className="mobile-pay-bar"><div><span>完整报告</span><b>$4.99</b><del>$9.99</del></div><button onClick={onClick}>立即解锁</button></div>;
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,106 @@
|
||||
export type PaymentChannel = 'stripe' | 'binance_pay';
|
||||
|
||||
interface CheckoutRequest {
|
||||
reportId: string;
|
||||
channel: PaymentChannel;
|
||||
token: string;
|
||||
snapshot: unknown;
|
||||
}
|
||||
|
||||
interface CheckoutResponse {
|
||||
orderNo: string;
|
||||
checkoutUrl?: string;
|
||||
demo: boolean;
|
||||
}
|
||||
|
||||
interface ApiPayload<T> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||
export const isDemoPayment = import.meta.env.VITE_PAYMENT_MODE === 'demo';
|
||||
export const authTokenKey = 'lunar-commerce-token';
|
||||
export const authEmailKey = 'lunar-commerce-email';
|
||||
|
||||
async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${apiBaseURL}${path}`, {
|
||||
...options,
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
});
|
||||
const payload = await response.json().catch(() => null) as ApiPayload<T> | null;
|
||||
if (!response.ok || !payload || payload.code !== 0) throw new Error(payload?.msg ?? '服务暂时不可用');
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
export async function requestEmailCode(email: string): Promise<void> {
|
||||
await apiRequest<never>('/api/auth/email/request', { method: 'POST', body: JSON.stringify({ email }) });
|
||||
}
|
||||
|
||||
export async function saveChartRecord(input: {
|
||||
reportId: string;
|
||||
name: string;
|
||||
birthDate: string;
|
||||
birthTime: string;
|
||||
gender: string;
|
||||
city: string;
|
||||
snapshot: unknown;
|
||||
}): Promise<void> {
|
||||
await apiRequest<never>('/api/commerce/charts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyEmailCode(email: string, code: string): Promise<string> {
|
||||
const result = await apiRequest<{ token: string }>('/api/auth/email/verify', { method: 'POST', body: JSON.stringify({ email, code }) });
|
||||
localStorage.setItem(authTokenKey, result.token);
|
||||
localStorage.setItem(authEmailKey, email.toLowerCase());
|
||||
return result.token;
|
||||
}
|
||||
|
||||
export async function createCheckout(request: CheckoutRequest): Promise<CheckoutResponse> {
|
||||
if (isDemoPayment) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 700));
|
||||
return { orderNo: `DEMO-${Date.now()}`, demo: true };
|
||||
}
|
||||
const result = await apiRequest<{ orderNo: string; checkoutUrl: string; demo?: boolean }>('/api/commerce/checkout', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${request.token}` },
|
||||
body: JSON.stringify({ provider: request.channel, reportId: request.reportId, snapshot: request.snapshot }),
|
||||
});
|
||||
return { orderNo: result.orderNo, checkoutUrl: result.checkoutUrl, demo: result.demo ?? false };
|
||||
}
|
||||
|
||||
export async function checkReportAccess(reportId: string, token: string): Promise<boolean> {
|
||||
const result = await apiRequest<{ unlocked: boolean }>(`/api/commerce/reports/${encodeURIComponent(reportId)}/access`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return result.unlocked;
|
||||
}
|
||||
|
||||
export async function waitForPaidOrder(orderNo: string, token: string, attempts = 12): Promise<string | null> {
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
const order = await apiRequest<{ status: string; accessCode?: string }>(`/api/commerce/orders/${encodeURIComponent(orderNo)}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (order.status === 'paid') return order.accessCode ?? null;
|
||||
if (order.status === 'cancelled' || order.status === 'refunded') return null;
|
||||
await new Promise(resolve => window.setTimeout(resolve, 2000));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function listPurchasedReports(token: string): Promise<Array<{ reportId: string; accessCode: string; productName: string; paidAt: string }>> {
|
||||
const result = await apiRequest<{ list: Array<{ reportId: string; accessCode: string; productName: string; paidAt: string }> }>('/api/commerce/reports', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return result.list;
|
||||
}
|
||||
|
||||
export async function recoverReport<T>(accessCode: string, token: string): Promise<{ reportId: string; snapshot: T }> {
|
||||
return apiRequest<{ reportId: string; snapshot: T }>(`/api/commerce/reports/recover/${encodeURIComponent(accessCode.toUpperCase())}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { BaziFullResult, ElementProfile, ShenshaInfo } from '@lunar/core';
|
||||
|
||||
export interface PremiumSection {
|
||||
index: string;
|
||||
title: string;
|
||||
kicker: string;
|
||||
summary: string;
|
||||
details: string[];
|
||||
}
|
||||
|
||||
const ELEMENT_TRAITS: Record<string, { gift: string; caution: string; field: string; rhythm: string }> = {
|
||||
木: { gift: '成长力、规划感与同理心', caution: '容易把长期目标变成眼前压力', field: '教育、内容、设计与长期经营', rhythm: '先建立根基,再持续放大优势' },
|
||||
火: { gift: '表达力、行动力与感染力', caution: '节奏过快时容易透支专注', field: '传播、品牌、销售与创新项目', rhythm: '抓住窗口,但为决策保留冷静期' },
|
||||
土: { gift: '稳定性、承诺感与资源整合力', caution: '求稳过度时会错过变化', field: '管理、运营、资产与服务业', rhythm: '稳中求变,用小步试错替代停滞' },
|
||||
金: { gift: '判断力、边界感与执行标准', caution: '标准过高时容易显得紧绷', field: '技术、金融、法律与精细管理', rhythm: '先定规则,再给关系留出弹性' },
|
||||
水: { gift: '洞察力、适应力与信息敏感度', caution: '选择过多时容易分散心力', field: '研究、咨询、贸易与流动型工作', rhythm: '聚焦一个主航道,让变化服务目标' },
|
||||
};
|
||||
|
||||
const SUPPORTING_ELEMENT: Record<string, string> = { 木: '水', 火: '木', 土: '火', 金: '土', 水: '金' };
|
||||
|
||||
export function createReportId(seed: string): string {
|
||||
let hash = 2166136261;
|
||||
for (const char of seed) {
|
||||
hash ^= char.charCodeAt(0);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return `ZM-${Math.abs(hash).toString(36).toUpperCase().padStart(7, '0').slice(0, 7)}`;
|
||||
}
|
||||
|
||||
export function createPremiumSections(result: BaziFullResult, elements: ElementProfile, shensha: ShenshaInfo[]): PremiumSection[] {
|
||||
const chart = result.eightChar;
|
||||
const dominant = ELEMENT_TRAITS[elements.dominant] ?? ELEMENT_TRAITS.土;
|
||||
const dayMaster = ELEMENT_TRAITS[chart.dayMasterElement] ?? dominant;
|
||||
const helpful = SUPPORTING_ELEMENT[chart.dayMasterElement] ?? elements.weakest;
|
||||
const positiveStars = shensha.filter(item => item.type === '吉').slice(0, 3).map(item => item.name);
|
||||
const cautionStars = shensha.filter(item => item.type === '凶').slice(0, 2).map(item => item.name);
|
||||
const firstFortune = result.decadeFortunes[0];
|
||||
const starText = positiveStars.length ? positiveStars.join('、') : '命局本身的稳定结构';
|
||||
const cautionText = cautionStars.length ? cautionStars.join('、') : '节奏失衡';
|
||||
|
||||
return [
|
||||
{
|
||||
index: '壹',
|
||||
title: '性格底色',
|
||||
kicker: `${chart.dayMaster} · 内在驱动力`,
|
||||
summary: `你的日主为${chart.dayMaster},核心优势集中在${dayMaster.gift}。`,
|
||||
details: [`五行以${elements.dominant}较突出,做事更重视${dominant.gift}。`, `需要留意:${dayMaster.caution}。`, `真正适合你的成长方式是:${dayMaster.rhythm}。`],
|
||||
},
|
||||
{
|
||||
index: '贰',
|
||||
title: '事业天赋',
|
||||
kicker: '能力结构 · 职场定位',
|
||||
summary: `你的优势更容易在${dominant.field}中形成复利。`,
|
||||
details: [`适合承担需要${dayMaster.gift}的角色。`, `命局中的${starText},提示你应主动经营专业信用与协作关系。`, `比起频繁换方向,更适合建立可重复的方法与个人标签。`],
|
||||
},
|
||||
{
|
||||
index: '叁',
|
||||
title: '财富节奏',
|
||||
kicker: '积累方式 · 风险边界',
|
||||
summary: `财富策略宜围绕“${dominant.rhythm}”展开。`,
|
||||
details: [`优先把${dayMaster.gift}转化成可计价的产品或服务。`, `遇到${cautionText}对应的冲动时,应延迟重大财务决定。`, `收入结构宜同时保留稳定现金流与小比例成长投入。`],
|
||||
},
|
||||
{
|
||||
index: '肆',
|
||||
title: '关系模式',
|
||||
kicker: '亲密关系 · 沟通课题',
|
||||
summary: `关系中的吸引力来自${dayMaster.gift},长久则依赖清晰表达。`,
|
||||
details: [`你更需要能理解你节奏、也尊重边界的伴侣。`, `情绪紧张时,先描述事实与需要,避免用结论代替沟通。`, `重要关系中要刻意安排稳定相处,而不是只依赖临时热情。`],
|
||||
},
|
||||
{
|
||||
index: '伍',
|
||||
title: '身心能量',
|
||||
kicker: '五行平衡 · 日常建议',
|
||||
summary: `${elements.weakest}相对偏弱,日常可借${helpful}的意象建立平衡感。`,
|
||||
details: [`把作息、运动和独处时间当作长期资产。`, `高压期减少多线程任务,让注意力回到一个明确目标。`, `所有健康问题应以正规医疗意见为准,本报告不替代诊断。`],
|
||||
},
|
||||
{
|
||||
index: '陆',
|
||||
title: '大运路线',
|
||||
kicker: '阶段重点 · 行动窗口',
|
||||
summary: firstFortune ? `首步大运为${firstFortune.ganzhi},约从${firstFortune.startAge}岁进入阶段转换。` : '人生节奏呈阶段性变化。',
|
||||
details: [`大运用于观察阶段主题,不代表确定事件。`, `顺势的关键是持续放大${dayMaster.gift},并管理${dayMaster.caution}。`, `完整报告保留十步大运时间轴,方便长期回看。`],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=Ma+Shan+Zheng&family=Noto+Serif+SC:wght@400;500;600;700;900&display=swap');
|
||||
|
||||
:root {
|
||||
color: #1a1c18;
|
||||
background: #f1ecdf;
|
||||
font-family: 'DM Sans', 'PingFang SC', sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--ink: #171915;
|
||||
--ink-soft: #5d6058;
|
||||
--paper: #f5f0e5;
|
||||
--paper-deep: #e9e0ce;
|
||||
--vermilion: #b5372c;
|
||||
--vermilion-dark: #84251f;
|
||||
--gold: #b48a47;
|
||||
--line: rgba(23, 25, 21, 0.14);
|
||||
--serif: 'Noto Serif SC', 'Songti SC', serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--paper); }
|
||||
button, input, select { font: inherit; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
button { cursor: pointer; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.site-shell { min-height: 100vh; overflow: hidden; }
|
||||
.payment-notice { position: fixed; z-index: 80; left: 50%; top: 18px; transform: translateX(-50%); width: min(560px, calc(100% - 28px)); padding: 13px 16px; display: flex; align-items: center; gap: 9px; background: #f5f0e5; color: var(--ink); border: 1px solid var(--gold); box-shadow: 0 16px 45px rgba(0,0,0,.22); font-size: 12px; }
|
||||
.payment-notice > svg { color: var(--vermilion); flex: 0 0 auto; }
|
||||
.payment-notice button { margin-left: auto; padding: 2px; border: 0; background: transparent; color: var(--ink-soft); }
|
||||
.payment-notice button svg { width: 16px; }
|
||||
|
||||
header {
|
||||
height: 82px;
|
||||
padding: 0 clamp(24px, 5vw, 76px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
width: 100%;
|
||||
color: #f7f1e4;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 12px; }
|
||||
.brand > span:last-child { display: flex; flex-direction: column; gap: 2px; }
|
||||
.brand b { font-family: var(--serif); letter-spacing: .22em; font-size: 18px; }
|
||||
.brand small { color: rgba(255,255,255,.55); font-size: 9px; letter-spacing: .3em; }
|
||||
.seal {
|
||||
width: 41px;
|
||||
height: 41px;
|
||||
border: 1px solid currentColor;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #f5eee0;
|
||||
font-family: var(--serif);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
line-height: 1.15;
|
||||
writing-mode: vertical-rl;
|
||||
letter-spacing: .15em;
|
||||
position: relative;
|
||||
}
|
||||
.seal::after { content: ''; position: absolute; inset: 3px; border: 1px solid currentColor; opacity: .45; }
|
||||
header nav { display: flex; gap: 38px; margin-left: auto; margin-right: 45px; }
|
||||
header nav a { font-size: 13px; color: rgba(255,255,255,.72); transition: color .25s ease; }
|
||||
header nav a:hover { color: #fff; }
|
||||
.recovery-link { margin-right: 10px; border: 0; background: transparent; color: rgba(255,255,255,.7); font-size: 11px; white-space: nowrap; }
|
||||
.recovery-link:hover { color: white; }
|
||||
.header-cta { border: 1px solid rgba(255,255,255,.38); padding: 10px 18px; font-size: 12px; letter-spacing: .12em; transition: .25s ease; }
|
||||
.header-cta:hover { background: #f6f0e4; color: var(--ink); }
|
||||
.menu-button { display: none; border: 0; background: transparent; color: inherit; }
|
||||
|
||||
.hero {
|
||||
min-height: 790px;
|
||||
background: var(--ink);
|
||||
color: #f7f1e4;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.08fr) minmax(420px, .92fr);
|
||||
align-items: center;
|
||||
gap: clamp(48px, 7vw, 120px);
|
||||
padding: 132px clamp(24px, 8vw, 128px) 90px;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
background-image: linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px);
|
||||
background-size: 38px 38px;
|
||||
}
|
||||
.hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 58vw;
|
||||
height: 58vw;
|
||||
max-width: 850px;
|
||||
max-height: 850px;
|
||||
border: 1px solid rgba(180, 138, 71, .13);
|
||||
border-radius: 50%;
|
||||
left: -17vw;
|
||||
top: 80px;
|
||||
z-index: -1;
|
||||
box-shadow: 0 0 0 90px rgba(180,138,71,.026), 0 0 0 180px rgba(180,138,71,.018);
|
||||
}
|
||||
.hero-orbit {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(110px, 16vw, 260px);
|
||||
font-weight: 900;
|
||||
color: rgba(255,255,255,.018);
|
||||
user-select: none;
|
||||
}
|
||||
.orbit-one { left: 5vw; bottom: 5%; }
|
||||
.orbit-two { left: 35vw; top: 7%; }
|
||||
.hero-copy { max-width: 650px; }
|
||||
.eyebrow { display: flex; align-items: center; gap: 13px; color: #c8aa76; font-family: var(--serif); font-size: 12px; letter-spacing: .28em; }
|
||||
.eyebrow span { width: 30px; height: 1px; background: currentColor; }
|
||||
.hero h1 { margin: 27px 0 30px; font-family: var(--serif); font-weight: 600; font-size: clamp(54px, 6.6vw, 98px); line-height: 1.13; letter-spacing: -.055em; }
|
||||
.hero h1 em { color: #c8aa76; font-style: normal; position: relative; }
|
||||
.hero h1 em::after { content: ''; position: absolute; left: 0; right: 0; bottom: -5px; height: 8px; border-top: 1px solid var(--vermilion); border-radius: 50%; transform: rotate(-2deg); }
|
||||
.hero-lead { max-width: 590px; font-family: var(--serif); color: rgba(247,241,228,.65); font-size: 16px; line-height: 2; letter-spacing: .04em; }
|
||||
.trust-row { display: flex; flex-wrap: wrap; gap: 18px 26px; margin-top: 38px; padding-top: 28px; border-top: 1px solid rgba(255,255,255,.1); }
|
||||
.trust-row span { display: flex; align-items: center; gap: 7px; color: rgba(255,255,255,.58); font-size: 11px; }
|
||||
.trust-row svg { color: #c8aa76; }
|
||||
|
||||
.birth-card {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
padding: 34px;
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
justify-self: end;
|
||||
box-shadow: 22px 26px 0 rgba(0,0,0,.17), 0 35px 80px rgba(0,0,0,.3);
|
||||
position: relative;
|
||||
}
|
||||
.birth-card::before { content: ''; position: absolute; inset: 8px; border: 1px solid rgba(181,55,44,.18); pointer-events: none; }
|
||||
.card-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 30px; position: relative; }
|
||||
.card-heading > div { display: flex; align-items: center; gap: 11px; }
|
||||
.card-heading p { margin: 0; font-family: var(--serif); font-size: 18px; font-weight: 600; }
|
||||
.step-number { color: var(--vermilion); font-size: 10px; letter-spacing: .12em; }
|
||||
.free-chip { color: var(--vermilion); border: 1px solid rgba(181,55,44,.3); padding: 5px 8px; font-size: 10px; }
|
||||
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.field { display: block; margin: 0 0 14px; position: relative; min-width: 0; }
|
||||
.field > span, .field legend { display: block; color: #77786f; font-size: 11px; margin: 0 0 7px; letter-spacing: .05em; }
|
||||
.field input, .field select {
|
||||
width: 100%;
|
||||
height: 47px;
|
||||
border: 1px solid #d7cebd;
|
||||
border-radius: 0;
|
||||
color: var(--ink);
|
||||
background: rgba(255,255,255,.4);
|
||||
padding: 0 13px;
|
||||
outline: 0;
|
||||
transition: border-color .2s ease, background .2s ease;
|
||||
font-size: 13px;
|
||||
}
|
||||
.field input:focus, .field select:focus { border-color: var(--vermilion); background: #fff; }
|
||||
.field input::placeholder { color: #aaa79d; }
|
||||
.segmented-field { padding: 0; border: 0; }
|
||||
.segment-control { height: 47px; display: grid; grid-template-columns: 1fr 1fr; border: 1px solid #d7cebd; padding: 3px; }
|
||||
.segment-control button { border: 0; background: transparent; color: #74766e; font-family: var(--serif); }
|
||||
.segment-control button.active { background: var(--ink); color: #f8f2e6; }
|
||||
.select-field > svg:first-of-type { position: absolute; left: 12px; bottom: 15px; color: var(--vermilion); pointer-events: none; }
|
||||
.select-field > svg:last-of-type { position: absolute; right: 11px; bottom: 15px; color: #77786f; pointer-events: none; }
|
||||
.select-field select { appearance: none; padding-left: 35px; }
|
||||
.primary-action, .offer-card button, .unlock-panel button, .pay-button {
|
||||
border: 0;
|
||||
background: var(--vermilion);
|
||||
color: white;
|
||||
min-height: 53px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
font-family: var(--serif);
|
||||
font-weight: 600;
|
||||
letter-spacing: .08em;
|
||||
transition: background .2s ease, transform .2s ease;
|
||||
}
|
||||
.primary-action:hover, .offer-card button:hover, .unlock-panel button:hover, .pay-button:hover { background: var(--vermilion-dark); transform: translateY(-1px); }
|
||||
.primary-action:disabled, .pay-button:disabled { opacity: .7; cursor: wait; }
|
||||
.form-note { display: flex; align-items: center; justify-content: center; gap: 6px; margin: 14px 0 0; font-size: 10px; color: #929086; }
|
||||
.loading-mark { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3); border-top-color: white; border-radius: 50%; animation: spin .7s linear infinite; }
|
||||
.scroll-cue { position: absolute; bottom: 27px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 8px; color: rgba(255,255,255,.42); font-size: 9px; letter-spacing: .2em; writing-mode: vertical-rl; }
|
||||
.scroll-cue svg { animation: bob 1.8s ease-in-out infinite; }
|
||||
|
||||
.value-strip { display: grid; grid-template-columns: repeat(4, 1fr); padding: 0 clamp(24px, 8vw, 128px); background: #ded4c1; border-bottom: 1px solid var(--line); }
|
||||
.value-strip div { min-height: 108px; display: flex; align-items: center; justify-content: center; gap: 13px; border-right: 1px solid var(--line); }
|
||||
.value-strip div:first-child { border-left: 1px solid var(--line); }
|
||||
.value-strip b { font-family: var(--serif); font-size: 28px; font-weight: 500; color: var(--vermilion); }
|
||||
.value-strip span { font-size: 11px; color: #666960; }
|
||||
|
||||
.report-section { padding-top: 112px; scroll-margin-top: 20px; }
|
||||
.section-heading > span { color: var(--vermilion); font-size: 10px; letter-spacing: .3em; font-weight: 600; }
|
||||
.section-heading h2 { margin: 13px 0 12px; font-family: var(--serif); font-size: clamp(34px, 4.3vw, 58px); line-height: 1.28; font-weight: 600; letter-spacing: -.035em; }
|
||||
.section-heading p { margin: 0; color: var(--ink-soft); font-size: 13px; line-height: 1.8; }
|
||||
.section-heading.centered { text-align: center; }
|
||||
.chart-paper {
|
||||
width: min(920px, calc(100% - 40px));
|
||||
margin: 52px auto 28px;
|
||||
background-color: #efe7d7;
|
||||
background-image: radial-gradient(rgba(79,55,32,.12) .6px, transparent .6px);
|
||||
background-size: 5px 5px;
|
||||
border: 1px solid #cfc1aa;
|
||||
box-shadow: 0 18px 50px rgba(59,43,24,.11);
|
||||
padding: 32px 45px;
|
||||
position: relative;
|
||||
}
|
||||
.chart-paper::before, .chart-paper::after { content: ''; position: absolute; width: 18px; height: 18px; border-color: var(--vermilion); opacity: .55; }
|
||||
.chart-paper::before { top: 10px; left: 10px; border-top: 1px solid; border-left: 1px solid; }
|
||||
.chart-paper::after { right: 10px; bottom: 10px; border-right: 1px solid; border-bottom: 1px solid; }
|
||||
.paper-topline { display: flex; justify-content: space-between; align-items: center; padding-bottom: 20px; border-bottom: 1px solid rgba(45,37,26,.18); font-family: var(--serif); font-size: 11px; letter-spacing: .12em; color: #797267; }
|
||||
.paper-topline strong { color: var(--vermilion); font-size: 14px; }
|
||||
.pillars-grid { display: grid; grid-template-columns: repeat(4, 1fr); max-width: 650px; margin: 28px auto; }
|
||||
.pillar { text-align: center; border-right: 1px solid rgba(45,37,26,.13); padding: 3px 18px 15px; }
|
||||
.pillar:last-child { border: 0; }
|
||||
.pillar > span { display: block; font-family: var(--serif); color: #6c695f; font-size: 12px; letter-spacing: .15em; }
|
||||
.pillar small { display: block; margin: 10px 0 3px; color: #9a9387; font-size: 10px; }
|
||||
.pillar b { display: block; font-family: var(--serif); font-size: clamp(38px, 4vw, 52px); font-weight: 600; line-height: 1.15; }
|
||||
.pillar i { display: block; margin-top: 10px; font-family: var(--serif); font-size: 10px; font-style: normal; color: #82796c; }
|
||||
.day-pillar { background: rgba(181,55,44,.045); box-shadow: inset 0 -2px var(--vermilion); }
|
||||
.element-木 { color: #3d6f4c; }
|
||||
.element-火 { color: #b5372c; }
|
||||
.element-土 { color: #8b693a; }
|
||||
.element-金 { color: #77736c; }
|
||||
.element-水 { color: #365f71; }
|
||||
.chart-meta { display: grid; grid-template-columns: repeat(4, 1fr); padding-top: 18px; border-top: 1px solid rgba(45,37,26,.18); text-align: center; font-size: 10px; color: #888075; }
|
||||
.chart-meta b { display: block; margin-top: 5px; font-family: var(--serif); color: #383a35; font-size: 13px; }
|
||||
|
||||
.preview-grid { width: min(920px, calc(100% - 40px)); margin: 0 auto 100px; display: grid; grid-template-columns: .9fr 1.1fr; gap: 18px; }
|
||||
.elements-card, .insight-card { padding: 28px 30px; border: 1px solid var(--line); }
|
||||
.elements-card { background: rgba(255,255,255,.35); }
|
||||
.insight-card { background: var(--vermilion-dark); color: white; }
|
||||
.mini-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 26px; }
|
||||
.mini-heading span { color: var(--vermilion); font-size: 9px; }
|
||||
.mini-heading h3 { margin: 0; font-family: var(--serif); font-weight: 600; font-size: 18px; }
|
||||
.mini-heading i { margin-left: auto; font-style: normal; color: #7c7d75; font-size: 9px; border: 1px solid var(--line); padding: 4px 7px; }
|
||||
.mini-heading.light span { color: #e2c790; }
|
||||
.mini-heading.light i { color: rgba(255,255,255,.65); border-color: rgba(255,255,255,.22); }
|
||||
.element-list { display: grid; gap: 12px; }
|
||||
.element-row { display: grid; grid-template-columns: 27px 1fr 28px; gap: 10px; align-items: center; }
|
||||
.element-dot { width: 25px; height: 25px; display: grid; place-items: center; color: white; font-family: var(--serif); font-size: 11px; border-radius: 50%; }
|
||||
.element-dot.wood { background: #477854; }.element-dot.fire { background: #b94a3d; }.element-dot.earth { background: #967247; }.element-dot.metal { background: #7d7e7a; }.element-dot.water { background: #426b7d; }
|
||||
.element-row > div { height: 5px; background: #ddd5c6; }
|
||||
.element-row > div i { display: block; height: 100%; background: var(--ink); }
|
||||
.element-row b { font-size: 10px; text-align: right; color: #77786f; }
|
||||
.elements-card > p { margin: 22px 0 0; padding-top: 15px; border-top: 1px solid var(--line); color: #7c7d75; font-size: 10px; line-height: 1.7; }
|
||||
.insight-list { display: grid; gap: 16px; }
|
||||
.insight-list > div { display: grid; grid-template-columns: 28px 1fr; gap: 12px; align-items: start; }
|
||||
.insight-list b { color: #dec48f; font-size: 9px; padding-top: 5px; }
|
||||
.insight-list p { margin: 0; font-family: var(--serif); color: rgba(255,255,255,.78); font-size: 12px; line-height: 1.8; }
|
||||
|
||||
.technical-chart { padding: 10px clamp(20px, 8vw, 128px) 110px; background: var(--paper); }
|
||||
.luck-header { max-width: 1120px; margin: 46px auto 14px; display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--line); background: rgba(255,255,255,.32); }
|
||||
.luck-header > div { min-height: 86px; display: flex; flex-direction: column; align-items: center; justify-content: center; border-right: 1px solid var(--line); }
|
||||
.luck-header > div:last-child { border: 0; }
|
||||
.luck-header small { color: var(--ink-soft); font-size: 9px; letter-spacing: .08em; }
|
||||
.luck-header b { margin-top: 6px; font-family: var(--serif); font-size: 15px; }
|
||||
.luck-header i { margin-top: 4px; color: #929086; font-size: 8px; font-style: normal; }
|
||||
.luck-panel, .relation-panel, .shensha-panel { max-width: 1120px; margin: 14px auto 0; padding: 27px; border: 1px solid var(--line); background: #ece4d5; }
|
||||
.technical-title { display: flex; align-items: center; justify-content: space-between; margin-bottom: 23px; }
|
||||
.technical-title > div { display: flex; align-items: center; gap: 10px; }
|
||||
.technical-title > div > span { color: var(--vermilion); font-size: 9px; }
|
||||
.technical-title h3 { margin: 0; font-family: var(--serif); font-size: 18px; }
|
||||
.technical-title > small { color: var(--ink-soft); font-size: 9px; }
|
||||
.decade-track { display: flex; gap: 9px; overflow-x: auto; padding: 2px 1px 9px; scrollbar-color: rgba(181,55,44,.45) transparent; }
|
||||
.decade-card { flex: 0 0 132px; min-height: 155px; padding: 17px 13px; display: flex; flex-direction: column; align-items: center; justify-content: center; border: 1px solid var(--line); background: rgba(255,255,255,.45); position: relative; cursor: pointer; font-family: inherit; color: inherit; }
|
||||
.decade-card > small { color: var(--ink-soft); font-size: 9px; }
|
||||
.decade-card > b { margin: 9px 0 7px; font-family: var(--serif); color: var(--vermilion-dark); font-size: 25px; letter-spacing: .1em; }
|
||||
.decade-card > span { font-size: 9px; color: #565950; }
|
||||
.decade-card > i { margin-top: 8px; color: #929086; font-size: 8px; font-style: normal; }
|
||||
.decade-card.current em { position: absolute; right: 6px; top: 6px; padding: 2px 5px; background: var(--vermilion); color: white; font-size: 8px; font-style: normal; }
|
||||
.decade-card.selected { border-color: var(--vermilion); box-shadow: inset 0 -3px var(--vermilion); }
|
||||
.annual-track { display: grid; grid-template-columns: repeat(10, minmax(70px, 1fr)); overflow-x: auto; }
|
||||
.annual-track > div { min-width: 70px; padding: 15px 5px; display: flex; flex-direction: column; align-items: center; border-right: 1px solid var(--line); background: rgba(255,255,255,.28); }
|
||||
.annual-track > div:last-child { border: 0; }
|
||||
.annual-track small, .annual-track span { color: var(--ink-soft); font-size: 8px; }
|
||||
.annual-track b { margin: 6px 0; font-family: var(--serif); font-size: 17px; }
|
||||
.annual-track > div.active { color: white; background: var(--vermilion-dark); }
|
||||
.annual-track > div.active small, .annual-track > div.active span { color: rgba(255,255,255,.62); }
|
||||
.technical-grid { max-width: 1120px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 14px; position: relative; }
|
||||
.technical-grid > .relation-panel,
|
||||
.technical-grid > .shensha-panel { width: 100%; min-width: 0; margin: 14px 0 0; }
|
||||
.relation-list { display: grid; gap: 8px; }
|
||||
.relation-list > div { min-height: 47px; padding: 10px 12px; display: grid; grid-template-columns: 88px 1fr; align-items: center; gap: 10px; background: rgba(255,255,255,.45); border-left: 3px solid #8b693a; }
|
||||
.relation-list > div.good { border-color: #477854; }.relation-list > div.alert { border-color: #b5372c; }.relation-list > div.warn { border-color: #80658d; }
|
||||
.relation-list b { font-family: var(--serif); font-size: 11px; }
|
||||
.relation-list span { color: var(--ink-soft); font-size: 9px; line-height: 1.5; }
|
||||
.empty-technical { color: var(--ink-soft); font-size: 11px; line-height: 1.8; }
|
||||
.shensha-list { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; max-height: 330px; overflow-y: auto; padding-right: 3px; }
|
||||
.shensha-list > div { display: grid; grid-template-columns: 26px 1fr; gap: 9px; align-items: start; padding: 11px; background: rgba(255,255,255,.45); }
|
||||
.shensha-list > div > span { width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid; border-radius: 50%; font-family: var(--serif); font-size: 9px; }
|
||||
.shensha-list .star-吉 > span { color: #477854; }.shensha-list .star-凶 > span { color: #b5372c; }.shensha-list .star-中性 > span { color: #8b693a; }
|
||||
.shensha-list b { display: block; font-family: var(--serif); font-size: 11px; }
|
||||
.shensha-list small { display: block; margin-top: 4px; color: var(--ink-soft); font-size: 8px; line-height: 1.55; }
|
||||
.technical-note { max-width: 1120px; margin: 12px auto 0; color: #858278; font-size: 9px; text-align: right; }
|
||||
|
||||
.premium-report { background: var(--ink); color: #f7f1e4; padding: 100px clamp(20px, 8vw, 128px) 115px; position: relative; }
|
||||
.premium-report::before { content: '命'; position: absolute; left: -30px; top: -60px; color: rgba(255,255,255,.025); font-family: var(--serif); font-size: 340px; font-weight: 900; pointer-events: none; }
|
||||
.light-heading > span { color: #c8aa76; }
|
||||
.light-heading p { color: rgba(255,255,255,.53); }
|
||||
.chapter-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; max-width: 1120px; margin: 48px auto 0; }
|
||||
.chapter { min-height: 270px; position: relative; overflow: hidden; padding: 28px; border: 1px solid rgba(255,255,255,.12); background: #1d1f1a; }
|
||||
.chapter-title { display: flex; align-items: center; gap: 14px; position: relative; z-index: 1; }
|
||||
.chapter-title > span { width: 35px; height: 35px; border: 1px solid rgba(200,170,118,.5); display: grid; place-items: center; color: #c8aa76; font-family: var(--serif); }
|
||||
.chapter-title > div { flex: 1; }
|
||||
.chapter-title small { color: rgba(255,255,255,.38); font-size: 8px; letter-spacing: .12em; }
|
||||
.chapter-title h3 { margin: 4px 0 0; font-family: var(--serif); font-size: 17px; }
|
||||
.chapter-title svg { color: rgba(255,255,255,.25); }
|
||||
.chapter-summary { position: relative; z-index: 1; margin: 23px 0 13px; color: rgba(255,255,255,.72); font-family: var(--serif); font-size: 12px; line-height: 1.8; }
|
||||
.chapter ul { position: relative; z-index: 1; margin: 0; padding: 0; list-style: none; display: grid; gap: 9px; }
|
||||
.chapter li { display: flex; gap: 7px; align-items: flex-start; color: rgba(255,255,255,.5); font-size: 10px; line-height: 1.55; }
|
||||
.chapter li svg { flex: 0 0 auto; color: #c8aa76; margin-top: 1px; }
|
||||
.chapter.locked .chapter-summary, .chapter.locked ul { filter: blur(5px); user-select: none; }
|
||||
.chapter .veil { position: absolute; inset: 65px 0 0; background: #1d1f1a; z-index: 2; }
|
||||
.chapter.unlocked { animation: chapterReveal .5s ease both; }
|
||||
.unlock-panel { max-width: 800px; margin: 42px auto 0; padding: 30px; display: grid; grid-template-columns: auto 1fr auto; gap: 20px; align-items: center; border: 1px solid rgba(200,170,118,.38); background: #24251f; box-shadow: 0 30px 70px rgba(0,0,0,.28); }
|
||||
.unlock-icon { width: 47px; height: 47px; display: grid; place-items: center; color: #c8aa76; border: 1px solid rgba(200,170,118,.38); border-radius: 50%; }
|
||||
.unlock-panel > div:nth-child(2) > span { color: #c8aa76; font-size: 9px; letter-spacing: .18em; }
|
||||
.unlock-panel h3 { margin: 6px 0 5px; font-family: var(--serif); font-size: 18px; }
|
||||
.unlock-panel p { margin: 0; color: rgba(255,255,255,.43); font-size: 10px; }
|
||||
.price { text-align: right; min-width: 100px; }
|
||||
.price small { display: block; color: rgba(255,255,255,.4); font-size: 9px; }
|
||||
.price b { display: block; font-size: 29px; color: #ead6ad; }
|
||||
.price sup { font-size: 13px; }
|
||||
.price del { color: rgba(255,255,255,.32); font-size: 10px; }
|
||||
.unlock-panel button { grid-column: 2 / 4; }
|
||||
.unlock-panel .safe-pay { grid-column: 2 / 4; display: flex; align-items: center; justify-content: center; gap: 5px; }
|
||||
|
||||
.how-section { padding: 120px clamp(24px, 8vw, 128px); background: #e8dfcf; }
|
||||
.how-section > .section-heading { max-width: 720px; }
|
||||
.method-grid { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 60px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.method-grid article { min-height: 280px; padding: 38px 34px; border-right: 1px solid var(--line); position: relative; }
|
||||
.method-grid article:first-child { border-left: 1px solid var(--line); }
|
||||
.method-grid article > span { position: absolute; right: 22px; top: 20px; font-family: var(--serif); color: rgba(181,55,44,.25); font-size: 38px; }
|
||||
.method-grid svg { color: var(--vermilion); width: 25px; height: 25px; }
|
||||
.method-grid h3 { margin: 48px 0 13px; font-family: var(--serif); font-size: 20px; }
|
||||
.method-grid p { margin: 0; color: var(--ink-soft); font-size: 12px; line-height: 1.9; }
|
||||
|
||||
.offer-section { display: grid; grid-template-columns: 1.2fr .8fr; background: var(--vermilion-dark); color: #fff; padding: 100px clamp(24px, 10vw, 150px); gap: clamp(60px, 10vw, 150px); align-items: center; position: relative; }
|
||||
.offer-copy { display: grid; grid-template-columns: auto 1fr; align-items: start; gap: 30px; }
|
||||
.vertical-mark { writing-mode: vertical-rl; border: 1px solid rgba(255,255,255,.25); padding: 13px 10px; color: rgba(255,255,255,.62); font-family: var(--serif); letter-spacing: .32em; font-size: 11px; }
|
||||
.offer-card { background: var(--paper); color: var(--ink); padding: 35px; box-shadow: 18px 18px 0 rgba(36,9,7,.18); }
|
||||
.offer-tag { color: var(--vermilion); font-size: 10px; letter-spacing: .15em; }
|
||||
.offer-price { display: flex; align-items: end; gap: 18px; margin: 18px 0 24px; padding-bottom: 22px; border-bottom: 1px solid var(--line); }
|
||||
.offer-price > b { font-size: 44px; font-weight: 600; }
|
||||
.offer-price sup { font-size: 18px; }
|
||||
.offer-price > span { display: flex; flex-direction: column; margin-bottom: 7px; }
|
||||
.offer-price del { color: #99968e; font-size: 11px; }
|
||||
.offer-price small { color: #666960; font-size: 10px; }
|
||||
.offer-card ul { list-style: none; margin: 0 0 28px; padding: 0; display: grid; gap: 14px; }
|
||||
.offer-card li { display: flex; align-items: center; gap: 9px; font-family: var(--serif); font-size: 12px; }
|
||||
.offer-card li svg { color: var(--vermilion); width: 17px; }
|
||||
.offer-card > small { display: block; margin-top: 12px; text-align: center; color: #908d85; font-size: 9px; }
|
||||
|
||||
.faq-section { padding: 115px clamp(24px, 14vw, 220px); background: var(--paper); display: grid; grid-template-columns: .6fr 1.4fr; gap: 90px; }
|
||||
.faq-list { border-top: 1px solid var(--line); }
|
||||
details { border-bottom: 1px solid var(--line); }
|
||||
summary { list-style: none; min-height: 74px; display: flex; align-items: center; justify-content: space-between; cursor: pointer; font-family: var(--serif); font-size: 14px; }
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary span { color: var(--vermilion); font-family: sans-serif; font-size: 20px; transition: transform .2s ease; }
|
||||
details[open] summary span { transform: rotate(45deg); }
|
||||
details p { color: var(--ink-soft); font-size: 12px; line-height: 1.9; margin: -8px 35px 24px 0; }
|
||||
|
||||
footer { background: #10120f; color: rgba(255,255,255,.45); min-height: 180px; padding: 42px clamp(24px, 8vw, 128px); display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
|
||||
.small-seal { color: var(--vermilion); width: 36px; height: 36px; }
|
||||
footer p { margin: 14px 0 8px; font-family: var(--serif); color: rgba(255,255,255,.72); font-size: 13px; letter-spacing: .18em; }
|
||||
footer > span { font-size: 9px; line-height: 1.7; }
|
||||
|
||||
.modal-backdrop { position: fixed; inset: 0; z-index: 100; background: rgba(8,9,7,.82); backdrop-filter: blur(7px); display: grid; place-items: center; padding: 20px; animation: fadeIn .2s ease; }
|
||||
.checkout-modal { width: min(460px, 100%); background: var(--paper); padding: 38px; position: relative; text-align: center; box-shadow: 0 40px 100px rgba(0,0,0,.42); }
|
||||
.modal-close { position: absolute; right: 15px; top: 15px; border: 0; background: transparent; color: #77786f; padding: 5px; }
|
||||
.modal-close svg { width: 20px; }
|
||||
.checkout-seal { color: var(--vermilion); margin: 0 auto 15px; }
|
||||
.modal-kicker { color: var(--vermilion); font-size: 9px; letter-spacing: .2em; }
|
||||
.checkout-modal h2 { margin: 8px 0 23px; font-family: var(--serif); font-size: 25px; }
|
||||
.checkout-price { display: flex; justify-content: space-between; align-items: center; padding: 17px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.checkout-price span { color: var(--ink-soft); font-size: 11px; }
|
||||
.checkout-price b { font-size: 30px; }
|
||||
.checkout-price sup { font-size: 13px; }
|
||||
.payment-options { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 22px 0 15px; }
|
||||
.payment-options button { min-height: 60px; background: transparent; border: 1px solid var(--line); color: var(--ink-soft); display: flex; align-items: center; justify-content: center; gap: 5px; position: relative; }
|
||||
.payment-options button span { font-weight: 600; color: var(--ink); }
|
||||
.payment-options button svg { position: absolute; right: 6px; top: 6px; width: 15px; opacity: 0; }
|
||||
.payment-options button.selected { border-color: var(--ink); background: rgba(255,255,255,.5); }
|
||||
.payment-options button.selected svg { opacity: 1; }
|
||||
.payment-options .stripe.selected svg { color: #635bff; }
|
||||
.payment-options .binance.selected svg { color: #c99400; }
|
||||
.email-login { display: grid; gap: 11px; text-align: left; }
|
||||
.email-login p { margin: 0 0 4px; color: var(--ink-soft); font-size: 11px; line-height: 1.7; }
|
||||
.email-login input { width: 100%; height: 48px; border: 1px solid #d7cebd; background: white; padding: 0 13px; outline: 0; }
|
||||
.email-login input:focus { border-color: var(--vermilion); }
|
||||
.purchased-report-list { display: grid; gap: 7px; margin-bottom: 5px; }
|
||||
.history-report { display: flex; justify-content: space-between; gap: 10px; padding: 10px 11px; border: 1px solid var(--line); background: rgba(255,255,255,.55); text-align: left; cursor: pointer; }
|
||||
.history-report:hover { border-color: var(--vermilion); }
|
||||
.history-report b { color: var(--vermilion-dark); font-size: 10px; }
|
||||
.history-report span { color: var(--ink-soft); font-size: 9px; }
|
||||
.demo-notice { margin: 0 0 15px; padding: 10px; background: #fff4db; color: #806525; text-align: left; font-size: 10px; line-height: 1.6; }
|
||||
.payment-error { color: var(--vermilion); font-size: 10px; }
|
||||
.checkout-note { display: flex; justify-content: center; align-items: center; gap: 5px; color: #8d8b83; font-size: 9px; margin: 13px 0 0; }
|
||||
.checkout-note svg { width: 13px; }
|
||||
.mobile-pay-bar { display: none; }
|
||||
|
||||
.reveal-one { animation: revealUp .7s .08s ease both; }
|
||||
.reveal-two { animation: revealUp .7s .22s ease both; }
|
||||
@keyframes revealUp { from { opacity: 0; transform: translateY(22px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes bob { 50% { transform: translateY(5px); } }
|
||||
@keyframes chapterReveal { from { opacity: .5; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
header nav { gap: 20px; margin-right: 24px; }
|
||||
.hero { grid-template-columns: 1fr; padding-top: 145px; }
|
||||
.hero-copy { text-align: center; margin: 0 auto; }
|
||||
.eyebrow { justify-content: center; }
|
||||
.trust-row { justify-content: center; }
|
||||
.birth-card { justify-self: center; }
|
||||
.scroll-cue { display: none; }
|
||||
.chapter-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.offer-section { grid-template-columns: 1fr 1fr; gap: 50px; }
|
||||
.faq-section { grid-template-columns: 1fr; gap: 35px; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
header { height: 66px; padding: 0 18px; }
|
||||
.brand b { font-size: 15px; }
|
||||
.brand small { display: none; }
|
||||
header .seal { width: 35px; height: 35px; }
|
||||
header nav { display: none; position: absolute; left: 0; right: 0; top: 66px; margin: 0; padding: 18px; background: #20221d; flex-direction: column; gap: 0; border-top: 1px solid rgba(255,255,255,.1); }
|
||||
header nav.open { display: flex; }
|
||||
header nav a { padding: 13px 8px; border-bottom: 1px solid rgba(255,255,255,.08); }
|
||||
.recovery-link { margin-left: auto; margin-right: 8px; font-size: 9px; }
|
||||
.header-cta { display: none; }
|
||||
.menu-button { display: grid; place-items: center; }
|
||||
.hero { min-height: 0; padding: 110px 18px 75px; gap: 42px; }
|
||||
.hero-copy { text-align: left; }
|
||||
.eyebrow { justify-content: flex-start; font-size: 10px; }
|
||||
.hero h1 { font-size: clamp(48px, 15vw, 68px); margin: 22px 0; }
|
||||
.hero-lead { font-size: 14px; line-height: 1.85; }
|
||||
.trust-row { justify-content: flex-start; gap: 12px 18px; margin-top: 26px; padding-top: 22px; }
|
||||
.trust-row span { width: calc(50% - 9px); font-size: 10px; }
|
||||
.birth-card { padding: 27px 22px; box-shadow: 10px 13px 0 rgba(0,0,0,.2); }
|
||||
.field-grid { gap: 8px; }
|
||||
.card-heading p { font-size: 16px; }
|
||||
.value-strip { grid-template-columns: 1fr 1fr; padding: 0; }
|
||||
.value-strip div { min-height: 82px; justify-content: flex-start; padding-left: 23px; border-bottom: 1px solid var(--line); }
|
||||
.value-strip b { font-size: 23px; }
|
||||
.value-strip span { font-size: 9px; }
|
||||
.report-section { padding-top: 78px; }
|
||||
.section-heading h2 { font-size: 34px; }
|
||||
.chart-paper { width: calc(100% - 24px); padding: 25px 14px; }
|
||||
.pillars-grid { margin: 23px auto; }
|
||||
.pillar { padding: 3px 4px 12px; }
|
||||
.pillar b { font-size: 36px; }
|
||||
.pillar i { font-size: 8px; }
|
||||
.chart-meta { gap: 4px; }
|
||||
.preview-grid { width: calc(100% - 24px); grid-template-columns: 1fr; margin-bottom: 70px; }
|
||||
.elements-card, .insight-card { padding: 25px 22px; }
|
||||
.technical-chart { padding: 0 12px 75px; }
|
||||
.luck-header { grid-template-columns: 1fr 1fr; }
|
||||
.luck-header > div { min-height: 70px; border-bottom: 1px solid var(--line); }
|
||||
.luck-header > div:nth-child(2) { border-right: 0; }
|
||||
.luck-panel, .relation-panel, .shensha-panel { padding: 21px 16px; }
|
||||
.technical-title { align-items: flex-start; }
|
||||
.technical-title > small { max-width: 105px; text-align: right; line-height: 1.5; }
|
||||
.annual-track { grid-template-columns: repeat(10, 70px); }
|
||||
.technical-grid { grid-template-columns: 1fr; gap: 0; }
|
||||
.shensha-list { grid-template-columns: 1fr; max-height: 430px; }
|
||||
.technical-note { text-align: left; line-height: 1.6; }
|
||||
.premium-report { padding: 76px 12px 100px; }
|
||||
.chapter-grid { grid-template-columns: 1fr; gap: 9px; }
|
||||
.chapter { min-height: 245px; }
|
||||
.unlock-panel { margin: 25px 2px 0; grid-template-columns: auto 1fr; padding: 23px 18px; }
|
||||
.unlock-icon { width: 42px; height: 42px; }
|
||||
.price { grid-column: 1 / 3; text-align: center; }
|
||||
.price small, .price b, .price del { display: inline-block; margin: 0 3px; }
|
||||
.unlock-panel button, .unlock-panel .safe-pay { grid-column: 1 / 3; }
|
||||
.how-section { padding: 80px 18px; }
|
||||
.method-grid { grid-template-columns: 1fr; margin-top: 40px; }
|
||||
.method-grid article { min-height: 220px; border-left: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.method-grid article:last-child { border-bottom: 0; }
|
||||
.offer-section { grid-template-columns: 1fr; padding: 74px 18px 88px; gap: 42px; }
|
||||
.offer-copy { gap: 18px; }
|
||||
.vertical-mark { display: none; }
|
||||
.offer-card { padding: 28px 22px; box-shadow: 9px 10px 0 rgba(36,9,7,.18); }
|
||||
.faq-section { padding: 78px 18px 110px; }
|
||||
.checkout-modal { padding: 32px 22px; }
|
||||
.mobile-pay-bar { position: fixed; left: 9px; right: 9px; bottom: 9px; z-index: 50; min-height: 64px; display: flex; align-items: center; justify-content: space-between; padding: 8px 9px 8px 18px; background: #171915; color: white; box-shadow: 0 14px 45px rgba(0,0,0,.35); }
|
||||
.mobile-pay-bar > div { display: flex; align-items: baseline; gap: 5px; }
|
||||
.mobile-pay-bar span { display: block; position: absolute; top: 8px; color: rgba(255,255,255,.48); font-size: 8px; }
|
||||
.mobile-pay-bar b { font-size: 21px; color: #ead6ad; margin-top: 10px; }
|
||||
.mobile-pay-bar del { font-size: 9px; color: rgba(255,255,255,.35); }
|
||||
.mobile-pay-bar button { border: 0; background: var(--vermilion); color: white; height: 46px; min-width: 130px; font-family: var(--serif); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user