每日运势小程序

This commit is contained in:
cjd
2025-11-09 18:41:07 +08:00
parent 1cc0241cda
commit abd82782af
34 changed files with 2204 additions and 0 deletions

View File

@@ -0,0 +1,304 @@
const fortuneStore = require("../../store/fortuneStore");
const messages = require("../../constants/messages");
const { analyzeFortune } = require("../../services/fortuneService");
const { showRewardedVideoAd } = require("../../utils/adService");
const { getPageSafeTop } = require("../../utils/safeArea");
const { formatRegionDisplay, deriveRegionFromParts, getRegionParts, normalizeRegionArray } = require("../../utils/region");
const LOADING_HINTS = [
"正在推演今日天干地支,请稍候...",
"为你梳理六维度能量波动...",
"结合命盘与今日日运,编排最佳行动节奏..."
];
function showToast(title) {
if (typeof tt === "undefined" || !tt.showToast) {
console.warn("Toast:", title);
return;
}
tt.showToast({
title,
icon: "none",
duration: 2000
});
}
Page({
data: {
birthDate: "",
birthTime: "",
selectedCity: "",
selectedProvince: "",
selectedDistrict: "",
regionValue: [],
regionDisplay: "",
isSubmitting: false,
isLoading: false,
loadingMessage: "",
safeAreaTop: 64,
tips: [
"建议每天专注测算一次,深度分析更精准",
"填写精确城市,可获得更贴近地域气场的指引",
"保持心态稳定,才能更好接住好运"
],
tipIndex: 0
},
onLoad() {
const { form } = fortuneStore.getState();
const persistedRegion = normalizeRegionArray(form.birthRegion);
const regionValue = persistedRegion.length ? persistedRegion : this.getInitialRegionValue(form);
const parts = getRegionParts(regionValue, {
province: form.birthProvince,
city: form.birthCity,
district: form.birthDistrict
});
this.setData({
birthDate: form.birthDate,
birthTime: form.birthTime,
selectedProvince: parts.province,
selectedCity: parts.city,
selectedDistrict: parts.district,
regionValue,
regionDisplay: formatRegionDisplay(regionValue, [parts.province, parts.city, parts.district]),
tipIndex: Math.floor(Math.random() * this.data.tips.length)
});
if (!persistedRegion.length && regionValue.length && parts.city) {
fortuneStore.setForm({
birthProvince: parts.province,
birthCity: parts.city,
birthDistrict: parts.district,
birthRegion: regionValue
});
}
this.updateSafeAreaPadding();
},
getInitialRegionValue(form) {
return deriveRegionFromParts({
province: form.birthProvince,
city: form.birthCity,
district: form.birthDistrict
});
},
updateSafeAreaPadding() {
const padding = getPageSafeTop();
this.setData({ safeAreaTop: padding });
},
resetRegionState() {
this.setData({
regionValue: [],
selectedProvince: "",
selectedCity: "",
selectedDistrict: "",
regionDisplay: ""
});
},
syncRegion(regionValue, fallbackParts = {}, options = {}) {
let normalized = normalizeRegionArray(regionValue);
if (!normalized.length) {
normalized = deriveRegionFromParts(fallbackParts);
}
const parts = getRegionParts(normalized, fallbackParts);
if (!parts.city) {
if (options.resetOnEmpty) {
this.resetRegionState();
}
return false;
}
this.setData({
regionValue: normalized,
selectedProvince: parts.province,
selectedCity: parts.city,
selectedDistrict: parts.district,
regionDisplay: formatRegionDisplay(normalized, [parts.province, parts.city, parts.district])
});
if (options.persist !== false) {
fortuneStore.setForm({
birthProvince: parts.province,
birthCity: parts.city,
birthDistrict: parts.district,
birthRegion: normalized
});
}
return true;
},
handleDateChange(event) {
const value = event.detail.value;
this.setData({ birthDate: value });
fortuneStore.setForm({ birthDate: value });
},
handleTimeChange(event) {
const value = event.detail.value;
this.setData({ birthTime: value });
fortuneStore.setForm({ birthTime: value });
},
handleRegionChange(event) {
const value = event.detail.value || [];
this.syncRegion(value, {}, { resetOnEmpty: true });
},
handleLocate() {
if (typeof tt === "undefined" || !tt.chooseLocation) {
showToast("当前端暂不支持定位");
return;
}
tt.chooseLocation({
success: (res) => {
const city = this.extractCity(res.address || res.name || "");
if (!city) {
showToast("未能识别所在城市,请通过选择器选择");
return;
}
const segments = this.parseAddressSegments(res);
const fallback = {
province: segments[0] || res.province || "",
city,
district: segments[2] || res.district || ""
};
this.syncRegion(segments, fallback, { resetOnEmpty: true });
},
fail: () => {
showToast("定位失败,请检查权限后重试");
}
});
},
extractCity(text) {
if (!text) {
return "";
}
const cityMatch = text.match(/([\u4e00-\u9fa5]+?(市|自治州|地区|盟))/);
if (cityMatch) {
return cityMatch[1];
}
const districtMatch = text.match(/([\u4e00-\u9fa5]+?(区|县))/);
if (districtMatch) {
return districtMatch[1].replace(/(区|县)$/, "市");
}
return "";
},
parseAddressSegments(locationResult) {
const segments = [];
const province = locationResult.province || "";
const city = locationResult.city || "";
const address = locationResult.address || "";
const text = `${province}${city}${address}`;
const provinceMatch = text.match(/([\u4e00-\u9fa5]+?(省|自治区|特别行政区))/);
const cityMatch = text.match(/([\u4e00-\u9fa5]+?(市|自治州|地区|盟))/);
const districtMatch = text.match(/([\u4e00-\u9fa5]+?(区|县|市))/);
if (provinceMatch) {
segments.push(provinceMatch[1]);
}
if (cityMatch) {
segments.push(cityMatch[1]);
}
if (districtMatch && (!segments.length || segments[segments.length - 1] !== districtMatch[1])) {
segments.push(districtMatch[1]);
}
return segments;
},
handleGoHistory() {
if (typeof tt !== "undefined" && tt.navigateTo) {
tt.navigateTo({ url: "/pages/history/index" });
}
},
validateForm() {
const { birthDate, selectedCity } = this.data;
if (!birthDate || !selectedCity) {
showToast(messages.FORM_INCOMPLETE);
return false;
}
if (selectedCity.length < 2) {
showToast(messages.CITY_INVALID);
return false;
}
if (!fortuneStore.canGenerateToday()) {
showToast(messages.QUOTA_EXCEEDED);
return false;
}
return true;
},
handleGenerate() {
if (this.data.isSubmitting) {
return;
}
if (!this.validateForm()) {
return;
}
this.setData({ isSubmitting: true });
showRewardedVideoAd()
.then(() => this.requestFortune())
.catch((error) => {
if (error && error.code === "ad_not_completed") {
showToast(messages.WATCH_AD_TO_CONTINUE);
} else {
showToast(messages.GENERATION_FAILED);
}
this.hideLoadingOverlay();
this.setData({ isSubmitting: false });
});
},
requestFortune() {
const payload = {
birthDate: this.data.birthDate,
birthTime: this.data.birthTime || "",
birthProvince: this.data.selectedProvince,
birthCity: this.data.selectedCity,
birthDistrict: this.data.selectedDistrict,
birthRegion: Array.isArray(this.data.regionValue) ? this.data.regionValue.slice() : []
};
this.showLoadingOverlay();
return analyzeFortune(payload)
.then((response) => {
if (!response) {
showToast(messages.GENERATION_FAILED);
return;
}
fortuneStore.setCurrentFortune(response);
fortuneStore.incrementQuota();
fortuneStore.appendHistory(
{
birthDate: payload.birthDate,
birthTime: payload.birthTime,
birthProvince: payload.birthProvince,
birthCity: payload.birthCity,
birthDistrict: payload.birthDistrict,
birthRegion: payload.birthRegion
},
response
);
if (typeof tt !== "undefined" && tt.navigateTo) {
tt.navigateTo({ url: "/pages/result/index" });
}
})
.catch((error) => {
console.log(error)
const message = error && error.data && error.data.message;
if (message === "CONTENT_RISK") {
showToast(messages.CONTENT_RISK);
} else if (message === "BIRTHDATE_INVALID") {
showToast("出生日期格式异常,请重新选择");
} else {
showToast(messages.GENERATION_FAILED);
}
})
.finally(() => {
this.hideLoadingOverlay();
this.setData({ isSubmitting: false });
});
},
showLoadingOverlay() {
const hint = LOADING_HINTS[Math.floor(Math.random() * LOADING_HINTS.length)];
this.setData({
isLoading: true,
loadingMessage: hint
});
},
hideLoadingOverlay() {
if (!this.data.isLoading) {
return;
}
this.setData({
isLoading: false,
loadingMessage: ""
});
}
});

View File

@@ -0,0 +1,3 @@
{
"navigationStyle": "custom"
}

View File

@@ -0,0 +1,84 @@
<view class="page">
<view class="glow-layer">
<view class="glow glow-one"></view>
<view class="glow glow-two"></view>
<view class="glow glow-three"></view>
</view>
<view class="loading-overlay" tt:if="{{isLoading}}">
<view class="loading-dialog">
<view class="loading-spinner"></view>
<text class="loading-text">{{loadingMessage}}</text>
<text class="loading-subtext">深度推算无需久等,请保持耐心</text>
</view>
</view>
<scroll-view class="content" scroll-y="true" style="padding-top: {{safeAreaTop}}px;">
<view class="hero">
<view class="hero-text">
<text class="hero-label">行知节奏</text>
<text class="hero-title">每日运势指南</text>
<text class="hero-desc">结合八字命盘与当日天象,规划六维行动节奏</text>
</view>
<view class="hero-tags">
<text class="tag">八字推演</text>
<text class="tag">吉时提醒</text>
<text class="tag">专属建议</text>
</view>
</view>
<view class="card">
<view class="card-header">
<text class="card-title">基础信息</text>
<text class="card-subtitle">填写准确数据可提升推演可靠度</text>
</view>
<view class="form-item">
<text class="label">出生日期</text>
<picker mode="date" value="{{birthDate}}" bindchange="handleDateChange">
<view class="picker-value">{{birthDate}}</view>
</picker>
</view>
<view class="form-item">
<text class="label">出生时间(可选)</text>
<picker mode="time" value="{{birthTime}}" bindchange="handleTimeChange">
<view class="picker-value {{birthTime ? '' : 'placeholder'}}">
{{birthTime || '不清楚时间'}}
</view>
</picker>
</view>
<view class="form-item">
<text class="label">出生城市</text>
<view class="city-row">
<picker mode="region" value="{{regionValue}}" bindchange="handleRegionChange">
<view class="picker-value {{regionDisplay ? '' : 'placeholder'}}">
{{regionDisplay || '请选择出生城市'}}
</view>
</picker>
<!-- <button class="locate-btn" size="mini" plain="true" bindtap="handleLocate">定位</button> -->
</view>
</view>
</view>
<view class="card cta-card">
<view class="cta-text">
<text class="cta-title">结果生成</text>
<text class="cta-subtitle">完成激励广告后立即开启</text>
</view>
<button
class="primary"
type="primary"
bindtap="handleGenerate"
loading="{{isSubmitting}}"
disabled="{{isSubmitting}}"
>
观看广告后生成
</button>
<button class="secondary" bindtap="handleGoHistory">查看历史记录</button>
</view>
<view class="card tips-card">
<text class="tips-label">灵感提示</text>
<text class="tips-text">{{tips[tipIndex]}}</text>
</view>
</scroll-view>
</view>

View File

@@ -0,0 +1,259 @@
.page {
position: relative;
min-height: 100vh;
padding: 0 24rpx 80rpx;
box-sizing: border-box;
}
.glow-layer {
position: absolute;
inset: 0;
overflow: hidden;
}
.glow {
position: absolute;
border-radius: 999px;
filter: blur(120rpx);
opacity: 0.6;
}
.glow-one {
width: 320rpx;
height: 320rpx;
top: 60rpx;
right: -80rpx;
background: #ff6ac1;
}
.glow-two {
width: 260rpx;
height: 260rpx;
bottom: 300rpx;
left: -100rpx;
background: #6c7bff;
}
.glow-three {
width: 220rpx;
height: 220rpx;
bottom: 80rpx;
right: 20rpx;
background: #48d2c0;
}
.content {
position: relative;
z-index: 1;
min-height: 100vh;
box-sizing: border-box;
}
.hero {
margin-bottom: 32rpx;
}
.hero-text {
margin-bottom: 20rpx;
}
.hero-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.6);
letter-spacing: 4rpx;
}
.hero-title {
display: block;
margin-top: 12rpx;
font-size: 48rpx;
font-weight: 700;
color: #fff3d6;
}
.hero-desc {
display: block;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.7);
margin-top: 12rpx;
}
.hero-tags {
display: flex;
gap: 16rpx;
}
.tag {
padding: 10rpx 26rpx;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: #fff;
font-size: 24rpx;
}
.card {
background: rgba(255, 255, 255, 0.08);
border-radius: 26rpx;
padding: 32rpx;
margin-bottom: 28rpx;
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
}
.card-header {
display: flex;
flex-direction: column;
gap: 6rpx;
margin-bottom: 20rpx;
}
.card-title {
font-size: 30rpx;
color: #fff9ea;
font-weight: 600;
}
.card-subtitle {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.55);
}
.form-item + .form-item {
margin-top: 28rpx;
}
.label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.65);
margin-bottom: 12rpx;
display: block;
}
.picker-value {
width: 100%;
padding: 24rpx;
min-height: 72rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 30rpx;
color: #fff7e3;
box-sizing: border-box;
display: flex;
align-items: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.placeholder {
color: rgba(255, 255, 255, 0.4);
}
.city-row {
/* display: flex; */
align-items: center;
gap: 16rpx;
}
.locate-btn {
padding: 0 28rpx;
height: 68rpx;
line-height: 64rpx;
border-radius: 22rpx;
border: 1px solid rgba(255, 255, 255, 0.25);
color: #fff;
font-size: 24rpx;
background: transparent;
}
.cta-card .primary {
margin-top: 24rpx;
}
.cta-card .secondary {
margin-top: 16rpx;
}
.cta-text {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.cta-title {
font-size: 30rpx;
color: #fff9ea;
font-weight: 600;
}
.cta-subtitle {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
}
.tips-card {
margin-bottom: 80rpx;
}
.tips-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
}
.tips-text {
display: block;
margin-top: 12rpx;
font-size: 28rpx;
line-height: 42rpx;
color: #fff7e3;
}
.loading-overlay {
position: fixed;
inset: 0;
background: rgba(5, 6, 15, 0.82);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.loading-dialog {
width: 520rpx;
padding: 48rpx 32rpx;
border-radius: 32rpx;
background: rgba(23, 27, 48, 0.92);
text-align: center;
}
.loading-spinner {
width: 88rpx;
height: 88rpx;
margin: 0 auto 24rpx;
border-radius: 50%;
border: 6rpx solid rgba(255, 255, 255, 0.12);
border-top-color: #ffb85c;
animation: spin 1.2s linear infinite;
}
.loading-text {
font-size: 30rpx;
color: #fff9eb;
}
.loading-subtext {
margin-top: 12rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.55);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}