miounet11 commited on
Commit ·
5890c7b
1
Parent(s): 1062766
feat: add email notification system and fix share button bug
Browse files- Fix share button scroll bug by adding relative position to parent container
- Add email subscription management system with verification
- Add email service module (nodemailer) for sending various email types
- Add scheduled email reminders (daily/monthly/yearly fortune, birthday)
- Add password reset functionality with token-based recovery
- Add reward system: 1000 points for email binding, 1000 for subscriptions
- Add EmailSubscriptionManager component in Dashboard
- Add lunar-javascript type declarations
- Add core document engine for profile management
- .env +14 -0
- components/ProgressiveAnalysisResult.tsx +176 -1
- components/email/EmailSubscriptionManager.tsx +379 -0
- components/fortune/ProfileQuickSwitch.tsx +1 -0
- components/layout/RightSidebar.tsx +49 -53
- components/profile/ProfileCard.tsx +64 -8
- components/profile/ProfileManager.tsx +101 -22
- index.css +14 -0
- package-lock.json +32 -0
- package.json +2 -0
- pages/DashboardPage.tsx +141 -125
- pages/HomePage.tsx +1 -1
- server/coreDocumentEngine.js +464 -0
- server/database.js +331 -7
- server/emailScheduler.js +308 -0
- server/emailService.js +457 -0
- server/index.js +470 -1
- types/lunar-javascript.d.ts +51 -0
.env
CHANGED
|
@@ -10,3 +10,17 @@ JWT_SECRET=your-secure-jwt-secret-here
|
|
| 10 |
# Points Configuration
|
| 11 |
FREE_INIT_POINTS=1000
|
| 12 |
COST_PER_ANALYSIS=50
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
# Points Configuration
|
| 11 |
FREE_INIT_POINTS=1000
|
| 12 |
COST_PER_ANALYSIS=50
|
| 13 |
+
|
| 14 |
+
# 邮箱配置
|
| 15 |
+
MAIL_SMTP_HOST=mail.life-kline.cn
|
| 16 |
+
MAIL_SMTP_HOST_IP=180.97.70.166
|
| 17 |
+
MAIL_SMTP_PORT=25
|
| 18 |
+
MAIL_SMTP_SECURE=false
|
| 19 |
+
MAIL_SMTP_IGNORE_TLS=true
|
| 20 |
+
MAIL_FROM=code@life-kline.com
|
| 21 |
+
MAIL_PASSWORD=ZbmgXJ8F
|
| 22 |
+
MAIL_SUBJECT_PREFIX=人生k线命理加强版
|
| 23 |
+
|
| 24 |
+
# 邮件奖励配置
|
| 25 |
+
EMAIL_BINDING_REWARD=1000
|
| 26 |
+
EMAIL_SUBSCRIPTION_REWARD=1000
|
components/ProgressiveAnalysisResult.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|
| 7 |
import {
|
| 8 |
ScrollText, Briefcase, Coins, Heart, Activity, Users, Star, Info,
|
| 9 |
Brain, Bitcoin, Compass, Calendar, Sparkles, TrendingUp, TrendingDown,
|
| 10 |
-
User, MapPin, Clock, Zap, AlertCircle, CheckCircle, Loader2
|
| 11 |
} from 'lucide-react';
|
| 12 |
import ProgressiveSkeleton from './ProgressiveSkeleton';
|
| 13 |
import ResultActions from './ResultActions';
|
|
@@ -37,8 +37,64 @@ interface ProgressiveAnalysisResultProps {
|
|
| 37 |
onSaveProfile?: (profileName: string) => Promise<void>;
|
| 38 |
// 分享回调
|
| 39 |
onShare?: () => void;
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
const ScoreBar = ({ score }: { score: number }) => {
|
| 43 |
let colorClass = "bg-gray-300";
|
| 44 |
if (score >= 9) colorClass = "bg-green-500";
|
|
@@ -145,6 +201,56 @@ const Card = ({ title, icon: Icon, content, score, colorClass, extraBadges, load
|
|
| 145 |
);
|
| 146 |
};
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
// 新增: 幸运元素卡片
|
| 149 |
const LuckyElementsCard = ({ data, loading }: { data: any; loading: boolean }) => {
|
| 150 |
if (loading) {
|
|
@@ -336,6 +442,7 @@ const ProgressiveAnalysisResult: React.FC<ProgressiveAnalysisResultProps> = ({
|
|
| 336 |
userName,
|
| 337 |
onSaveProfile,
|
| 338 |
onShare,
|
|
|
|
| 339 |
}) => {
|
| 340 |
// Agent状态
|
| 341 |
const [agentStatuses, setAgentStatuses] = useState<Record<AgentType, AgentStatus>>({
|
|
@@ -357,6 +464,10 @@ const ProgressiveAnalysisResult: React.FC<ProgressiveAnalysisResultProps> = ({
|
|
| 357 |
const [fromCache, setFromCache] = useState(false);
|
| 358 |
const [progressMessages, setProgressMessages] = useState<string[]>([]);
|
| 359 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
// 更新Agent状��
|
| 361 |
const updateAgentStatus = useCallback((agentType: AgentType, update: Partial<AgentStatus>) => {
|
| 362 |
setAgentStatuses(prev => ({
|
|
@@ -577,6 +688,61 @@ const ProgressiveAnalysisResult: React.FC<ProgressiveAnalysisResultProps> = ({
|
|
| 577 |
};
|
| 578 |
}, [requestData, analysisUrl, onComplete, onError, updateAgentStatus, mergeAgentData]);
|
| 579 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
// Agent名称映射
|
| 581 |
const agentNames: Record<AgentType, string> = {
|
| 582 |
core: '核心命理',
|
|
@@ -645,6 +811,15 @@ const ProgressiveAnalysisResult: React.FC<ProgressiveAnalysisResultProps> = ({
|
|
| 645 |
</div>
|
| 646 |
)}
|
| 647 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 648 |
{/* Results Section - Wrapper for image capture */}
|
| 649 |
<div id="result-chart-section">
|
| 650 |
{/* Bazi Pillars */}
|
|
|
|
| 7 |
import {
|
| 8 |
ScrollText, Briefcase, Coins, Heart, Activity, Users, Star, Info,
|
| 9 |
Brain, Bitcoin, Compass, Calendar, Sparkles, TrendingUp, TrendingDown,
|
| 10 |
+
User, MapPin, Clock, Zap, AlertCircle, CheckCircle, Loader2, RefreshCw
|
| 11 |
} from 'lucide-react';
|
| 12 |
import ProgressiveSkeleton from './ProgressiveSkeleton';
|
| 13 |
import ResultActions from './ResultActions';
|
|
|
|
| 37 |
onSaveProfile?: (profileName: string) => Promise<void>;
|
| 38 |
// 分享回调
|
| 39 |
onShare?: () => void;
|
| 40 |
+
// 档案ID(用于重新生成)
|
| 41 |
+
profileId?: string;
|
| 42 |
}
|
| 43 |
|
| 44 |
+
// 验证结果完整性
|
| 45 |
+
interface ValidationResult {
|
| 46 |
+
isValid: boolean;
|
| 47 |
+
issues: string[];
|
| 48 |
+
score: number;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const validateAnalysisResult = (chartData: any[], analysisData: any): ValidationResult => {
|
| 52 |
+
const issues: string[] = [];
|
| 53 |
+
let score = 100;
|
| 54 |
+
|
| 55 |
+
// 检查 chartData
|
| 56 |
+
if (!chartData || !Array.isArray(chartData)) {
|
| 57 |
+
issues.push('K线数据缺失');
|
| 58 |
+
score -= 40;
|
| 59 |
+
} else if (chartData.length === 0) {
|
| 60 |
+
issues.push('K线数据为空');
|
| 61 |
+
score -= 40;
|
| 62 |
+
} else if (chartData.length < 10) {
|
| 63 |
+
issues.push('K线数据不完整(少于10个数据点)');
|
| 64 |
+
score -= 30;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// 检查 analysisData 核心字段
|
| 68 |
+
if (!analysisData) {
|
| 69 |
+
issues.push('分析数据完全缺失');
|
| 70 |
+
score -= 60;
|
| 71 |
+
} else {
|
| 72 |
+
// 检查性格分析
|
| 73 |
+
if (!analysisData.personality || analysisData.personality.trim().length === 0) {
|
| 74 |
+
issues.push('性格分析内容为空');
|
| 75 |
+
score -= 10;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// 检查事业分析
|
| 79 |
+
if (!analysisData.industry || analysisData.industry.trim().length === 0) {
|
| 80 |
+
issues.push('事业分析内容为空');
|
| 81 |
+
score -= 10;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// 检查财富分析
|
| 85 |
+
if (!analysisData.wealth || analysisData.wealth.trim().length === 0) {
|
| 86 |
+
issues.push('财富分析内容为空');
|
| 87 |
+
score -= 10;
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
isValid: score >= 70,
|
| 93 |
+
issues,
|
| 94 |
+
score
|
| 95 |
+
};
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
const ScoreBar = ({ score }: { score: number }) => {
|
| 99 |
let colorClass = "bg-gray-300";
|
| 100 |
if (score >= 9) colorClass = "bg-green-500";
|
|
|
|
| 201 |
);
|
| 202 |
};
|
| 203 |
|
| 204 |
+
// 新增: 结果验证警告横幅
|
| 205 |
+
const ResultValidationWarning: React.FC<{
|
| 206 |
+
validation: ValidationResult;
|
| 207 |
+
onRegenerate: () => void;
|
| 208 |
+
isRegenerating: boolean;
|
| 209 |
+
}> = ({ validation, onRegenerate, isRegenerating }) => {
|
| 210 |
+
if (validation.isValid) return null;
|
| 211 |
+
|
| 212 |
+
return (
|
| 213 |
+
<div className="bg-amber-50 border-l-4 border-amber-500 p-4 rounded-lg mb-6 animate-fade-in">
|
| 214 |
+
<div className="flex items-start gap-3">
|
| 215 |
+
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
| 216 |
+
<div className="flex-1">
|
| 217 |
+
<h3 className="text-amber-800 font-bold mb-1">检测到分析结果可能不完整</h3>
|
| 218 |
+
<p className="text-amber-700 text-sm mb-2">
|
| 219 |
+
完整性评分: {validation.score}/100
|
| 220 |
+
</p>
|
| 221 |
+
{validation.issues.length > 0 && (
|
| 222 |
+
<ul className="text-amber-700 text-sm space-y-1 mb-3">
|
| 223 |
+
{validation.issues.map((issue, idx) => (
|
| 224 |
+
<li key={idx} className="flex items-center gap-1">
|
| 225 |
+
<span className="w-1 h-1 bg-amber-600 rounded-full"></span>
|
| 226 |
+
{issue}
|
| 227 |
+
</li>
|
| 228 |
+
))}
|
| 229 |
+
</ul>
|
| 230 |
+
)}
|
| 231 |
+
<button
|
| 232 |
+
onClick={onRegenerate}
|
| 233 |
+
disabled={isRegenerating}
|
| 234 |
+
className="flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium text-sm"
|
| 235 |
+
>
|
| 236 |
+
{isRegenerating ? (
|
| 237 |
+
<>
|
| 238 |
+
<Loader2 className="w-4 h-4 animate-spin" />
|
| 239 |
+
正在重新生成...
|
| 240 |
+
</>
|
| 241 |
+
) : (
|
| 242 |
+
<>
|
| 243 |
+
<RefreshCw className="w-4 h-4" />
|
| 244 |
+
重新生成核心分析
|
| 245 |
+
</>
|
| 246 |
+
)}
|
| 247 |
+
</button>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
</div>
|
| 251 |
+
);
|
| 252 |
+
};
|
| 253 |
+
|
| 254 |
// 新增: 幸运元素卡片
|
| 255 |
const LuckyElementsCard = ({ data, loading }: { data: any; loading: boolean }) => {
|
| 256 |
if (loading) {
|
|
|
|
| 442 |
userName,
|
| 443 |
onSaveProfile,
|
| 444 |
onShare,
|
| 445 |
+
profileId,
|
| 446 |
}) => {
|
| 447 |
// Agent状态
|
| 448 |
const [agentStatuses, setAgentStatuses] = useState<Record<AgentType, AgentStatus>>({
|
|
|
|
| 464 |
const [fromCache, setFromCache] = useState(false);
|
| 465 |
const [progressMessages, setProgressMessages] = useState<string[]>([]);
|
| 466 |
|
| 467 |
+
// 验证和重新生成状态
|
| 468 |
+
const [validation, setValidation] = useState<ValidationResult | null>(null);
|
| 469 |
+
const [isRegenerating, setIsRegenerating] = useState(false);
|
| 470 |
+
|
| 471 |
// 更新Agent状��
|
| 472 |
const updateAgentStatus = useCallback((agentType: AgentType, update: Partial<AgentStatus>) => {
|
| 473 |
setAgentStatuses(prev => ({
|
|
|
|
| 688 |
};
|
| 689 |
}, [requestData, analysisUrl, onComplete, onError, updateAgentStatus, mergeAgentData]);
|
| 690 |
|
| 691 |
+
// 验证分析结果完整性(当分析完成时)
|
| 692 |
+
useEffect(() => {
|
| 693 |
+
if (isComplete && analysis && chartData) {
|
| 694 |
+
const validationResult = validateAnalysisResult(chartData, analysis);
|
| 695 |
+
setValidation(validationResult);
|
| 696 |
+
|
| 697 |
+
if (!validationResult.isValid) {
|
| 698 |
+
console.warn('[ProgressiveAnalysisResult] 检测到分析结果不完整:', validationResult);
|
| 699 |
+
}
|
| 700 |
+
}
|
| 701 |
+
}, [isComplete, analysis, chartData]);
|
| 702 |
+
|
| 703 |
+
// 重新生成核心文档
|
| 704 |
+
const handleRegenerate = useCallback(async () => {
|
| 705 |
+
if (!profileId) {
|
| 706 |
+
onError?.('无法重新生成:缺少档案ID');
|
| 707 |
+
return;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
setIsRegenerating(true);
|
| 711 |
+
|
| 712 |
+
try {
|
| 713 |
+
const response = await fetch(`/api/profiles/${profileId}/regenerate`, {
|
| 714 |
+
method: 'POST',
|
| 715 |
+
headers: {
|
| 716 |
+
'Content-Type': 'application/json',
|
| 717 |
+
},
|
| 718 |
+
credentials: 'include',
|
| 719 |
+
body: JSON.stringify({
|
| 720 |
+
reason: '用户手动触发 - 结果不完整'
|
| 721 |
+
}),
|
| 722 |
+
});
|
| 723 |
+
|
| 724 |
+
if (!response.ok) {
|
| 725 |
+
throw new Error('重新生成失败');
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
const data = await response.json();
|
| 729 |
+
|
| 730 |
+
// 显示成功消息
|
| 731 |
+
setProgressMessages(prev => [...prev, '✓ 核心文档重新生成已触发']);
|
| 732 |
+
|
| 733 |
+
// 等待2秒后刷新页面或重新加载结果
|
| 734 |
+
setTimeout(() => {
|
| 735 |
+
window.location.reload();
|
| 736 |
+
}, 2000);
|
| 737 |
+
|
| 738 |
+
} catch (error) {
|
| 739 |
+
console.error('[ProgressiveAnalysisResult] 重新生成失败:', error);
|
| 740 |
+
onError?.('重新生成失败,请稍后重试');
|
| 741 |
+
} finally {
|
| 742 |
+
setIsRegenerating(false);
|
| 743 |
+
}
|
| 744 |
+
}, [profileId, onError]);
|
| 745 |
+
|
| 746 |
// Agent名称映射
|
| 747 |
const agentNames: Record<AgentType, string> = {
|
| 748 |
core: '核心命理',
|
|
|
|
| 811 |
</div>
|
| 812 |
)}
|
| 813 |
|
| 814 |
+
{/* 结果验证警告 */}
|
| 815 |
+
{validation && !validation.isValid && profileId && (
|
| 816 |
+
<ResultValidationWarning
|
| 817 |
+
validation={validation}
|
| 818 |
+
onRegenerate={handleRegenerate}
|
| 819 |
+
isRegenerating={isRegenerating}
|
| 820 |
+
/>
|
| 821 |
+
)}
|
| 822 |
+
|
| 823 |
{/* Results Section - Wrapper for image capture */}
|
| 824 |
<div id="result-chart-section">
|
| 825 |
{/* Bazi Pillars */}
|
components/email/EmailSubscriptionManager.tsx
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { Mail, Check, Bell, Gift, AlertCircle, Loader2, CheckCircle, Award } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
interface EmailSubscriptionManagerProps {
|
| 5 |
+
userPoints: number;
|
| 6 |
+
onPointsChange: () => void;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
interface SubscriptionData {
|
| 10 |
+
emailVerified: boolean;
|
| 11 |
+
bindingRewardClaimed: boolean;
|
| 12 |
+
subscriptionRewardClaimed: boolean;
|
| 13 |
+
subscriptions: {
|
| 14 |
+
sub_daily_fortune: boolean;
|
| 15 |
+
sub_monthly_fortune: boolean;
|
| 16 |
+
sub_yearly_fortune: boolean;
|
| 17 |
+
sub_birthday_reminder: boolean;
|
| 18 |
+
sub_low_points: boolean;
|
| 19 |
+
sub_feature_updates: boolean;
|
| 20 |
+
sub_promotions: boolean;
|
| 21 |
+
};
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const EmailSubscriptionManager: React.FC<EmailSubscriptionManagerProps> = ({
|
| 25 |
+
userPoints: _userPoints,
|
| 26 |
+
onPointsChange
|
| 27 |
+
}) => {
|
| 28 |
+
const [loading, setLoading] = useState(true);
|
| 29 |
+
const [subscription, setSubscription] = useState<SubscriptionData | null>(null);
|
| 30 |
+
const [verificationSending, setVerificationSending] = useState(false);
|
| 31 |
+
const [rewardClaiming, setRewardClaiming] = useState(false);
|
| 32 |
+
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
| 33 |
+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
| 34 |
+
|
| 35 |
+
// Fetch subscription settings
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
const fetchSubscription = async () => {
|
| 38 |
+
try {
|
| 39 |
+
const response = await fetch('/api/email/subscription', {
|
| 40 |
+
credentials: 'include'
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
if (response.ok) {
|
| 44 |
+
const data = await response.json();
|
| 45 |
+
setSubscription(data);
|
| 46 |
+
} else {
|
| 47 |
+
setErrorMessage('Failed to load email settings');
|
| 48 |
+
}
|
| 49 |
+
} catch (err) {
|
| 50 |
+
console.error('Failed to fetch subscription:', err);
|
| 51 |
+
setErrorMessage('Failed to load email settings');
|
| 52 |
+
} finally {
|
| 53 |
+
setLoading(false);
|
| 54 |
+
}
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
fetchSubscription();
|
| 58 |
+
}, []);
|
| 59 |
+
|
| 60 |
+
const showMessage = (message: string, isError: boolean = false) => {
|
| 61 |
+
if (isError) {
|
| 62 |
+
setErrorMessage(message);
|
| 63 |
+
setTimeout(() => setErrorMessage(null), 5000);
|
| 64 |
+
} else {
|
| 65 |
+
setSuccessMessage(message);
|
| 66 |
+
setTimeout(() => setSuccessMessage(null), 5000);
|
| 67 |
+
}
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
const handleSendVerification = async () => {
|
| 71 |
+
setVerificationSending(true);
|
| 72 |
+
try {
|
| 73 |
+
const response = await fetch('/api/email/send-verification', {
|
| 74 |
+
method: 'POST',
|
| 75 |
+
credentials: 'include'
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
const data = await response.json();
|
| 79 |
+
|
| 80 |
+
if (response.ok) {
|
| 81 |
+
showMessage('Verification email sent! Please check your inbox.');
|
| 82 |
+
} else {
|
| 83 |
+
showMessage(data.message || 'Failed to send verification email', true);
|
| 84 |
+
}
|
| 85 |
+
} catch (err) {
|
| 86 |
+
showMessage('Network error, please try again', true);
|
| 87 |
+
} finally {
|
| 88 |
+
setVerificationSending(false);
|
| 89 |
+
}
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
const handleToggleSubscription = async (key: keyof SubscriptionData['subscriptions']) => {
|
| 93 |
+
if (!subscription) return;
|
| 94 |
+
|
| 95 |
+
const newValue = !subscription.subscriptions[key];
|
| 96 |
+
const updatedSubscriptions = {
|
| 97 |
+
...subscription.subscriptions,
|
| 98 |
+
[key]: newValue
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
// Optimistic update
|
| 102 |
+
setSubscription({
|
| 103 |
+
...subscription,
|
| 104 |
+
subscriptions: updatedSubscriptions
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
try {
|
| 108 |
+
const response = await fetch('/api/email/subscription', {
|
| 109 |
+
method: 'PUT',
|
| 110 |
+
headers: { 'Content-Type': 'application/json' },
|
| 111 |
+
credentials: 'include',
|
| 112 |
+
body: JSON.stringify({ subscriptions: updatedSubscriptions })
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
if (!response.ok) {
|
| 116 |
+
// Revert on error
|
| 117 |
+
setSubscription(subscription);
|
| 118 |
+
showMessage('Failed to update subscription', true);
|
| 119 |
+
}
|
| 120 |
+
} catch (err) {
|
| 121 |
+
// Revert on error
|
| 122 |
+
setSubscription(subscription);
|
| 123 |
+
showMessage('Network error, please try again', true);
|
| 124 |
+
}
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
const handleClaimBindingReward = async () => {
|
| 128 |
+
setRewardClaiming(true);
|
| 129 |
+
try {
|
| 130 |
+
const response = await fetch('/api/email/claim-binding-reward', {
|
| 131 |
+
method: 'POST',
|
| 132 |
+
credentials: 'include'
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
const data = await response.json();
|
| 136 |
+
|
| 137 |
+
if (response.ok) {
|
| 138 |
+
showMessage(`Successfully claimed ${data.points} points!`);
|
| 139 |
+
setSubscription(prev => prev ? { ...prev, bindingRewardClaimed: true } : null);
|
| 140 |
+
onPointsChange();
|
| 141 |
+
} else {
|
| 142 |
+
showMessage(data.message || 'Failed to claim reward', true);
|
| 143 |
+
}
|
| 144 |
+
} catch (err) {
|
| 145 |
+
showMessage('Network error, please try again', true);
|
| 146 |
+
} finally {
|
| 147 |
+
setRewardClaiming(false);
|
| 148 |
+
}
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
const handleClaimSubscriptionReward = async () => {
|
| 152 |
+
setRewardClaiming(true);
|
| 153 |
+
try {
|
| 154 |
+
const response = await fetch('/api/email/claim-subscription-reward', {
|
| 155 |
+
method: 'POST',
|
| 156 |
+
credentials: 'include'
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
const data = await response.json();
|
| 160 |
+
|
| 161 |
+
if (response.ok) {
|
| 162 |
+
showMessage(`Successfully claimed ${data.points} points!`);
|
| 163 |
+
setSubscription(prev => prev ? { ...prev, subscriptionRewardClaimed: true } : null);
|
| 164 |
+
onPointsChange();
|
| 165 |
+
} else {
|
| 166 |
+
showMessage(data.message || 'Failed to claim reward', true);
|
| 167 |
+
}
|
| 168 |
+
} catch (err) {
|
| 169 |
+
showMessage('Network error, please try again', true);
|
| 170 |
+
} finally {
|
| 171 |
+
setRewardClaiming(false);
|
| 172 |
+
}
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
+
const subscriptionOptions = [
|
| 176 |
+
{ key: 'sub_daily_fortune' as const, label: '每日运势提醒', icon: Bell },
|
| 177 |
+
{ key: 'sub_monthly_fortune' as const, label: '月度运势提醒', icon: Bell },
|
| 178 |
+
{ key: 'sub_yearly_fortune' as const, label: '年度运势提醒', icon: Bell },
|
| 179 |
+
{ key: 'sub_birthday_reminder' as const, label: '农历生日提醒', icon: Gift },
|
| 180 |
+
{ key: 'sub_low_points' as const, label: '积分不足提醒', icon: AlertCircle },
|
| 181 |
+
{ key: 'sub_feature_updates' as const, label: '功能更新通知', icon: Check },
|
| 182 |
+
{ key: 'sub_promotions' as const, label: '活动促销通知', icon: Gift },
|
| 183 |
+
];
|
| 184 |
+
|
| 185 |
+
const hasAnySubscription = subscription && Object.values(subscription.subscriptions).some(v => v);
|
| 186 |
+
|
| 187 |
+
if (loading) {
|
| 188 |
+
return (
|
| 189 |
+
<div className="flex items-center justify-center py-12">
|
| 190 |
+
<Loader2 className="w-8 h-8 text-indigo-600 animate-spin" />
|
| 191 |
+
</div>
|
| 192 |
+
);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
return (
|
| 196 |
+
<div className="space-y-6">
|
| 197 |
+
{/* Messages */}
|
| 198 |
+
{successMessage && (
|
| 199 |
+
<div className="flex items-center gap-2 p-4 bg-green-50 border border-green-200 text-green-700 rounded-lg">
|
| 200 |
+
<CheckCircle className="w-5 h-5 flex-shrink-0" />
|
| 201 |
+
<span className="text-sm">{successMessage}</span>
|
| 202 |
+
</div>
|
| 203 |
+
)}
|
| 204 |
+
|
| 205 |
+
{errorMessage && (
|
| 206 |
+
<div className="flex items-center gap-2 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
| 207 |
+
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
| 208 |
+
<span className="text-sm">{errorMessage}</span>
|
| 209 |
+
</div>
|
| 210 |
+
)}
|
| 211 |
+
|
| 212 |
+
{/* Email Verification Status */}
|
| 213 |
+
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
| 214 |
+
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
| 215 |
+
<Mail className="w-5 h-5" />
|
| 216 |
+
邮箱验证状态
|
| 217 |
+
</h3>
|
| 218 |
+
|
| 219 |
+
{subscription?.emailVerified ? (
|
| 220 |
+
<div className="space-y-4">
|
| 221 |
+
<div className="flex items-center gap-2 text-green-600">
|
| 222 |
+
<CheckCircle className="w-5 h-5" />
|
| 223 |
+
<span className="font-medium">邮箱已验证</span>
|
| 224 |
+
</div>
|
| 225 |
+
|
| 226 |
+
{!subscription.bindingRewardClaimed && (
|
| 227 |
+
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg p-4">
|
| 228 |
+
<div className="flex items-center justify-between">
|
| 229 |
+
<div className="flex items-center gap-2">
|
| 230 |
+
<Award className="w-5 h-5 text-green-600" />
|
| 231 |
+
<span className="text-sm font-medium text-gray-900">验证奖励可领取</span>
|
| 232 |
+
</div>
|
| 233 |
+
<button
|
| 234 |
+
onClick={handleClaimBindingReward}
|
| 235 |
+
disabled={rewardClaiming}
|
| 236 |
+
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 text-sm font-medium"
|
| 237 |
+
>
|
| 238 |
+
{rewardClaiming ? (
|
| 239 |
+
<>
|
| 240 |
+
<Loader2 className="w-4 h-4 animate-spin" />
|
| 241 |
+
领取中...
|
| 242 |
+
</>
|
| 243 |
+
) : (
|
| 244 |
+
<>
|
| 245 |
+
<Gift className="w-4 h-4" />
|
| 246 |
+
领取 1000 点
|
| 247 |
+
</>
|
| 248 |
+
)}
|
| 249 |
+
</button>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
)}
|
| 253 |
+
</div>
|
| 254 |
+
) : (
|
| 255 |
+
<div className="space-y-4">
|
| 256 |
+
<div className="flex items-center gap-2 text-amber-600">
|
| 257 |
+
<AlertCircle className="w-5 h-5" />
|
| 258 |
+
<span className="font-medium">邮箱未验证</span>
|
| 259 |
+
</div>
|
| 260 |
+
|
| 261 |
+
<button
|
| 262 |
+
onClick={handleSendVerification}
|
| 263 |
+
disabled={verificationSending}
|
| 264 |
+
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 text-sm font-medium"
|
| 265 |
+
>
|
| 266 |
+
{verificationSending ? (
|
| 267 |
+
<>
|
| 268 |
+
<Loader2 className="w-4 h-4 animate-spin" />
|
| 269 |
+
发送中...
|
| 270 |
+
</>
|
| 271 |
+
) : (
|
| 272 |
+
<>
|
| 273 |
+
<Mail className="w-4 h-4" />
|
| 274 |
+
发送验证邮件
|
| 275 |
+
</>
|
| 276 |
+
)}
|
| 277 |
+
</button>
|
| 278 |
+
|
| 279 |
+
<p className="text-xs text-gray-500">
|
| 280 |
+
验证邮箱后可获得 1000 积分奖励
|
| 281 |
+
</p>
|
| 282 |
+
</div>
|
| 283 |
+
)}
|
| 284 |
+
</div>
|
| 285 |
+
|
| 286 |
+
{/* Subscription Options */}
|
| 287 |
+
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
| 288 |
+
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
| 289 |
+
<Bell className="w-5 h-5" />
|
| 290 |
+
订阅设置
|
| 291 |
+
</h3>
|
| 292 |
+
|
| 293 |
+
<div className="space-y-3">
|
| 294 |
+
{subscriptionOptions.map(option => {
|
| 295 |
+
const Icon = option.icon;
|
| 296 |
+
const isEnabled = subscription?.subscriptions[option.key] || false;
|
| 297 |
+
|
| 298 |
+
return (
|
| 299 |
+
<div
|
| 300 |
+
key={option.key}
|
| 301 |
+
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
| 302 |
+
>
|
| 303 |
+
<div className="flex items-center gap-3">
|
| 304 |
+
<Icon className="w-5 h-5 text-gray-600" />
|
| 305 |
+
<span className="text-sm font-medium text-gray-900">{option.label}</span>
|
| 306 |
+
</div>
|
| 307 |
+
|
| 308 |
+
<button
|
| 309 |
+
onClick={() => handleToggleSubscription(option.key)}
|
| 310 |
+
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2 ${
|
| 311 |
+
isEnabled ? 'bg-indigo-600' : 'bg-gray-200'
|
| 312 |
+
}`}
|
| 313 |
+
>
|
| 314 |
+
<span
|
| 315 |
+
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
| 316 |
+
isEnabled ? 'translate-x-5' : 'translate-x-0'
|
| 317 |
+
}`}
|
| 318 |
+
/>
|
| 319 |
+
</button>
|
| 320 |
+
</div>
|
| 321 |
+
);
|
| 322 |
+
})}
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
|
| 326 |
+
{/* Subscription Reward */}
|
| 327 |
+
{hasAnySubscription && !subscription?.subscriptionRewardClaimed && (
|
| 328 |
+
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 border border-purple-200 rounded-lg p-6">
|
| 329 |
+
<div className="flex items-center justify-between">
|
| 330 |
+
<div className="flex items-center gap-3">
|
| 331 |
+
<div className="p-2 bg-purple-100 rounded-full">
|
| 332 |
+
<Gift className="w-6 h-6 text-purple-600" />
|
| 333 |
+
</div>
|
| 334 |
+
<div>
|
| 335 |
+
<h4 className="text-base font-semibold text-gray-900">订阅奖励</h4>
|
| 336 |
+
<p className="text-sm text-gray-600">感谢您订阅邮件通知</p>
|
| 337 |
+
</div>
|
| 338 |
+
</div>
|
| 339 |
+
|
| 340 |
+
<button
|
| 341 |
+
onClick={handleClaimSubscriptionReward}
|
| 342 |
+
disabled={rewardClaiming}
|
| 343 |
+
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 text-sm font-medium whitespace-nowrap"
|
| 344 |
+
>
|
| 345 |
+
{rewardClaiming ? (
|
| 346 |
+
<>
|
| 347 |
+
<Loader2 className="w-4 h-4 animate-spin" />
|
| 348 |
+
领取中...
|
| 349 |
+
</>
|
| 350 |
+
) : (
|
| 351 |
+
<>
|
| 352 |
+
<Gift className="w-4 h-4" />
|
| 353 |
+
领取 1000 点
|
| 354 |
+
</>
|
| 355 |
+
)}
|
| 356 |
+
</button>
|
| 357 |
+
</div>
|
| 358 |
+
</div>
|
| 359 |
+
)}
|
| 360 |
+
|
| 361 |
+
{/* Info Box */}
|
| 362 |
+
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
| 363 |
+
<div className="flex gap-3">
|
| 364 |
+
<Mail className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
| 365 |
+
<div className="text-sm text-blue-900">
|
| 366 |
+
<p className="font-medium mb-1">邮件订阅说明</p>
|
| 367 |
+
<ul className="space-y-1 text-blue-800 list-disc list-inside">
|
| 368 |
+
<li>您可以随时开启或关闭任何订阅</li>
|
| 369 |
+
<li>我们尊重您的隐私,不会向第三方共享您的邮箱</li>
|
| 370 |
+
<li>验证邮箱和订阅均可获得积分奖励</li>
|
| 371 |
+
</ul>
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
</div>
|
| 375 |
+
</div>
|
| 376 |
+
);
|
| 377 |
+
};
|
| 378 |
+
|
| 379 |
+
export default EmailSubscriptionManager;
|
components/fortune/ProfileQuickSwitch.tsx
CHANGED
|
@@ -8,6 +8,7 @@ export interface ProfileInfo {
|
|
| 8 |
monthPillar: string;
|
| 9 |
dayPillar: string;
|
| 10 |
hourPillar: string;
|
|
|
|
| 11 |
}
|
| 12 |
|
| 13 |
interface ProfileQuickSwitchProps {
|
|
|
|
| 8 |
monthPillar: string;
|
| 9 |
dayPillar: string;
|
| 10 |
hourPillar: string;
|
| 11 |
+
isDefault?: boolean;
|
| 12 |
}
|
| 13 |
|
| 14 |
interface ProfileQuickSwitchProps {
|
components/layout/RightSidebar.tsx
CHANGED
|
@@ -44,12 +44,12 @@ const CurrentProfileCard: React.FC<{
|
|
| 44 |
return (
|
| 45 |
<div className="bg-gradient-to-br from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-4">
|
| 46 |
<div className="flex items-center gap-3 mb-3">
|
| 47 |
-
<div className="w-10 h-10 bg-indigo-100 rounded-full flex items-center justify-center">
|
| 48 |
<User className="w-5 h-5 text-indigo-400" />
|
| 49 |
</div>
|
| 50 |
-
<div>
|
| 51 |
-
<h3 className="font-bold text-gray-800">创建八字档案</h3>
|
| 52 |
-
<p className="text-xs text-gray-500">保存八字以查看运势分析</p>
|
| 53 |
</div>
|
| 54 |
</div>
|
| 55 |
<button
|
|
@@ -66,12 +66,12 @@ const CurrentProfileCard: React.FC<{
|
|
| 66 |
return (
|
| 67 |
<div className="bg-gradient-to-br from-gray-50 to-slate-50 border border-gray-200 rounded-xl p-4">
|
| 68 |
<div className="flex items-center gap-3 mb-3">
|
| 69 |
-
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
| 70 |
<User className="w-5 h-5 text-gray-400" />
|
| 71 |
</div>
|
| 72 |
-
<div className="flex-1">
|
| 73 |
-
<h3 className="font-bold text-gray-800">选择档案</h3>
|
| 74 |
-
<p className="text-xs text-gray-500">请选择要查看运势的档案</p>
|
| 75 |
</div>
|
| 76 |
</div>
|
| 77 |
<div className="relative">
|
|
@@ -79,11 +79,11 @@ const CurrentProfileCard: React.FC<{
|
|
| 79 |
onClick={() => setShowDropdown(!showDropdown)}
|
| 80 |
className="w-full py-2 px-3 bg-white border border-gray-200 rounded-lg text-sm text-left flex items-center justify-between hover:border-indigo-300 transition-colors"
|
| 81 |
>
|
| 82 |
-
<span className="text-gray-500">选择档案...</span>
|
| 83 |
-
<ChevronRight className={`w-4 h-4 text-gray-400 transition-transform ${showDropdown ? 'rotate-90' : ''}`} />
|
| 84 |
</button>
|
| 85 |
{showDropdown && (
|
| 86 |
-
<div className="absolute z-20 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg py-1">
|
| 87 |
{profiles.map((p) => (
|
| 88 |
<button
|
| 89 |
key={p.id}
|
|
@@ -93,8 +93,8 @@ const CurrentProfileCard: React.FC<{
|
|
| 93 |
}}
|
| 94 |
className="w-full px-3 py-2 text-left text-sm hover:bg-indigo-50 transition-colors"
|
| 95 |
>
|
| 96 |
-
<div className="font-medium text-gray-800">{p.name}</div>
|
| 97 |
-
<div className="text-xs text-gray-500">
|
| 98 |
{p.yearPillar} {p.monthPillar} {p.dayPillar} {p.hourPillar}
|
| 99 |
</div>
|
| 100 |
</button>
|
|
@@ -108,17 +108,17 @@ const CurrentProfileCard: React.FC<{
|
|
| 108 |
|
| 109 |
return (
|
| 110 |
<div className="bg-gradient-to-br from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-4">
|
| 111 |
-
<div className="flex items-center justify-between mb-3">
|
| 112 |
-
<div className="flex items-center gap-3">
|
| 113 |
-
<div className="w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center">
|
| 114 |
<User className="w-5 h-5 text-white" />
|
| 115 |
</div>
|
| 116 |
-
<div>
|
| 117 |
-
<h3 className="font-bold text-gray-800">{profile.name}</h3>
|
| 118 |
-
<p className="text-xs text-gray-500">当前查看档案</p>
|
| 119 |
</div>
|
| 120 |
</div>
|
| 121 |
-
<div className="relative">
|
| 122 |
<button
|
| 123 |
onClick={() => setShowDropdown(!showDropdown)}
|
| 124 |
className="p-2 hover:bg-white/50 rounded-lg transition-colors"
|
|
@@ -127,7 +127,7 @@ const CurrentProfileCard: React.FC<{
|
|
| 127 |
<ChevronRight className={`w-4 h-4 text-gray-500 transition-transform ${showDropdown ? 'rotate-90' : ''}`} />
|
| 128 |
</button>
|
| 129 |
{showDropdown && profiles.length > 1 && (
|
| 130 |
-
<div className="absolute z-20 right-0 w-48 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg py-1">
|
| 131 |
{profiles.filter(p => p.id !== profile.id).map((p) => (
|
| 132 |
<button
|
| 133 |
key={p.id}
|
|
@@ -137,7 +137,7 @@ const CurrentProfileCard: React.FC<{
|
|
| 137 |
}}
|
| 138 |
className="w-full px-3 py-2 text-left text-sm hover:bg-indigo-50 transition-colors"
|
| 139 |
>
|
| 140 |
-
<div className="font-medium text-gray-800">{p.name}</div>
|
| 141 |
<div className="text-xs text-gray-500 truncate">
|
| 142 |
{p.yearPillar} {p.monthPillar} {p.dayPillar} {p.hourPillar}
|
| 143 |
</div>
|
|
@@ -149,22 +149,22 @@ const CurrentProfileCard: React.FC<{
|
|
| 149 |
</div>
|
| 150 |
|
| 151 |
{/* 四柱显示 */}
|
| 152 |
-
<div className="grid grid-cols-4 gap-2 mb-3">
|
| 153 |
-
<div className="text-center bg-white/60 rounded-lg py-2">
|
| 154 |
-
<div className="text-xs text-gray-500 mb-0.5">年柱</div>
|
| 155 |
-
<div className="font-bold text-indigo-700 font-serif-sc">{profile.yearPillar}</div>
|
| 156 |
</div>
|
| 157 |
-
<div className="text-center bg-white/60 rounded-lg py-2">
|
| 158 |
-
<div className="text-xs text-gray-500 mb-0.5">月柱</div>
|
| 159 |
-
<div className="font-bold text-indigo-700 font-serif-sc">{profile.monthPillar}</div>
|
| 160 |
</div>
|
| 161 |
-
<div className="text-center bg-white/60 rounded-lg py-2">
|
| 162 |
-
<div className="text-xs text-gray-500 mb-0.5">日柱</div>
|
| 163 |
-
<div className="font-bold text-indigo-700 font-serif-sc">{profile.dayPillar}</div>
|
| 164 |
</div>
|
| 165 |
-
<div className="text-center bg-white/60 rounded-lg py-2">
|
| 166 |
-
<div className="text-xs text-gray-500 mb-0.5">时柱</div>
|
| 167 |
-
<div className="font-bold text-indigo-700 font-serif-sc">{profile.hourPillar}</div>
|
| 168 |
</div>
|
| 169 |
</div>
|
| 170 |
|
|
@@ -198,29 +198,31 @@ const SaveCurrentBaziPrompt: React.FC<{
|
|
| 198 |
return (
|
| 199 |
<div className="bg-gradient-to-br from-amber-50 to-orange-50 border border-amber-200 rounded-xl p-4">
|
| 200 |
<div className="flex items-start gap-3">
|
| 201 |
-
<div className="p-2 bg-amber-100 rounded-lg">
|
| 202 |
<AlertCircle className="w-5 h-5 text-amber-600" />
|
| 203 |
</div>
|
| 204 |
-
<div className="flex-1">
|
| 205 |
<h4 className="font-semibold text-gray-800 text-sm mb-1">保存当前命盘</h4>
|
| 206 |
<p className="text-xs text-gray-600 mb-3">
|
| 207 |
检测到您刚完成一次测算,是否将此八字保存为档案以便查看运势?
|
| 208 |
</p>
|
| 209 |
-
<div className="text-xs text-gray-500 mb-3 bg-white/50 rounded-lg p-2">
|
| 210 |
<span className="font-medium">八字:</span>
|
| 211 |
-
|
|
|
|
|
|
|
| 212 |
</div>
|
| 213 |
<div className="flex gap-2">
|
| 214 |
<button
|
| 215 |
onClick={() => setShowInput(true)}
|
| 216 |
className="flex-1 py-2 bg-amber-500 hover:bg-amber-600 text-white text-sm font-medium rounded-lg flex items-center justify-center gap-1.5"
|
| 217 |
>
|
| 218 |
-
<Save className="w-4 h-4" />
|
| 219 |
-
保存档案
|
| 220 |
</button>
|
| 221 |
<button
|
| 222 |
onClick={onDismiss}
|
| 223 |
-
className="px-3 py-2 text-gray-500 hover:text-gray-700 text-sm"
|
| 224 |
>
|
| 225 |
暂不
|
| 226 |
</button>
|
|
@@ -235,7 +237,7 @@ const SaveCurrentBaziPrompt: React.FC<{
|
|
| 235 |
<div className="bg-gradient-to-br from-amber-50 to-orange-50 border border-amber-200 rounded-xl p-4">
|
| 236 |
<div className="flex items-center justify-between mb-3">
|
| 237 |
<h4 className="font-semibold text-gray-800 text-sm">为此命盘取个名字</h4>
|
| 238 |
-
<button onClick={() => setShowInput(false)} className="p-1 hover:bg-amber-100 rounded">
|
| 239 |
<X className="w-4 h-4 text-gray-500" />
|
| 240 |
</button>
|
| 241 |
</div>
|
|
@@ -252,7 +254,7 @@ const SaveCurrentBaziPrompt: React.FC<{
|
|
| 252 |
disabled={!profileName.trim() || saving}
|
| 253 |
className="w-full py-2 bg-amber-500 hover:bg-amber-600 disabled:bg-gray-300 text-white text-sm font-medium rounded-lg flex items-center justify-center gap-1.5"
|
| 254 |
>
|
| 255 |
-
{saving ? '保存中...' : <><Check className="w-4 h-4" />确认保存</>}
|
| 256 |
</button>
|
| 257 |
</div>
|
| 258 |
);
|
|
@@ -289,7 +291,6 @@ const MonthlyFortuneCard: React.FC<{ profileId: string | null; onViewDetail: ()
|
|
| 289 |
const [fortune, setFortune] = useState<any>(null);
|
| 290 |
const [loading, setLoading] = useState(false);
|
| 291 |
const [error, setError] = useState<string | null>(null);
|
| 292 |
-
const [fetched, setFetched] = useState(false);
|
| 293 |
|
| 294 |
const currentYear = new Date().getFullYear();
|
| 295 |
const currentMonth = new Date().getMonth() + 1;
|
|
@@ -297,7 +298,6 @@ const MonthlyFortuneCard: React.FC<{ profileId: string | null; onViewDetail: ()
|
|
| 297 |
|
| 298 |
// Reset state when profileId changes (no auto-fetch)
|
| 299 |
useEffect(() => {
|
| 300 |
-
setFetched(false);
|
| 301 |
setFortune(null);
|
| 302 |
setError(null);
|
| 303 |
}, [profileId]);
|
|
@@ -321,7 +321,6 @@ const MonthlyFortuneCard: React.FC<{ profileId: string | null; onViewDetail: ()
|
|
| 321 |
setError('获取月度运势失败');
|
| 322 |
} finally {
|
| 323 |
setLoading(false);
|
| 324 |
-
setFetched(true);
|
| 325 |
}
|
| 326 |
};
|
| 327 |
|
|
@@ -376,7 +375,7 @@ const MonthlyFortuneCard: React.FC<{ profileId: string | null; onViewDetail: ()
|
|
| 376 |
<div className="space-y-2">
|
| 377 |
<p className="text-sm text-gray-500">{error}</p>
|
| 378 |
<button
|
| 379 |
-
onClick={() => {
|
| 380 |
className="w-full py-1.5 text-sm text-blue-600 hover:bg-blue-50 rounded-lg"
|
| 381 |
>
|
| 382 |
重试
|
|
@@ -405,12 +404,10 @@ const YearlyFortuneCard: React.FC<{ profileId: string | null; onViewDetail: () =
|
|
| 405 |
const [fortune, setFortune] = useState<any>(null);
|
| 406 |
const [loading, setLoading] = useState(false);
|
| 407 |
const [error, setError] = useState<string | null>(null);
|
| 408 |
-
const [fetched, setFetched] = useState(false);
|
| 409 |
const currentYear = new Date().getFullYear();
|
| 410 |
|
| 411 |
// Reset state when profileId changes (no auto-fetch)
|
| 412 |
useEffect(() => {
|
| 413 |
-
setFetched(false);
|
| 414 |
setFortune(null);
|
| 415 |
setError(null);
|
| 416 |
}, [profileId]);
|
|
@@ -432,7 +429,6 @@ const YearlyFortuneCard: React.FC<{ profileId: string | null; onViewDetail: () =
|
|
| 432 |
setError('获取年度运势失败');
|
| 433 |
} finally {
|
| 434 |
setLoading(false);
|
| 435 |
-
setFetched(true);
|
| 436 |
}
|
| 437 |
};
|
| 438 |
|
|
@@ -504,7 +500,7 @@ const YearlyFortuneCard: React.FC<{ profileId: string | null; onViewDetail: () =
|
|
| 504 |
<div className="space-y-2">
|
| 505 |
<p className="text-sm text-gray-500">{error}</p>
|
| 506 |
<button
|
| 507 |
-
onClick={() => {
|
| 508 |
className="w-full py-1.5 text-sm text-amber-600 hover:bg-amber-50 rounded-lg"
|
| 509 |
>
|
| 510 |
重试
|
|
@@ -529,7 +525,7 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
|
|
| 529 |
isLoggedIn,
|
| 530 |
userInfo,
|
| 531 |
onLogin,
|
| 532 |
-
onLogout,
|
| 533 |
onGenerate,
|
| 534 |
isAnalysisPanelOpen = false,
|
| 535 |
}) => {
|
|
|
|
| 44 |
return (
|
| 45 |
<div className="bg-gradient-to-br from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-4">
|
| 46 |
<div className="flex items-center gap-3 mb-3">
|
| 47 |
+
<div className="w-10 h-10 bg-indigo-100 rounded-full flex items-center justify-center flex-shrink-0">
|
| 48 |
<User className="w-5 h-5 text-indigo-400" />
|
| 49 |
</div>
|
| 50 |
+
<div className="flex-1 min-w-0">
|
| 51 |
+
<h3 className="font-bold text-gray-800 text-sm sm:text-base truncate">创建八字档案</h3>
|
| 52 |
+
<p className="text-xs text-gray-500 truncate">保存八字以查看运势分析</p>
|
| 53 |
</div>
|
| 54 |
</div>
|
| 55 |
<button
|
|
|
|
| 66 |
return (
|
| 67 |
<div className="bg-gradient-to-br from-gray-50 to-slate-50 border border-gray-200 rounded-xl p-4">
|
| 68 |
<div className="flex items-center gap-3 mb-3">
|
| 69 |
+
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
|
| 70 |
<User className="w-5 h-5 text-gray-400" />
|
| 71 |
</div>
|
| 72 |
+
<div className="flex-1 min-w-0">
|
| 73 |
+
<h3 className="font-bold text-gray-800 text-sm sm:text-base truncate">选择档案</h3>
|
| 74 |
+
<p className="text-xs text-gray-500 truncate">请选择要查看运势的档案</p>
|
| 75 |
</div>
|
| 76 |
</div>
|
| 77 |
<div className="relative">
|
|
|
|
| 79 |
onClick={() => setShowDropdown(!showDropdown)}
|
| 80 |
className="w-full py-2 px-3 bg-white border border-gray-200 rounded-lg text-sm text-left flex items-center justify-between hover:border-indigo-300 transition-colors"
|
| 81 |
>
|
| 82 |
+
<span className="text-gray-500 truncate">选择档案...</span>
|
| 83 |
+
<ChevronRight className={`w-4 h-4 text-gray-400 transition-transform flex-shrink-0 ${showDropdown ? 'rotate-90' : ''}`} />
|
| 84 |
</button>
|
| 85 |
{showDropdown && (
|
| 86 |
+
<div className="absolute z-20 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg py-1 max-h-60 overflow-y-auto">
|
| 87 |
{profiles.map((p) => (
|
| 88 |
<button
|
| 89 |
key={p.id}
|
|
|
|
| 93 |
}}
|
| 94 |
className="w-full px-3 py-2 text-left text-sm hover:bg-indigo-50 transition-colors"
|
| 95 |
>
|
| 96 |
+
<div className="font-medium text-gray-800 truncate">{p.name}</div>
|
| 97 |
+
<div className="text-xs text-gray-500 truncate">
|
| 98 |
{p.yearPillar} {p.monthPillar} {p.dayPillar} {p.hourPillar}
|
| 99 |
</div>
|
| 100 |
</button>
|
|
|
|
| 108 |
|
| 109 |
return (
|
| 110 |
<div className="bg-gradient-to-br from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-4">
|
| 111 |
+
<div className="flex items-center justify-between gap-2 mb-3">
|
| 112 |
+
<div className="flex items-center gap-3 flex-1 min-w-0">
|
| 113 |
+
<div className="w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center flex-shrink-0">
|
| 114 |
<User className="w-5 h-5 text-white" />
|
| 115 |
</div>
|
| 116 |
+
<div className="flex-1 min-w-0">
|
| 117 |
+
<h3 className="font-bold text-gray-800 text-sm sm:text-base truncate">{profile.name}</h3>
|
| 118 |
+
<p className="text-xs text-gray-500 truncate">当前查看档案</p>
|
| 119 |
</div>
|
| 120 |
</div>
|
| 121 |
+
<div className="relative flex-shrink-0">
|
| 122 |
<button
|
| 123 |
onClick={() => setShowDropdown(!showDropdown)}
|
| 124 |
className="p-2 hover:bg-white/50 rounded-lg transition-colors"
|
|
|
|
| 127 |
<ChevronRight className={`w-4 h-4 text-gray-500 transition-transform ${showDropdown ? 'rotate-90' : ''}`} />
|
| 128 |
</button>
|
| 129 |
{showDropdown && profiles.length > 1 && (
|
| 130 |
+
<div className="absolute z-20 right-0 w-48 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg py-1 max-h-60 overflow-y-auto">
|
| 131 |
{profiles.filter(p => p.id !== profile.id).map((p) => (
|
| 132 |
<button
|
| 133 |
key={p.id}
|
|
|
|
| 137 |
}}
|
| 138 |
className="w-full px-3 py-2 text-left text-sm hover:bg-indigo-50 transition-colors"
|
| 139 |
>
|
| 140 |
+
<div className="font-medium text-gray-800 truncate">{p.name}</div>
|
| 141 |
<div className="text-xs text-gray-500 truncate">
|
| 142 |
{p.yearPillar} {p.monthPillar} {p.dayPillar} {p.hourPillar}
|
| 143 |
</div>
|
|
|
|
| 149 |
</div>
|
| 150 |
|
| 151 |
{/* 四柱显示 */}
|
| 152 |
+
<div className="grid grid-cols-4 gap-1.5 sm:gap-2 mb-3">
|
| 153 |
+
<div className="text-center bg-white/60 rounded-lg py-1.5 sm:py-2 px-1">
|
| 154 |
+
<div className="text-xs text-gray-500 mb-0.5 truncate">年柱</div>
|
| 155 |
+
<div className="font-bold text-xs sm:text-sm text-indigo-700 font-serif-sc truncate">{profile.yearPillar}</div>
|
| 156 |
</div>
|
| 157 |
+
<div className="text-center bg-white/60 rounded-lg py-1.5 sm:py-2 px-1">
|
| 158 |
+
<div className="text-xs text-gray-500 mb-0.5 truncate">月柱</div>
|
| 159 |
+
<div className="font-bold text-xs sm:text-sm text-indigo-700 font-serif-sc truncate">{profile.monthPillar}</div>
|
| 160 |
</div>
|
| 161 |
+
<div className="text-center bg-white/60 rounded-lg py-1.5 sm:py-2 px-1">
|
| 162 |
+
<div className="text-xs text-gray-500 mb-0.5 truncate">日柱</div>
|
| 163 |
+
<div className="font-bold text-xs sm:text-sm text-indigo-700 font-serif-sc truncate">{profile.dayPillar}</div>
|
| 164 |
</div>
|
| 165 |
+
<div className="text-center bg-white/60 rounded-lg py-1.5 sm:py-2 px-1">
|
| 166 |
+
<div className="text-xs text-gray-500 mb-0.5 truncate">时柱</div>
|
| 167 |
+
<div className="font-bold text-xs sm:text-sm text-indigo-700 font-serif-sc truncate">{profile.hourPillar}</div>
|
| 168 |
</div>
|
| 169 |
</div>
|
| 170 |
|
|
|
|
| 198 |
return (
|
| 199 |
<div className="bg-gradient-to-br from-amber-50 to-orange-50 border border-amber-200 rounded-xl p-4">
|
| 200 |
<div className="flex items-start gap-3">
|
| 201 |
+
<div className="p-2 bg-amber-100 rounded-lg flex-shrink-0">
|
| 202 |
<AlertCircle className="w-5 h-5 text-amber-600" />
|
| 203 |
</div>
|
| 204 |
+
<div className="flex-1 min-w-0">
|
| 205 |
<h4 className="font-semibold text-gray-800 text-sm mb-1">保存当前命盘</h4>
|
| 206 |
<p className="text-xs text-gray-600 mb-3">
|
| 207 |
检测到您刚完成一次测算,是否将此八字保存为档案以便查看运势?
|
| 208 |
</p>
|
| 209 |
+
<div className="text-xs text-gray-500 mb-3 bg-white/50 rounded-lg p-2 overflow-x-auto">
|
| 210 |
<span className="font-medium">八字:</span>
|
| 211 |
+
<span className="whitespace-nowrap">
|
| 212 |
+
{currentBazi.yearPillar} {currentBazi.monthPillar} {currentBazi.dayPillar} {currentBazi.hourPillar}
|
| 213 |
+
</span>
|
| 214 |
</div>
|
| 215 |
<div className="flex gap-2">
|
| 216 |
<button
|
| 217 |
onClick={() => setShowInput(true)}
|
| 218 |
className="flex-1 py-2 bg-amber-500 hover:bg-amber-600 text-white text-sm font-medium rounded-lg flex items-center justify-center gap-1.5"
|
| 219 |
>
|
| 220 |
+
<Save className="w-4 h-4 flex-shrink-0" />
|
| 221 |
+
<span className="truncate">保存档案</span>
|
| 222 |
</button>
|
| 223 |
<button
|
| 224 |
onClick={onDismiss}
|
| 225 |
+
className="px-3 py-2 text-gray-500 hover:text-gray-700 text-sm flex-shrink-0"
|
| 226 |
>
|
| 227 |
暂不
|
| 228 |
</button>
|
|
|
|
| 237 |
<div className="bg-gradient-to-br from-amber-50 to-orange-50 border border-amber-200 rounded-xl p-4">
|
| 238 |
<div className="flex items-center justify-between mb-3">
|
| 239 |
<h4 className="font-semibold text-gray-800 text-sm">为此命盘取个名字</h4>
|
| 240 |
+
<button onClick={() => setShowInput(false)} className="p-1 hover:bg-amber-100 rounded flex-shrink-0">
|
| 241 |
<X className="w-4 h-4 text-gray-500" />
|
| 242 |
</button>
|
| 243 |
</div>
|
|
|
|
| 254 |
disabled={!profileName.trim() || saving}
|
| 255 |
className="w-full py-2 bg-amber-500 hover:bg-amber-600 disabled:bg-gray-300 text-white text-sm font-medium rounded-lg flex items-center justify-center gap-1.5"
|
| 256 |
>
|
| 257 |
+
{saving ? '保存中...' : <><Check className="w-4 h-4 flex-shrink-0" />确认保存</>}
|
| 258 |
</button>
|
| 259 |
</div>
|
| 260 |
);
|
|
|
|
| 291 |
const [fortune, setFortune] = useState<any>(null);
|
| 292 |
const [loading, setLoading] = useState(false);
|
| 293 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
| 294 |
|
| 295 |
const currentYear = new Date().getFullYear();
|
| 296 |
const currentMonth = new Date().getMonth() + 1;
|
|
|
|
| 298 |
|
| 299 |
// Reset state when profileId changes (no auto-fetch)
|
| 300 |
useEffect(() => {
|
|
|
|
| 301 |
setFortune(null);
|
| 302 |
setError(null);
|
| 303 |
}, [profileId]);
|
|
|
|
| 321 |
setError('获取月度运势失败');
|
| 322 |
} finally {
|
| 323 |
setLoading(false);
|
|
|
|
| 324 |
}
|
| 325 |
};
|
| 326 |
|
|
|
|
| 375 |
<div className="space-y-2">
|
| 376 |
<p className="text-sm text-gray-500">{error}</p>
|
| 377 |
<button
|
| 378 |
+
onClick={() => { fetchMonthlyFortune(); }}
|
| 379 |
className="w-full py-1.5 text-sm text-blue-600 hover:bg-blue-50 rounded-lg"
|
| 380 |
>
|
| 381 |
重试
|
|
|
|
| 404 |
const [fortune, setFortune] = useState<any>(null);
|
| 405 |
const [loading, setLoading] = useState(false);
|
| 406 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
| 407 |
const currentYear = new Date().getFullYear();
|
| 408 |
|
| 409 |
// Reset state when profileId changes (no auto-fetch)
|
| 410 |
useEffect(() => {
|
|
|
|
| 411 |
setFortune(null);
|
| 412 |
setError(null);
|
| 413 |
}, [profileId]);
|
|
|
|
| 429 |
setError('获取年度运势失败');
|
| 430 |
} finally {
|
| 431 |
setLoading(false);
|
|
|
|
| 432 |
}
|
| 433 |
};
|
| 434 |
|
|
|
|
| 500 |
<div className="space-y-2">
|
| 501 |
<p className="text-sm text-gray-500">{error}</p>
|
| 502 |
<button
|
| 503 |
+
onClick={() => { fetchYearlyFortune(); }}
|
| 504 |
className="w-full py-1.5 text-sm text-amber-600 hover:bg-amber-50 rounded-lg"
|
| 505 |
>
|
| 506 |
重试
|
|
|
|
| 525 |
isLoggedIn,
|
| 526 |
userInfo,
|
| 527 |
onLogin,
|
| 528 |
+
onLogout: _onLogout,
|
| 529 |
onGenerate,
|
| 530 |
isAnalysisPanelOpen = false,
|
| 531 |
}) => {
|
components/profile/ProfileCard.tsx
CHANGED
|
@@ -1,18 +1,22 @@
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
-
import { Star, Edit, Trash2, Check, Calendar, MapPin, User as UserIcon, TrendingUp } from 'lucide-react';
|
| 3 |
import { UserInput, Gender } from '../../types';
|
| 4 |
|
|
|
|
|
|
|
| 5 |
interface ProfileCardProps {
|
| 6 |
profile: UserInput & {
|
| 7 |
id: string;
|
| 8 |
isDefault: boolean;
|
| 9 |
createdAt: string;
|
|
|
|
| 10 |
};
|
| 11 |
isCurrent?: boolean;
|
| 12 |
onEdit: () => void;
|
| 13 |
onDelete: () => void;
|
| 14 |
onSetDefault: () => void;
|
| 15 |
onSelect: () => void;
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
const ProfileCard: React.FC<ProfileCardProps> = ({
|
|
@@ -21,7 +25,8 @@ const ProfileCard: React.FC<ProfileCardProps> = ({
|
|
| 21 |
onEdit,
|
| 22 |
onDelete,
|
| 23 |
onSetDefault,
|
| 24 |
-
onSelect
|
|
|
|
| 25 |
}) => {
|
| 26 |
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
| 27 |
|
|
@@ -38,6 +43,41 @@ const ProfileCard: React.FC<ProfileCardProps> = ({
|
|
| 38 |
});
|
| 39 |
};
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
const handleDelete = () => {
|
| 42 |
onDelete();
|
| 43 |
setShowDeleteConfirm(false);
|
|
@@ -76,13 +116,18 @@ const ProfileCard: React.FC<ProfileCardProps> = ({
|
|
| 76 |
<div className={`bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow p-5 ${
|
| 77 |
isCurrent ? 'ring-2 ring-blue-500' : ''
|
| 78 |
}`}>
|
| 79 |
-
{/* Default Badge */}
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
<
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
</div>
|
| 85 |
-
|
| 86 |
|
| 87 |
{/* Profile Info */}
|
| 88 |
<div className="space-y-3 mb-4">
|
|
@@ -152,6 +197,17 @@ const ProfileCard: React.FC<ProfileCardProps> = ({
|
|
| 152 |
|
| 153 |
{/* Action Buttons */}
|
| 154 |
<div className="space-y-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
<button
|
| 156 |
onClick={onSelect}
|
| 157 |
className={`w-full py-2 px-3 rounded-lg text-sm font-medium transition-colors ${
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
+
import { Star, Edit, Trash2, Check, Calendar, MapPin, User as UserIcon, TrendingUp, AlertCircle, Loader2, CheckCircle, RefreshCw } from 'lucide-react';
|
| 3 |
import { UserInput, Gender } from '../../types';
|
| 4 |
|
| 5 |
+
type CoreDocumentStatus = 'pending' | 'generating' | 'ready' | 'failed';
|
| 6 |
+
|
| 7 |
interface ProfileCardProps {
|
| 8 |
profile: UserInput & {
|
| 9 |
id: string;
|
| 10 |
isDefault: boolean;
|
| 11 |
createdAt: string;
|
| 12 |
+
coreDocumentStatus?: CoreDocumentStatus;
|
| 13 |
};
|
| 14 |
isCurrent?: boolean;
|
| 15 |
onEdit: () => void;
|
| 16 |
onDelete: () => void;
|
| 17 |
onSetDefault: () => void;
|
| 18 |
onSelect: () => void;
|
| 19 |
+
onRegenerateCore: () => void;
|
| 20 |
}
|
| 21 |
|
| 22 |
const ProfileCard: React.FC<ProfileCardProps> = ({
|
|
|
|
| 25 |
onEdit,
|
| 26 |
onDelete,
|
| 27 |
onSetDefault,
|
| 28 |
+
onSelect,
|
| 29 |
+
onRegenerateCore
|
| 30 |
}) => {
|
| 31 |
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
| 32 |
|
|
|
|
| 43 |
});
|
| 44 |
};
|
| 45 |
|
| 46 |
+
const getCoreDocumentBadge = () => {
|
| 47 |
+
const status = profile.coreDocumentStatus || 'pending';
|
| 48 |
+
|
| 49 |
+
switch (status) {
|
| 50 |
+
case 'pending':
|
| 51 |
+
return (
|
| 52 |
+
<div className="flex items-center space-x-1 text-xs text-gray-500">
|
| 53 |
+
<div className="w-2 h-2 rounded-full bg-gray-400"></div>
|
| 54 |
+
<span>待生成</span>
|
| 55 |
+
</div>
|
| 56 |
+
);
|
| 57 |
+
case 'generating':
|
| 58 |
+
return (
|
| 59 |
+
<div className="flex items-center space-x-1 text-xs text-blue-600">
|
| 60 |
+
<Loader2 className="w-3 h-3 animate-spin" />
|
| 61 |
+
<span>生成中</span>
|
| 62 |
+
</div>
|
| 63 |
+
);
|
| 64 |
+
case 'ready':
|
| 65 |
+
return (
|
| 66 |
+
<div className="flex items-center space-x-1 text-xs text-green-600">
|
| 67 |
+
<CheckCircle className="w-3 h-3" />
|
| 68 |
+
<span>已就绪</span>
|
| 69 |
+
</div>
|
| 70 |
+
);
|
| 71 |
+
case 'failed':
|
| 72 |
+
return (
|
| 73 |
+
<div className="flex items-center space-x-1 text-xs text-red-600">
|
| 74 |
+
<AlertCircle className="w-3 h-3" />
|
| 75 |
+
<span>生成失败</span>
|
| 76 |
+
</div>
|
| 77 |
+
);
|
| 78 |
+
}
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
const handleDelete = () => {
|
| 82 |
onDelete();
|
| 83 |
setShowDeleteConfirm(false);
|
|
|
|
| 116 |
<div className={`bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow p-5 ${
|
| 117 |
isCurrent ? 'ring-2 ring-blue-500' : ''
|
| 118 |
}`}>
|
| 119 |
+
{/* Header with Default Badge and Core Document Status */}
|
| 120 |
+
<div className="flex items-center justify-between mb-3">
|
| 121 |
+
{profile.isDefault && (
|
| 122 |
+
<div className="flex items-center space-x-1 text-amber-600 text-xs font-medium">
|
| 123 |
+
<Star className="w-3 h-3 fill-current" />
|
| 124 |
+
<span>Default Profile</span>
|
| 125 |
+
</div>
|
| 126 |
+
)}
|
| 127 |
+
<div className={profile.isDefault ? '' : 'ml-auto'}>
|
| 128 |
+
{getCoreDocumentBadge()}
|
| 129 |
</div>
|
| 130 |
+
</div>
|
| 131 |
|
| 132 |
{/* Profile Info */}
|
| 133 |
<div className="space-y-3 mb-4">
|
|
|
|
| 197 |
|
| 198 |
{/* Action Buttons */}
|
| 199 |
<div className="space-y-2">
|
| 200 |
+
{/* Regenerate button for failed status */}
|
| 201 |
+
{profile.coreDocumentStatus === 'failed' && (
|
| 202 |
+
<button
|
| 203 |
+
onClick={onRegenerateCore}
|
| 204 |
+
className="w-full py-2 px-3 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors text-sm font-medium flex items-center justify-center"
|
| 205 |
+
>
|
| 206 |
+
<RefreshCw className="w-4 h-4 mr-1" />
|
| 207 |
+
重新生成核心文档
|
| 208 |
+
</button>
|
| 209 |
+
)}
|
| 210 |
+
|
| 211 |
<button
|
| 212 |
onClick={onSelect}
|
| 213 |
className={`w-full py-2 px-3 rounded-lg text-sm font-medium transition-colors ${
|
components/profile/ProfileManager.tsx
CHANGED
|
@@ -4,10 +4,13 @@ import ProfileCard from './ProfileCard';
|
|
| 4 |
import CreateProfileModal from './CreateProfileModal';
|
| 5 |
import { UserInput, Gender } from '../../types';
|
| 6 |
|
|
|
|
|
|
|
| 7 |
interface UserProfile extends UserInput {
|
| 8 |
id: string;
|
| 9 |
isDefault: boolean;
|
| 10 |
createdAt: string;
|
|
|
|
| 11 |
}
|
| 12 |
|
| 13 |
interface ProfileManagerProps {
|
|
@@ -25,30 +28,51 @@ const ProfileManager: React.FC<ProfileManagerProps> = ({
|
|
| 25 |
const [editingProfile, setEditingProfile] = useState<UserProfile | null>(null);
|
| 26 |
const [error, setError] = useState<string | null>(null);
|
| 27 |
|
| 28 |
-
|
|
|
|
|
|
|
| 29 |
useEffect(() => {
|
| 30 |
const loadProfiles = async () => {
|
|
|
|
| 31 |
try {
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
if (
|
| 35 |
-
const
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
-
|
| 42 |
-
// TODO: Load from API when backend is ready
|
| 43 |
-
// const response = await fetch('/api/profiles');
|
| 44 |
-
// const data = await response.json();
|
| 45 |
-
// setProfiles(data);
|
| 46 |
} catch (err) {
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
|
|
|
| 52 |
};
|
| 53 |
|
| 54 |
loadProfiles();
|
|
@@ -111,14 +135,36 @@ const ProfileManager: React.FC<ProfileManagerProps> = ({
|
|
| 111 |
return;
|
| 112 |
}
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
if (profileToDelete?.isDefault && updatedProfiles.length > 0) {
|
| 118 |
updatedProfiles[0].isDefault = true;
|
| 119 |
}
|
| 120 |
-
|
| 121 |
-
|
| 122 |
};
|
| 123 |
|
| 124 |
const handleSetDefault = async (profileId: string) => {
|
|
@@ -130,6 +176,38 @@ const ProfileManager: React.FC<ProfileManagerProps> = ({
|
|
| 130 |
await saveProfiles(updatedProfiles);
|
| 131 |
};
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
const openEditModal = (profile: UserProfile) => {
|
| 134 |
setEditingProfile(profile);
|
| 135 |
};
|
|
@@ -202,6 +280,7 @@ const ProfileManager: React.FC<ProfileManagerProps> = ({
|
|
| 202 |
onDelete={() => handleDeleteProfile(profile.id)}
|
| 203 |
onSetDefault={() => handleSetDefault(profile.id)}
|
| 204 |
onSelect={() => onProfileSelect?.(profile)}
|
|
|
|
| 205 |
/>
|
| 206 |
))}
|
| 207 |
</div>
|
|
|
|
| 4 |
import CreateProfileModal from './CreateProfileModal';
|
| 5 |
import { UserInput, Gender } from '../../types';
|
| 6 |
|
| 7 |
+
type CoreDocumentStatus = 'pending' | 'generating' | 'ready' | 'failed';
|
| 8 |
+
|
| 9 |
interface UserProfile extends UserInput {
|
| 10 |
id: string;
|
| 11 |
isDefault: boolean;
|
| 12 |
createdAt: string;
|
| 13 |
+
coreDocumentStatus?: CoreDocumentStatus;
|
| 14 |
}
|
| 15 |
|
| 16 |
interface ProfileManagerProps {
|
|
|
|
| 28 |
const [editingProfile, setEditingProfile] = useState<UserProfile | null>(null);
|
| 29 |
const [error, setError] = useState<string | null>(null);
|
| 30 |
|
| 31 |
+
const PROFILES_STORAGE_KEY = 'lifekline_profiles';
|
| 32 |
+
|
| 33 |
+
// Load profiles from API first, with localStorage as fallback
|
| 34 |
useEffect(() => {
|
| 35 |
const loadProfiles = async () => {
|
| 36 |
+
setIsLoading(true);
|
| 37 |
try {
|
| 38 |
+
// Try API first
|
| 39 |
+
const response = await fetch('/api/profiles', { credentials: 'include' });
|
| 40 |
+
if (response.ok) {
|
| 41 |
+
const data = await response.json();
|
| 42 |
+
const apiProfiles: UserProfile[] = data.profiles.map((p: any) => ({
|
| 43 |
+
id: p.id,
|
| 44 |
+
name: p.name,
|
| 45 |
+
gender: p.gender,
|
| 46 |
+
birthYear: p.birthYear?.toString() || '',
|
| 47 |
+
yearPillar: p.yearPillar || '',
|
| 48 |
+
monthPillar: p.monthPillar || '',
|
| 49 |
+
dayPillar: p.dayPillar || '',
|
| 50 |
+
hourPillar: p.hourPillar || '',
|
| 51 |
+
startAge: p.startAge?.toString() || '',
|
| 52 |
+
firstDaYun: p.firstDaYun || '',
|
| 53 |
+
birthPlace: p.birthPlace || '',
|
| 54 |
+
isDefault: p.isDefault || false,
|
| 55 |
+
coreDocumentStatus: p.coreDocumentStatus || 'pending',
|
| 56 |
+
createdAt: p.createdAt,
|
| 57 |
+
}));
|
| 58 |
+
setProfiles(apiProfiles);
|
| 59 |
+
// Sync to localStorage as backup
|
| 60 |
+
localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(apiProfiles));
|
| 61 |
+
setIsLoading(false);
|
| 62 |
+
return;
|
| 63 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
} catch (err) {
|
| 65 |
+
console.error('API fetch failed, using localStorage:', err);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// Fallback to localStorage
|
| 69 |
+
if (typeof window !== 'undefined') {
|
| 70 |
+
const saved = localStorage.getItem(PROFILES_STORAGE_KEY);
|
| 71 |
+
if (saved) {
|
| 72 |
+
setProfiles(JSON.parse(saved));
|
| 73 |
+
}
|
| 74 |
}
|
| 75 |
+
setIsLoading(false);
|
| 76 |
};
|
| 77 |
|
| 78 |
loadProfiles();
|
|
|
|
| 135 |
return;
|
| 136 |
}
|
| 137 |
|
| 138 |
+
try {
|
| 139 |
+
const response = await fetch(`/api/profiles/${profileId}`, {
|
| 140 |
+
method: 'DELETE',
|
| 141 |
+
credentials: 'include',
|
| 142 |
+
});
|
| 143 |
+
|
| 144 |
+
if (response.ok) {
|
| 145 |
+
// If deleting default profile, make another one default
|
| 146 |
+
let updatedProfiles = profiles.filter(p => p.id !== profileId);
|
| 147 |
+
if (profileToDelete?.isDefault && updatedProfiles.length > 0) {
|
| 148 |
+
updatedProfiles[0].isDefault = true;
|
| 149 |
+
}
|
| 150 |
|
| 151 |
+
// Remove from local state
|
| 152 |
+
setProfiles(updatedProfiles);
|
| 153 |
+
// Update localStorage
|
| 154 |
+
localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(updatedProfiles));
|
| 155 |
+
return;
|
| 156 |
+
}
|
| 157 |
+
} catch (err) {
|
| 158 |
+
console.error('API delete failed, using localStorage only:', err);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// Fallback to localStorage only delete
|
| 162 |
+
let updatedProfiles = profiles.filter(p => p.id !== profileId);
|
| 163 |
if (profileToDelete?.isDefault && updatedProfiles.length > 0) {
|
| 164 |
updatedProfiles[0].isDefault = true;
|
| 165 |
}
|
| 166 |
+
setProfiles(updatedProfiles);
|
| 167 |
+
localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(updatedProfiles));
|
| 168 |
};
|
| 169 |
|
| 170 |
const handleSetDefault = async (profileId: string) => {
|
|
|
|
| 176 |
await saveProfiles(updatedProfiles);
|
| 177 |
};
|
| 178 |
|
| 179 |
+
const handleRegenerateCore = async (profileId: string) => {
|
| 180 |
+
try {
|
| 181 |
+
// Update status to generating immediately
|
| 182 |
+
setProfiles(prev => prev.map(p =>
|
| 183 |
+
p.id === profileId ? { ...p, coreDocumentStatus: 'generating' as CoreDocumentStatus } : p
|
| 184 |
+
));
|
| 185 |
+
|
| 186 |
+
const response = await fetch(`/api/profiles/${profileId}/regenerate`, {
|
| 187 |
+
method: 'POST',
|
| 188 |
+
credentials: 'include',
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
if (response.ok) {
|
| 192 |
+
const data = await response.json();
|
| 193 |
+
// Update with response data
|
| 194 |
+
setProfiles(prev => prev.map(p =>
|
| 195 |
+
p.id === profileId ? { ...p, coreDocumentStatus: data.coreDocumentStatus || 'generating' } : p
|
| 196 |
+
));
|
| 197 |
+
} else {
|
| 198 |
+
// On error, set to failed
|
| 199 |
+
setProfiles(prev => prev.map(p =>
|
| 200 |
+
p.id === profileId ? { ...p, coreDocumentStatus: 'failed' as CoreDocumentStatus } : p
|
| 201 |
+
));
|
| 202 |
+
}
|
| 203 |
+
} catch (err) {
|
| 204 |
+
console.error('Failed to regenerate core document:', err);
|
| 205 |
+
setProfiles(prev => prev.map(p =>
|
| 206 |
+
p.id === profileId ? { ...p, coreDocumentStatus: 'failed' as CoreDocumentStatus } : p
|
| 207 |
+
));
|
| 208 |
+
}
|
| 209 |
+
};
|
| 210 |
+
|
| 211 |
const openEditModal = (profile: UserProfile) => {
|
| 212 |
setEditingProfile(profile);
|
| 213 |
};
|
|
|
|
| 280 |
onDelete={() => handleDeleteProfile(profile.id)}
|
| 281 |
onSetDefault={() => handleSetDefault(profile.id)}
|
| 282 |
onSelect={() => onProfileSelect?.(profile)}
|
| 283 |
+
onRegenerateCore={() => handleRegenerateCore(profile.id)}
|
| 284 |
/>
|
| 285 |
))}
|
| 286 |
</div>
|
index.css
CHANGED
|
@@ -3,3 +3,17 @@
|
|
| 3 |
@tailwind base;
|
| 4 |
@tailwind components;
|
| 5 |
@tailwind utilities;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
@tailwind base;
|
| 4 |
@tailwind components;
|
| 5 |
@tailwind utilities;
|
| 6 |
+
|
| 7 |
+
/* Custom utility classes */
|
| 8 |
+
@layer utilities {
|
| 9 |
+
/* Hide scrollbar for Chrome, Safari and Opera */
|
| 10 |
+
.scrollbar-hide::-webkit-scrollbar {
|
| 11 |
+
display: none;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/* Hide scrollbar for IE, Edge and Firefox */
|
| 15 |
+
.scrollbar-hide {
|
| 16 |
+
-ms-overflow-style: none; /* IE and Edge */
|
| 17 |
+
scrollbar-width: none; /* Firefox */
|
| 18 |
+
}
|
| 19 |
+
}
|
package-lock.json
CHANGED
|
@@ -20,7 +20,9 @@
|
|
| 20 |
"lucide-react": "^0.561.0",
|
| 21 |
"lunar-javascript": "^1.7.7",
|
| 22 |
"nanoid": "^5.0.7",
|
|
|
|
| 23 |
"node-fetch": "^3.3.2",
|
|
|
|
| 24 |
"qrcode.react": "^4.2.0",
|
| 25 |
"react": "^19.0.0",
|
| 26 |
"react-dom": "^19.0.0",
|
|
@@ -3458,6 +3460,18 @@
|
|
| 3458 |
"node": ">=10"
|
| 3459 |
}
|
| 3460 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3461 |
"node_modules/node-domexception": {
|
| 3462 |
"version": "1.0.0",
|
| 3463 |
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
|
@@ -3503,6 +3517,15 @@
|
|
| 3503 |
"dev": true,
|
| 3504 |
"license": "MIT"
|
| 3505 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3506 |
"node_modules/normalize-path": {
|
| 3507 |
"version": "3.0.0",
|
| 3508 |
"resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
|
|
@@ -5134,6 +5157,15 @@
|
|
| 5134 |
"base64-arraybuffer": "^1.0.2"
|
| 5135 |
}
|
| 5136 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5137 |
"node_modules/vary": {
|
| 5138 |
"version": "1.1.2",
|
| 5139 |
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
|
|
|
| 20 |
"lucide-react": "^0.561.0",
|
| 21 |
"lunar-javascript": "^1.7.7",
|
| 22 |
"nanoid": "^5.0.7",
|
| 23 |
+
"node-cron": "^3.0.3",
|
| 24 |
"node-fetch": "^3.3.2",
|
| 25 |
+
"nodemailer": "^6.9.8",
|
| 26 |
"qrcode.react": "^4.2.0",
|
| 27 |
"react": "^19.0.0",
|
| 28 |
"react-dom": "^19.0.0",
|
|
|
|
| 3460 |
"node": ">=10"
|
| 3461 |
}
|
| 3462 |
},
|
| 3463 |
+
"node_modules/node-cron": {
|
| 3464 |
+
"version": "3.0.3",
|
| 3465 |
+
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
|
| 3466 |
+
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
|
| 3467 |
+
"license": "ISC",
|
| 3468 |
+
"dependencies": {
|
| 3469 |
+
"uuid": "8.3.2"
|
| 3470 |
+
},
|
| 3471 |
+
"engines": {
|
| 3472 |
+
"node": ">=6.0.0"
|
| 3473 |
+
}
|
| 3474 |
+
},
|
| 3475 |
"node_modules/node-domexception": {
|
| 3476 |
"version": "1.0.0",
|
| 3477 |
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
|
|
|
| 3517 |
"dev": true,
|
| 3518 |
"license": "MIT"
|
| 3519 |
},
|
| 3520 |
+
"node_modules/nodemailer": {
|
| 3521 |
+
"version": "6.10.1",
|
| 3522 |
+
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
| 3523 |
+
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
| 3524 |
+
"license": "MIT-0",
|
| 3525 |
+
"engines": {
|
| 3526 |
+
"node": ">=6.0.0"
|
| 3527 |
+
}
|
| 3528 |
+
},
|
| 3529 |
"node_modules/normalize-path": {
|
| 3530 |
"version": "3.0.0",
|
| 3531 |
"resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
|
|
|
|
| 5157 |
"base64-arraybuffer": "^1.0.2"
|
| 5158 |
}
|
| 5159 |
},
|
| 5160 |
+
"node_modules/uuid": {
|
| 5161 |
+
"version": "8.3.2",
|
| 5162 |
+
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
| 5163 |
+
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
| 5164 |
+
"license": "MIT",
|
| 5165 |
+
"bin": {
|
| 5166 |
+
"uuid": "dist/bin/uuid"
|
| 5167 |
+
}
|
| 5168 |
+
},
|
| 5169 |
"node_modules/vary": {
|
| 5170 |
"version": "1.1.2",
|
| 5171 |
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
package.json
CHANGED
|
@@ -28,7 +28,9 @@
|
|
| 28 |
"lucide-react": "^0.561.0",
|
| 29 |
"lunar-javascript": "^1.7.7",
|
| 30 |
"nanoid": "^5.0.7",
|
|
|
|
| 31 |
"node-fetch": "^3.3.2",
|
|
|
|
| 32 |
"qrcode.react": "^4.2.0",
|
| 33 |
"react": "^19.0.0",
|
| 34 |
"react-dom": "^19.0.0",
|
|
|
|
| 28 |
"lucide-react": "^0.561.0",
|
| 29 |
"lunar-javascript": "^1.7.7",
|
| 30 |
"nanoid": "^5.0.7",
|
| 31 |
+
"node-cron": "^3.0.3",
|
| 32 |
"node-fetch": "^3.3.2",
|
| 33 |
+
"nodemailer": "^6.9.8",
|
| 34 |
"qrcode.react": "^4.2.0",
|
| 35 |
"react": "^19.0.0",
|
| 36 |
"react-dom": "^19.0.0",
|
pages/DashboardPage.tsx
CHANGED
|
@@ -20,9 +20,11 @@ import {
|
|
| 20 |
LogOut,
|
| 21 |
Ticket,
|
| 22 |
Check,
|
| 23 |
-
X
|
|
|
|
| 24 |
} from 'lucide-react';
|
| 25 |
import ProfileManager from '../components/profile/ProfileManager';
|
|
|
|
| 26 |
import { UserInput, Gender, LifeDestinyResult, HistoryListItem, UserProfile } from '../types';
|
| 27 |
|
| 28 |
interface DashboardStats {
|
|
@@ -59,7 +61,7 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|
| 59 |
}) => {
|
| 60 |
const navigate = useNavigate();
|
| 61 |
const [searchParams] = useSearchParams();
|
| 62 |
-
const [activeTab, setActiveTab] = useState<'overview' | 'profiles' | 'fortune' | 'history'>('overview');
|
| 63 |
const [stats, setStats] = useState<DashboardStats>({
|
| 64 |
profileCount: 0,
|
| 65 |
totalAnalyses: 0,
|
|
@@ -82,7 +84,7 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|
| 82 |
// Handle tab from URL
|
| 83 |
useEffect(() => {
|
| 84 |
const tab = searchParams.get('tab');
|
| 85 |
-
if (tab && ['overview', 'profiles', 'fortune', 'history'].includes(tab)) {
|
| 86 |
setActiveTab(tab as any);
|
| 87 |
}
|
| 88 |
}, [searchParams]);
|
|
@@ -227,7 +229,7 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|
| 227 |
|
| 228 |
const formatDate = (dateStr: string) => {
|
| 229 |
const date = new Date(dateStr);
|
| 230 |
-
return date.toLocaleDateString('
|
| 231 |
month: 'short',
|
| 232 |
day: 'numeric',
|
| 233 |
year: 'numeric',
|
|
@@ -243,9 +245,14 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|
| 243 |
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
| 244 |
const diffDays = Math.floor(diffHours / 24);
|
| 245 |
|
| 246 |
-
if (diffDays > 0) return `${diffDays}
|
| 247 |
-
if (diffHours > 0) return `${diffHours}
|
| 248 |
-
return '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
};
|
| 250 |
|
| 251 |
if (!isLoggedIn) {
|
|
@@ -255,15 +262,15 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|
| 255 |
<div className="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
| 256 |
<User className="w-8 h-8 text-indigo-600" />
|
| 257 |
</div>
|
| 258 |
-
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
| 259 |
<p className="text-gray-600 mb-6">
|
| 260 |
-
|
| 261 |
</p>
|
| 262 |
<button
|
| 263 |
onClick={onLoginClick}
|
| 264 |
className="w-full py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white font-bold rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all"
|
| 265 |
>
|
| 266 |
-
|
| 267 |
</button>
|
| 268 |
</div>
|
| 269 |
</div>
|
|
@@ -274,37 +281,37 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|
| 274 |
<div className="min-h-screen bg-gray-50">
|
| 275 |
{/* Header */}
|
| 276 |
<div className="bg-white border-b border-gray-200">
|
| 277 |
-
<div className="max-w-4xl mx-auto px-6 py-6">
|
| 278 |
-
<div className="flex items-center justify-between mb-4">
|
| 279 |
-
<div>
|
| 280 |
-
<h1 className="text-3xl font-bold text-gray-900">
|
| 281 |
-
<p className="text-gray-600 mt-1">
|
| 282 |
</div>
|
| 283 |
<button
|
| 284 |
onClick={onLogout}
|
| 285 |
-
className="flex items-center space-x-2 px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
| 286 |
>
|
| 287 |
-
<LogOut className="w-5 h-5" />
|
| 288 |
-
<span>
|
| 289 |
</button>
|
| 290 |
</div>
|
| 291 |
|
| 292 |
{/* Points Balance with Animation */}
|
| 293 |
-
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 rounded-2xl p-6 text-white">
|
| 294 |
<div className="flex items-center justify-between">
|
| 295 |
-
<div>
|
| 296 |
<div className="flex items-center space-x-2 mb-2">
|
| 297 |
-
<Coins className="w-6 h-6" />
|
| 298 |
-
<span className="text-lg font-medium">积分余额</span>
|
| 299 |
</div>
|
| 300 |
-
<div className="text-4xl font-bold">
|
| 301 |
{animatedPoints.toLocaleString()}
|
| 302 |
</div>
|
| 303 |
-
<p className="text-indigo-100 mt-1">可用于分析测算</p>
|
| 304 |
</div>
|
| 305 |
-
<div className="text-right">
|
| 306 |
-
<div className="text-2xl mb-1">🎯</div>
|
| 307 |
-
<p className="text-sm text-indigo-100">等级 {Math.floor((userInfo?.points || 0) / 100) + 1}</p>
|
| 308 |
</div>
|
| 309 |
</div>
|
| 310 |
|
|
@@ -373,77 +380,78 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|
| 373 |
</div>
|
| 374 |
|
| 375 |
{/* Quick Actions */}
|
| 376 |
-
<div className="max-w-4xl mx-auto px-6 py-6">
|
| 377 |
-
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
| 378 |
<button
|
| 379 |
onClick={handleAddProfile}
|
| 380 |
-
className="bg-white p-4 rounded-xl shadow-sm hover:shadow-md transition-shadow text-center group"
|
| 381 |
>
|
| 382 |
-
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-2 group-hover:bg-blue-200 transition-colors">
|
| 383 |
-
<Plus className="w-6 h-6 text-blue-600" />
|
| 384 |
</div>
|
| 385 |
-
<span className="text-sm font-medium text-gray-700">
|
| 386 |
</button>
|
| 387 |
|
| 388 |
<button
|
| 389 |
onClick={handleViewHistory}
|
| 390 |
-
className="bg-white p-4 rounded-xl shadow-sm hover:shadow-md transition-shadow text-center group"
|
| 391 |
>
|
| 392 |
-
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-2 group-hover:bg-green-200 transition-colors">
|
| 393 |
-
<History className="w-6 h-6 text-green-600" />
|
| 394 |
</div>
|
| 395 |
-
<span className="text-sm font-medium text-gray-700">
|
| 396 |
</button>
|
| 397 |
|
| 398 |
<button
|
| 399 |
onClick={() => navigate('/knowledge')}
|
| 400 |
-
className="bg-white p-4 rounded-xl shadow-sm hover:shadow-md transition-shadow text-center group"
|
| 401 |
>
|
| 402 |
-
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-2 group-hover:bg-purple-200 transition-colors">
|
| 403 |
-
<TrendingUp className="w-6 h-6 text-purple-600" />
|
| 404 |
</div>
|
| 405 |
-
<span className="text-sm font-medium text-gray-700">
|
| 406 |
</button>
|
| 407 |
|
| 408 |
<button
|
| 409 |
onClick={() => navigate('/cases')}
|
| 410 |
-
className="bg-white p-4 rounded-xl shadow-sm hover:shadow-md transition-shadow text-center group"
|
| 411 |
>
|
| 412 |
-
<div className="w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-2 group-hover:bg-amber-200 transition-colors">
|
| 413 |
-
<Eye className="w-6 h-6 text-amber-600" />
|
| 414 |
</div>
|
| 415 |
-
<span className="text-sm font-medium text-gray-700">
|
| 416 |
</button>
|
| 417 |
</div>
|
| 418 |
</div>
|
| 419 |
|
| 420 |
{/* Tabs */}
|
| 421 |
-
<div className="max-w-4xl mx-auto px-6">
|
| 422 |
-
<div className="bg-white rounded-xl shadow-sm">
|
| 423 |
-
<div className="flex border-b border-gray-200">
|
| 424 |
{[
|
| 425 |
-
{ id: 'overview', label: '
|
| 426 |
-
{ id: 'profiles', label: '
|
| 427 |
-
{ id: 'fortune', label: '
|
| 428 |
-
{ id: 'history', label: '
|
|
|
|
| 429 |
].map(tab => (
|
| 430 |
<button
|
| 431 |
key={tab.id}
|
| 432 |
onClick={() => setActiveTab(tab.id as any)}
|
| 433 |
-
className={`flex-1 flex items-center justify-center space-x-2 py-4 px-6 font-medium transition-colors ${
|
| 434 |
activeTab === tab.id
|
| 435 |
? 'text-indigo-600 border-b-2 border-indigo-600 bg-indigo-50'
|
| 436 |
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
| 437 |
}`}
|
| 438 |
>
|
| 439 |
-
<tab.icon className="w-5 h-5" />
|
| 440 |
-
<span>{tab.label}</span>
|
| 441 |
</button>
|
| 442 |
))}
|
| 443 |
</div>
|
| 444 |
|
| 445 |
{/* Tab Content */}
|
| 446 |
-
<div className="p-6">
|
| 447 |
{isLoading ? (
|
| 448 |
<div className="flex items-center justify-center py-12">
|
| 449 |
<Loader2 className="w-8 h-8 text-indigo-600 animate-spin" />
|
|
@@ -455,7 +463,7 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|
| 455 |
onClick={() => window.location.reload()}
|
| 456 |
className="text-indigo-600 hover:text-indigo-800"
|
| 457 |
>
|
| 458 |
-
|
| 459 |
</button>
|
| 460 |
</div>
|
| 461 |
) : (
|
|
@@ -464,74 +472,74 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|
| 464 |
{activeTab === 'overview' && (
|
| 465 |
<div className="space-y-6">
|
| 466 |
{/* Stats Grid */}
|
| 467 |
-
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
| 468 |
-
<div className="bg-gray-50 p-4 rounded-lg">
|
| 469 |
-
<div className="text-2xl font-bold text-gray-900">{stats.profileCount}</div>
|
| 470 |
-
<div className="text-sm text-gray-600">
|
| 471 |
</div>
|
| 472 |
-
<div className="bg-gray-50 p-4 rounded-lg">
|
| 473 |
-
<div className="text-2xl font-bold text-gray-900">{stats.totalAnalyses}</div>
|
| 474 |
-
<div className="text-sm text-gray-600">
|
| 475 |
</div>
|
| 476 |
-
<div className="bg-gray-50 p-4 rounded-lg">
|
| 477 |
-
<div className="text-2xl font-bold text-gray-900">{stats.totalShares}</div>
|
| 478 |
-
<div className="text-sm text-gray-600">
|
| 479 |
</div>
|
| 480 |
-
<div className="bg-gray-50 p-4 rounded-lg">
|
| 481 |
-
<div className="text-2xl font-bold text-gray-900">{stats.pointsEarned}</div>
|
| 482 |
-
<div className="text-sm text-gray-600">
|
| 483 |
</div>
|
| 484 |
</div>
|
| 485 |
|
| 486 |
{/* Recent Activity */}
|
| 487 |
-
<div className="grid md:grid-cols-2
|
| 488 |
{/* Recent Analyses */}
|
| 489 |
<div>
|
| 490 |
-
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
| 491 |
<div className="space-y-2">
|
| 492 |
{recentAnalyses.length > 0 ? (
|
| 493 |
recentAnalyses.slice(0, 3).map(analysis => (
|
| 494 |
-
<div key={analysis.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
| 495 |
-
<div className="flex-1">
|
| 496 |
-
<div className="font-medium text-gray-900 truncate">{analysis.summary}</div>
|
| 497 |
-
<div className="text-sm text-gray-500">{formatTime(analysis.createdAt)}</div>
|
| 498 |
</div>
|
| 499 |
-
<ChevronRight className="w-4 h-4 text-gray-400" />
|
| 500 |
</div>
|
| 501 |
))
|
| 502 |
) : (
|
| 503 |
-
<p className="text-gray-500 text-center py-4">
|
| 504 |
)}
|
| 505 |
</div>
|
| 506 |
</div>
|
| 507 |
|
| 508 |
{/* Recent Rewards */}
|
| 509 |
<div>
|
| 510 |
-
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
| 511 |
<div className="space-y-2">
|
| 512 |
{shareRewards.length > 0 ? (
|
| 513 |
shareRewards.slice(0, 3).map(reward => (
|
| 514 |
-
<div key={reward.id} className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
| 515 |
-
<div className="flex-1">
|
| 516 |
-
<div className="font-medium text-gray-900">
|
| 517 |
-
<div className="text-sm text-gray-500">{formatTime(reward.createdAt)}</div>
|
| 518 |
</div>
|
| 519 |
-
<span className="text-green-600 font-medium">+{reward.points}</span>
|
| 520 |
</div>
|
| 521 |
))
|
| 522 |
) : (
|
| 523 |
-
<p className="text-gray-500 text-center py-4">
|
| 524 |
)}
|
| 525 |
</div>
|
| 526 |
</div>
|
| 527 |
</div>
|
| 528 |
|
| 529 |
{/* Fortune Preview */}
|
| 530 |
-
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 p-6 rounded-xl">
|
| 531 |
-
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
| 532 |
-
<div className="text-gray-700">
|
| 533 |
<p className="mb-2">🌟 Your luck is looking positive today! Consider making important decisions.</p>
|
| 534 |
-
<p className="text-sm text-gray-600">Lucky numbers: 7, 14, 21 | Lucky color: Blue</p>
|
| 535 |
</div>
|
| 536 |
</div>
|
| 537 |
</div>
|
|
@@ -546,33 +554,33 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|
| 546 |
{activeTab === 'fortune' && (
|
| 547 |
<div className="space-y-6">
|
| 548 |
<div className="text-center py-12">
|
| 549 |
-
<div className="text-6xl mb-4">🔮</div>
|
| 550 |
-
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
| 551 |
-
<p className="text-gray-600 mb-6">
|
| 552 |
<button
|
| 553 |
onClick={() => navigate('/')}
|
| 554 |
-
className="px-6 py-3 bg-gradient-to-r from-purple-600 to-indigo-600 text-white font-medium rounded-lg hover:from-purple-700 hover:to-indigo-700 transition-all"
|
| 555 |
>
|
| 556 |
-
|
| 557 |
</button>
|
| 558 |
</div>
|
| 559 |
|
| 560 |
-
<div className="grid md:grid-cols-2
|
| 561 |
-
<div className="bg-amber-50 p-6 rounded-xl">
|
| 562 |
-
<h4 className="font-semibold text-gray-900 mb-2">📅
|
| 563 |
-
<p className="text-gray-700">A favorable week for career opportunities. Stay open to new connections.</p>
|
| 564 |
</div>
|
| 565 |
-
<div className="bg-blue-50 p-6 rounded-xl">
|
| 566 |
-
<h4 className="font-semibold text-gray-900 mb-2">💰
|
| 567 |
-
<p className="text-gray-700">Financial stability is improving. Avoid impulsive investments.</p>
|
| 568 |
</div>
|
| 569 |
-
<div className="bg-pink-50 p-6 rounded-xl">
|
| 570 |
-
<h4 className="font-semibold text-gray-900 mb-2">❤️
|
| 571 |
-
<p className="text-gray-700">Harmonious relationships await. Express your feelings honestly.</p>
|
| 572 |
</div>
|
| 573 |
-
<div className="bg-green-50 p-6 rounded-xl">
|
| 574 |
-
<h4 className="font-semibold text-gray-900 mb-2">🌟
|
| 575 |
-
<p className="text-gray-700">Energy levels are high. Maintain your exercise routine.</p>
|
| 576 |
</div>
|
| 577 |
</div>
|
| 578 |
</div>
|
|
@@ -582,52 +590,60 @@ const DashboardPage: React.FC<DashboardPageProps> = ({
|
|
| 582 |
{activeTab === 'history' && (
|
| 583 |
<div className="space-y-4">
|
| 584 |
<div className="flex items-center justify-between mb-4">
|
| 585 |
-
<h3 className="text-lg font-semibold text-gray-900">
|
| 586 |
<button
|
| 587 |
onClick={() => navigate('/')}
|
| 588 |
-
className="text-indigo-600 hover:text-indigo-800 text-sm"
|
| 589 |
>
|
| 590 |
-
|
| 591 |
</button>
|
| 592 |
</div>
|
| 593 |
|
| 594 |
{recentAnalyses.length > 0 ? (
|
| 595 |
recentAnalyses.map(analysis => (
|
| 596 |
-
<div key={analysis.id} className="bg-gray-50 p-4 rounded-lg">
|
| 597 |
-
<div className="flex items-start justify-between">
|
| 598 |
-
<div className="flex-1">
|
| 599 |
-
<h4 className="font-medium text-gray-900">{analysis.summary}</h4>
|
| 600 |
-
<div className="flex items-center
|
| 601 |
<span className="flex items-center">
|
| 602 |
-
<Calendar className="w-4 h-4 mr-1" />
|
| 603 |
-
{formatDate(analysis.createdAt)}
|
| 604 |
</span>
|
| 605 |
-
<span className="flex items-center">
|
| 606 |
-
<Coins className="w-4 h-4 mr-1" />
|
| 607 |
{analysis.cost} points
|
| 608 |
</span>
|
| 609 |
</div>
|
| 610 |
</div>
|
| 611 |
-
<button className="text-gray-400 hover:text-gray-600">
|
| 612 |
-
<ChevronRight className="w-5 h-5" />
|
| 613 |
</button>
|
| 614 |
</div>
|
| 615 |
</div>
|
| 616 |
))
|
| 617 |
) : (
|
| 618 |
<div className="text-center py-12">
|
| 619 |
-
<History className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
| 620 |
-
<p className="text-gray-500 mb-4">
|
| 621 |
<button
|
| 622 |
onClick={() => navigate('/')}
|
| 623 |
-
className="text-indigo-600 hover:text-indigo-800"
|
| 624 |
>
|
| 625 |
-
|
| 626 |
</button>
|
| 627 |
</div>
|
| 628 |
)}
|
| 629 |
</div>
|
| 630 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
</>
|
| 632 |
)}
|
| 633 |
</div>
|
|
|
|
| 20 |
LogOut,
|
| 21 |
Ticket,
|
| 22 |
Check,
|
| 23 |
+
X,
|
| 24 |
+
Mail
|
| 25 |
} from 'lucide-react';
|
| 26 |
import ProfileManager from '../components/profile/ProfileManager';
|
| 27 |
+
import EmailSubscriptionManager from '../components/email/EmailSubscriptionManager';
|
| 28 |
import { UserInput, Gender, LifeDestinyResult, HistoryListItem, UserProfile } from '../types';
|
| 29 |
|
| 30 |
interface DashboardStats {
|
|
|
|
| 61 |
}) => {
|
| 62 |
const navigate = useNavigate();
|
| 63 |
const [searchParams] = useSearchParams();
|
| 64 |
+
const [activeTab, setActiveTab] = useState<'overview' | 'profiles' | 'fortune' | 'history' | 'email'>('overview');
|
| 65 |
const [stats, setStats] = useState<DashboardStats>({
|
| 66 |
profileCount: 0,
|
| 67 |
totalAnalyses: 0,
|
|
|
|
| 84 |
// Handle tab from URL
|
| 85 |
useEffect(() => {
|
| 86 |
const tab = searchParams.get('tab');
|
| 87 |
+
if (tab && ['overview', 'profiles', 'fortune', 'history', 'email'].includes(tab)) {
|
| 88 |
setActiveTab(tab as any);
|
| 89 |
}
|
| 90 |
}, [searchParams]);
|
|
|
|
| 229 |
|
| 230 |
const formatDate = (dateStr: string) => {
|
| 231 |
const date = new Date(dateStr);
|
| 232 |
+
return date.toLocaleDateString('zh-CN', {
|
| 233 |
month: 'short',
|
| 234 |
day: 'numeric',
|
| 235 |
year: 'numeric',
|
|
|
|
| 245 |
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
| 246 |
const diffDays = Math.floor(diffHours / 24);
|
| 247 |
|
| 248 |
+
if (diffDays > 0) return `${diffDays}天前`;
|
| 249 |
+
if (diffHours > 0) return `${diffHours}小时前`;
|
| 250 |
+
return '刚刚';
|
| 251 |
+
};
|
| 252 |
+
|
| 253 |
+
const refreshUserInfo = () => {
|
| 254 |
+
// Trigger a page reload to refresh user points
|
| 255 |
+
window.location.reload();
|
| 256 |
};
|
| 257 |
|
| 258 |
if (!isLoggedIn) {
|
|
|
|
| 262 |
<div className="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
| 263 |
<User className="w-8 h-8 text-indigo-600" />
|
| 264 |
</div>
|
| 265 |
+
<h2 className="text-2xl font-bold text-gray-900 mb-2">需要登录</h2>
|
| 266 |
<p className="text-gray-600 mb-6">
|
| 267 |
+
请登录以访问您的个人中心和管理档案
|
| 268 |
</p>
|
| 269 |
<button
|
| 270 |
onClick={onLoginClick}
|
| 271 |
className="w-full py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white font-bold rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all"
|
| 272 |
>
|
| 273 |
+
登录
|
| 274 |
</button>
|
| 275 |
</div>
|
| 276 |
</div>
|
|
|
|
| 281 |
<div className="min-h-screen bg-gray-50">
|
| 282 |
{/* Header */}
|
| 283 |
<div className="bg-white border-b border-gray-200">
|
| 284 |
+
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-4 sm:py-6">
|
| 285 |
+
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-0 mb-4">
|
| 286 |
+
<div className="flex-1 min-w-0">
|
| 287 |
+
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">个人中心</h1>
|
| 288 |
+
<p className="text-sm sm:text-base text-gray-600 mt-1 truncate">{userInfo?.email}</p>
|
| 289 |
</div>
|
| 290 |
<button
|
| 291 |
onClick={onLogout}
|
| 292 |
+
className="flex items-center space-x-2 px-3 sm:px-4 py-2 text-sm sm:text-base text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors whitespace-nowrap"
|
| 293 |
>
|
| 294 |
+
<LogOut className="w-4 h-4 sm:w-5 sm:h-5" />
|
| 295 |
+
<span>退出登录</span>
|
| 296 |
</button>
|
| 297 |
</div>
|
| 298 |
|
| 299 |
{/* Points Balance with Animation */}
|
| 300 |
+
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 rounded-xl sm:rounded-2xl p-4 sm:p-6 text-white">
|
| 301 |
<div className="flex items-center justify-between">
|
| 302 |
+
<div className="flex-1 min-w-0">
|
| 303 |
<div className="flex items-center space-x-2 mb-2">
|
| 304 |
+
<Coins className="w-5 h-5 sm:w-6 sm:h-6" />
|
| 305 |
+
<span className="text-base sm:text-lg font-medium">积分余额</span>
|
| 306 |
</div>
|
| 307 |
+
<div className="text-3xl sm:text-4xl font-bold">
|
| 308 |
{animatedPoints.toLocaleString()}
|
| 309 |
</div>
|
| 310 |
+
<p className="text-xs sm:text-sm text-indigo-100 mt-1">可用于分析测算</p>
|
| 311 |
</div>
|
| 312 |
+
<div className="text-right flex-shrink-0 ml-4">
|
| 313 |
+
<div className="text-xl sm:text-2xl mb-1">🎯</div>
|
| 314 |
+
<p className="text-xs sm:text-sm text-indigo-100 whitespace-nowrap">等级 {Math.floor((userInfo?.points || 0) / 100) + 1}</p>
|
| 315 |
</div>
|
| 316 |
</div>
|
| 317 |
|
|
|
|
| 380 |
</div>
|
| 381 |
|
| 382 |
{/* Quick Actions */}
|
| 383 |
+
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-4 sm:py-6">
|
| 384 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4">
|
| 385 |
<button
|
| 386 |
onClick={handleAddProfile}
|
| 387 |
+
className="bg-white p-3 sm:p-4 rounded-xl shadow-sm hover:shadow-md transition-shadow text-center group"
|
| 388 |
>
|
| 389 |
+
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-2 group-hover:bg-blue-200 transition-colors">
|
| 390 |
+
<Plus className="w-5 h-5 sm:w-6 sm:h-6 text-blue-600" />
|
| 391 |
</div>
|
| 392 |
+
<span className="text-xs sm:text-sm font-medium text-gray-700 block truncate px-1">添加档案</span>
|
| 393 |
</button>
|
| 394 |
|
| 395 |
<button
|
| 396 |
onClick={handleViewHistory}
|
| 397 |
+
className="bg-white p-3 sm:p-4 rounded-xl shadow-sm hover:shadow-md transition-shadow text-center group"
|
| 398 |
>
|
| 399 |
+
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-2 group-hover:bg-green-200 transition-colors">
|
| 400 |
+
<History className="w-5 h-5 sm:w-6 sm:h-6 text-green-600" />
|
| 401 |
</div>
|
| 402 |
+
<span className="text-xs sm:text-sm font-medium text-gray-700 block truncate px-1">查看历史</span>
|
| 403 |
</button>
|
| 404 |
|
| 405 |
<button
|
| 406 |
onClick={() => navigate('/knowledge')}
|
| 407 |
+
className="bg-white p-3 sm:p-4 rounded-xl shadow-sm hover:shadow-md transition-shadow text-center group"
|
| 408 |
>
|
| 409 |
+
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-2 group-hover:bg-purple-200 transition-colors">
|
| 410 |
+
<TrendingUp className="w-5 h-5 sm:w-6 sm:h-6 text-purple-600" />
|
| 411 |
</div>
|
| 412 |
+
<span className="text-xs sm:text-sm font-medium text-gray-700 block truncate px-1">了解更多</span>
|
| 413 |
</button>
|
| 414 |
|
| 415 |
<button
|
| 416 |
onClick={() => navigate('/cases')}
|
| 417 |
+
className="bg-white p-3 sm:p-4 rounded-xl shadow-sm hover:shadow-md transition-shadow text-center group"
|
| 418 |
>
|
| 419 |
+
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-2 group-hover:bg-amber-200 transition-colors">
|
| 420 |
+
<Eye className="w-5 h-5 sm:w-6 sm:h-6 text-amber-600" />
|
| 421 |
</div>
|
| 422 |
+
<span className="text-xs sm:text-sm font-medium text-gray-700 block truncate px-1">浏览案例</span>
|
| 423 |
</button>
|
| 424 |
</div>
|
| 425 |
</div>
|
| 426 |
|
| 427 |
{/* Tabs */}
|
| 428 |
+
<div className="max-w-4xl mx-auto px-4 sm:px-6">
|
| 429 |
+
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
| 430 |
+
<div className="flex border-b border-gray-200 overflow-x-auto scrollbar-hide">
|
| 431 |
{[
|
| 432 |
+
{ id: 'overview', label: '概览', icon: TrendingUp },
|
| 433 |
+
{ id: 'profiles', label: '我的档案', icon: User },
|
| 434 |
+
{ id: 'fortune', label: '运势', icon: Star },
|
| 435 |
+
{ id: 'history', label: '历史', icon: History },
|
| 436 |
+
{ id: 'email', label: '邮件设置', icon: Mail }
|
| 437 |
].map(tab => (
|
| 438 |
<button
|
| 439 |
key={tab.id}
|
| 440 |
onClick={() => setActiveTab(tab.id as any)}
|
| 441 |
+
className={`flex-1 flex items-center justify-center space-x-1.5 sm:space-x-2 py-3 sm:py-4 px-3 sm:px-6 font-medium text-sm sm:text-base transition-colors whitespace-nowrap min-w-0 ${
|
| 442 |
activeTab === tab.id
|
| 443 |
? 'text-indigo-600 border-b-2 border-indigo-600 bg-indigo-50'
|
| 444 |
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
| 445 |
}`}
|
| 446 |
>
|
| 447 |
+
<tab.icon className="w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0" />
|
| 448 |
+
<span className="truncate">{tab.label}</span>
|
| 449 |
</button>
|
| 450 |
))}
|
| 451 |
</div>
|
| 452 |
|
| 453 |
{/* Tab Content */}
|
| 454 |
+
<div className="p-4 sm:p-6">
|
| 455 |
{isLoading ? (
|
| 456 |
<div className="flex items-center justify-center py-12">
|
| 457 |
<Loader2 className="w-8 h-8 text-indigo-600 animate-spin" />
|
|
|
|
| 463 |
onClick={() => window.location.reload()}
|
| 464 |
className="text-indigo-600 hover:text-indigo-800"
|
| 465 |
>
|
| 466 |
+
重试
|
| 467 |
</button>
|
| 468 |
</div>
|
| 469 |
) : (
|
|
|
|
| 472 |
{activeTab === 'overview' && (
|
| 473 |
<div className="space-y-6">
|
| 474 |
{/* Stats Grid */}
|
| 475 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4">
|
| 476 |
+
<div className="bg-gray-50 p-3 sm:p-4 rounded-lg">
|
| 477 |
+
<div className="text-xl sm:text-2xl font-bold text-gray-900">{stats.profileCount}</div>
|
| 478 |
+
<div className="text-xs sm:text-sm text-gray-600 truncate">档案数</div>
|
| 479 |
</div>
|
| 480 |
+
<div className="bg-gray-50 p-3 sm:p-4 rounded-lg">
|
| 481 |
+
<div className="text-xl sm:text-2xl font-bold text-gray-900">{stats.totalAnalyses}</div>
|
| 482 |
+
<div className="text-xs sm:text-sm text-gray-600 truncate">分析次数</div>
|
| 483 |
</div>
|
| 484 |
+
<div className="bg-gray-50 p-3 sm:p-4 rounded-lg">
|
| 485 |
+
<div className="text-xl sm:text-2xl font-bold text-gray-900">{stats.totalShares}</div>
|
| 486 |
+
<div className="text-xs sm:text-sm text-gray-600 truncate">分享次数</div>
|
| 487 |
</div>
|
| 488 |
+
<div className="bg-gray-50 p-3 sm:p-4 rounded-lg">
|
| 489 |
+
<div className="text-xl sm:text-2xl font-bold text-gray-900">{stats.pointsEarned}</div>
|
| 490 |
+
<div className="text-xs sm:text-sm text-gray-600 truncate">获得积分</div>
|
| 491 |
</div>
|
| 492 |
</div>
|
| 493 |
|
| 494 |
{/* Recent Activity */}
|
| 495 |
+
<div className="grid gap-6 md:grid-cols-2">
|
| 496 |
{/* Recent Analyses */}
|
| 497 |
<div>
|
| 498 |
+
<h3 className="text-base sm:text-lg font-semibold text-gray-900 mb-3">最近分析</h3>
|
| 499 |
<div className="space-y-2">
|
| 500 |
{recentAnalyses.length > 0 ? (
|
| 501 |
recentAnalyses.slice(0, 3).map(analysis => (
|
| 502 |
+
<div key={analysis.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg gap-2">
|
| 503 |
+
<div className="flex-1 min-w-0">
|
| 504 |
+
<div className="font-medium text-sm sm:text-base text-gray-900 truncate">{analysis.summary}</div>
|
| 505 |
+
<div className="text-xs sm:text-sm text-gray-500 truncate">{formatTime(analysis.createdAt)}</div>
|
| 506 |
</div>
|
| 507 |
+
<ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
| 508 |
</div>
|
| 509 |
))
|
| 510 |
) : (
|
| 511 |
+
<p className="text-sm text-gray-500 text-center py-4">暂无分析记录</p>
|
| 512 |
)}
|
| 513 |
</div>
|
| 514 |
</div>
|
| 515 |
|
| 516 |
{/* Recent Rewards */}
|
| 517 |
<div>
|
| 518 |
+
<h3 className="text-base sm:text-lg font-semibold text-gray-900 mb-3">分享奖励</h3>
|
| 519 |
<div className="space-y-2">
|
| 520 |
{shareRewards.length > 0 ? (
|
| 521 |
shareRewards.slice(0, 3).map(reward => (
|
| 522 |
+
<div key={reward.id} className="flex items-center justify-between p-3 bg-green-50 rounded-lg gap-2">
|
| 523 |
+
<div className="flex-1 min-w-0">
|
| 524 |
+
<div className="font-medium text-sm sm:text-base text-gray-900">获得积分</div>
|
| 525 |
+
<div className="text-xs sm:text-sm text-gray-500 truncate">{formatTime(reward.createdAt)}</div>
|
| 526 |
</div>
|
| 527 |
+
<span className="text-green-600 font-medium whitespace-nowrap flex-shrink-0">+{reward.points}</span>
|
| 528 |
</div>
|
| 529 |
))
|
| 530 |
) : (
|
| 531 |
+
<p className="text-sm text-gray-500 text-center py-4">暂无奖励记录</p>
|
| 532 |
)}
|
| 533 |
</div>
|
| 534 |
</div>
|
| 535 |
</div>
|
| 536 |
|
| 537 |
{/* Fortune Preview */}
|
| 538 |
+
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 p-4 sm:p-6 rounded-xl">
|
| 539 |
+
<h3 className="text-base sm:text-lg font-semibold text-gray-900 mb-3">今日运势</h3>
|
| 540 |
+
<div className="text-sm sm:text-base text-gray-700">
|
| 541 |
<p className="mb-2">🌟 Your luck is looking positive today! Consider making important decisions.</p>
|
| 542 |
+
<p className="text-xs sm:text-sm text-gray-600">Lucky numbers: 7, 14, 21 | Lucky color: Blue</p>
|
| 543 |
</div>
|
| 544 |
</div>
|
| 545 |
</div>
|
|
|
|
| 554 |
{activeTab === 'fortune' && (
|
| 555 |
<div className="space-y-6">
|
| 556 |
<div className="text-center py-12">
|
| 557 |
+
<div className="text-5xl sm:text-6xl mb-4">🔮</div>
|
| 558 |
+
<h3 className="text-lg sm:text-xl font-semibold text-gray-900 mb-2">运势预测</h3>
|
| 559 |
+
<p className="text-sm sm:text-base text-gray-600 mb-6 px-4">获取基于您八字分析的个性化运势解读</p>
|
| 560 |
<button
|
| 561 |
onClick={() => navigate('/')}
|
| 562 |
+
className="px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-purple-600 to-indigo-600 text-white text-sm sm:text-base font-medium rounded-lg hover:from-purple-700 hover:to-indigo-700 transition-all"
|
| 563 |
>
|
| 564 |
+
开始新分析
|
| 565 |
</button>
|
| 566 |
</div>
|
| 567 |
|
| 568 |
+
<div className="grid gap-4 sm:gap-6 md:grid-cols-2">
|
| 569 |
+
<div className="bg-amber-50 p-4 sm:p-6 rounded-xl">
|
| 570 |
+
<h4 className="font-semibold text-sm sm:text-base text-gray-900 mb-2">📅 本周运势</h4>
|
| 571 |
+
<p className="text-xs sm:text-sm text-gray-700">A favorable week for career opportunities. Stay open to new connections.</p>
|
| 572 |
</div>
|
| 573 |
+
<div className="bg-blue-50 p-4 sm:p-6 rounded-xl">
|
| 574 |
+
<h4 className="font-semibold text-sm sm:text-base text-gray-900 mb-2">💰 财运展望</h4>
|
| 575 |
+
<p className="text-xs sm:text-sm text-gray-700">Financial stability is improving. Avoid impulsive investments.</p>
|
| 576 |
</div>
|
| 577 |
+
<div className="bg-pink-50 p-4 sm:p-6 rounded-xl">
|
| 578 |
+
<h4 className="font-semibold text-sm sm:text-base text-gray-900 mb-2">❤️ 感情运势</h4>
|
| 579 |
+
<p className="text-xs sm:text-sm text-gray-700">Harmonious relationships await. Express your feelings honestly.</p>
|
| 580 |
</div>
|
| 581 |
+
<div className="bg-green-50 p-4 sm:p-6 rounded-xl">
|
| 582 |
+
<h4 className="font-semibold text-sm sm:text-base text-gray-900 mb-2">🌟 健康运势</h4>
|
| 583 |
+
<p className="text-xs sm:text-sm text-gray-700">Energy levels are high. Maintain your exercise routine.</p>
|
| 584 |
</div>
|
| 585 |
</div>
|
| 586 |
</div>
|
|
|
|
| 590 |
{activeTab === 'history' && (
|
| 591 |
<div className="space-y-4">
|
| 592 |
<div className="flex items-center justify-between mb-4">
|
| 593 |
+
<h3 className="text-base sm:text-lg font-semibold text-gray-900">分析历史</h3>
|
| 594 |
<button
|
| 595 |
onClick={() => navigate('/')}
|
| 596 |
+
className="text-indigo-600 hover:text-indigo-800 text-xs sm:text-sm"
|
| 597 |
>
|
| 598 |
+
查看全部
|
| 599 |
</button>
|
| 600 |
</div>
|
| 601 |
|
| 602 |
{recentAnalyses.length > 0 ? (
|
| 603 |
recentAnalyses.map(analysis => (
|
| 604 |
+
<div key={analysis.id} className="bg-gray-50 p-3 sm:p-4 rounded-lg">
|
| 605 |
+
<div className="flex items-start justify-between gap-3">
|
| 606 |
+
<div className="flex-1 min-w-0">
|
| 607 |
+
<h4 className="font-medium text-sm sm:text-base text-gray-900 truncate">{analysis.summary}</h4>
|
| 608 |
+
<div className="flex flex-wrap items-center gap-x-3 sm:gap-x-4 gap-y-1 mt-2 text-xs sm:text-sm text-gray-500">
|
| 609 |
<span className="flex items-center">
|
| 610 |
+
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1 flex-shrink-0" />
|
| 611 |
+
<span className="truncate">{formatDate(analysis.createdAt)}</span>
|
| 612 |
</span>
|
| 613 |
+
<span className="flex items-center whitespace-nowrap">
|
| 614 |
+
<Coins className="w-3 h-3 sm:w-4 sm:h-4 mr-1 flex-shrink-0" />
|
| 615 |
{analysis.cost} points
|
| 616 |
</span>
|
| 617 |
</div>
|
| 618 |
</div>
|
| 619 |
+
<button className="text-gray-400 hover:text-gray-600 flex-shrink-0">
|
| 620 |
+
<ChevronRight className="w-4 h-4 sm:w-5 sm:h-5" />
|
| 621 |
</button>
|
| 622 |
</div>
|
| 623 |
</div>
|
| 624 |
))
|
| 625 |
) : (
|
| 626 |
<div className="text-center py-12">
|
| 627 |
+
<History className="w-10 h-10 sm:w-12 sm:h-12 text-gray-300 mx-auto mb-3" />
|
| 628 |
+
<p className="text-sm sm:text-base text-gray-500 mb-4">暂无分析历史</p>
|
| 629 |
<button
|
| 630 |
onClick={() => navigate('/')}
|
| 631 |
+
className="text-sm sm:text-base text-indigo-600 hover:text-indigo-800"
|
| 632 |
>
|
| 633 |
+
开始您的第一次分析
|
| 634 |
</button>
|
| 635 |
</div>
|
| 636 |
)}
|
| 637 |
</div>
|
| 638 |
)}
|
| 639 |
+
|
| 640 |
+
{/* Email Settings Tab */}
|
| 641 |
+
{activeTab === 'email' && (
|
| 642 |
+
<EmailSubscriptionManager
|
| 643 |
+
userPoints={userInfo?.points || 0}
|
| 644 |
+
onPointsChange={refreshUserInfo}
|
| 645 |
+
/>
|
| 646 |
+
)}
|
| 647 |
</>
|
| 648 |
)}
|
| 649 |
</div>
|
pages/HomePage.tsx
CHANGED
|
@@ -390,7 +390,7 @@ const HomePage: React.FC<HomePageProps> = ({
|
|
| 390 |
<h2 className="text-xl font-bold font-serif-sc text-gray-800">{userName ? `${userName}的` : ''}命盘分析报告</h2>
|
| 391 |
{fromCache && <span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-bold rounded-full flex items-center gap-1"><CheckCircle className="w-3 h-3" /> 一致性结果</span>}
|
| 392 |
</div>
|
| 393 |
-
<div className="flex gap-2 flex-wrap no-print">
|
| 394 |
<button onClick={() => setRightPanelOpen(!rightPanelOpen)} className={`flex items-center gap-1.5 px-3 py-2 border rounded-lg text-sm font-medium transition-all ${rightPanelOpen ? 'bg-indigo-50 text-indigo-600 border-indigo-200' : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'}`}>
|
| 395 |
{rightPanelOpen ? <PanelRightClose className="w-4 h-4" /> : <PanelRightOpen className="w-4 h-4" />} 详细分析
|
| 396 |
</button>
|
|
|
|
| 390 |
<h2 className="text-xl font-bold font-serif-sc text-gray-800">{userName ? `${userName}的` : ''}命盘分析报告</h2>
|
| 391 |
{fromCache && <span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-bold rounded-full flex items-center gap-1"><CheckCircle className="w-3 h-3" /> 一致性结果</span>}
|
| 392 |
</div>
|
| 393 |
+
<div className="flex gap-2 flex-wrap no-print relative">
|
| 394 |
<button onClick={() => setRightPanelOpen(!rightPanelOpen)} className={`flex items-center gap-1.5 px-3 py-2 border rounded-lg text-sm font-medium transition-all ${rightPanelOpen ? 'bg-indigo-50 text-indigo-600 border-indigo-200' : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'}`}>
|
| 395 |
{rightPanelOpen ? <PanelRightClose className="w-4 h-4" /> : <PanelRightOpen className="w-4 h-4" />} 详细分析
|
| 396 |
</button>
|
server/coreDocumentEngine.js
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 核心文档引擎 - Core Document Engine for Bazi Profiles
|
| 3 |
+
*
|
| 4 |
+
* 职责:
|
| 5 |
+
* 1. 为每个用户档案生成核心文档(100年命理数据)
|
| 6 |
+
* 2. 管理核心文档的缓存、验证和重新生成
|
| 7 |
+
* 3. 确保同一八字返回相同的核心结论
|
| 8 |
+
*
|
| 9 |
+
* 核心文档结构:
|
| 10 |
+
* - 100年生命时间线(chartPoints)
|
| 11 |
+
* - 命理核心分析(personality_core, career_core等)
|
| 12 |
+
* - K线数据(kline_data)
|
| 13 |
+
* - 巅峰年/低谷年
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
import { getUserProfileById, updateProfileCoreDocumentStatus, getDb, nowIso } from './database.js';
|
| 17 |
+
import { getCachedAnalysis, cacheAnalysis, computeBaziHash } from './cacheManager.js';
|
| 18 |
+
import { calculateLifeTimeline, generateFallbackKLine } from './baziCalculator.js';
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* 生成核心文档
|
| 22 |
+
* @param {object} profile - 用户档案对象
|
| 23 |
+
* @param {boolean} skipCache - 是否跳过缓存检查(强制重新生成)
|
| 24 |
+
* @returns {Promise<object>} 核心文档对象
|
| 25 |
+
*/
|
| 26 |
+
export const generateCoreDocument = async (profile, skipCache = false) => {
|
| 27 |
+
try {
|
| 28 |
+
console.log(`[CoreDocEngine] 开始生成核心文档 - Profile ID: ${profile.id}`);
|
| 29 |
+
|
| 30 |
+
// 1. 更新状态为"生成中"
|
| 31 |
+
updateProfileCoreDocumentStatus(profile.id, 'generating');
|
| 32 |
+
|
| 33 |
+
// 2. 计算100年生命时间线
|
| 34 |
+
const timelineData = calculateLifeTimeline({
|
| 35 |
+
birthYear: profile.birthYear,
|
| 36 |
+
gender: profile.gender === 'male' ? 'Male' : 'Female',
|
| 37 |
+
yearPillar: profile.yearPillar,
|
| 38 |
+
monthPillar: profile.monthPillar,
|
| 39 |
+
dayPillar: profile.dayPillar,
|
| 40 |
+
hourPillar: profile.hourPillar,
|
| 41 |
+
startAge: profile.startAge,
|
| 42 |
+
firstDaYun: profile.firstDaYun
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
console.log(`[CoreDocEngine] 时间线计算完成 - ${timelineData.timeline.length} 年`);
|
| 46 |
+
|
| 47 |
+
// 3. 计算八字哈希
|
| 48 |
+
const baziHash = computeBaziHash(
|
| 49 |
+
profile.yearPillar,
|
| 50 |
+
profile.monthPillar,
|
| 51 |
+
profile.dayPillar,
|
| 52 |
+
profile.hourPillar
|
| 53 |
+
);
|
| 54 |
+
|
| 55 |
+
// 4. 检查缓存(除非跳过)
|
| 56 |
+
let cachedAnalysis = null;
|
| 57 |
+
if (!skipCache) {
|
| 58 |
+
cachedAnalysis = getCachedAnalysis(baziHash, profile.gender);
|
| 59 |
+
if (cachedAnalysis) {
|
| 60 |
+
console.log(`[CoreDocEngine] 找到缓存分析 - Hash: ${baziHash}`);
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// 5. 如果没有缓存,触发分析生成(当前使用降级算法)
|
| 65 |
+
let coreDocument;
|
| 66 |
+
if (cachedAnalysis) {
|
| 67 |
+
// 使用缓存数据
|
| 68 |
+
coreDocument = {
|
| 69 |
+
profileId: profile.id,
|
| 70 |
+
baziHash,
|
| 71 |
+
chartPoints: cachedAnalysis.klineData || [],
|
| 72 |
+
personalityCore: cachedAnalysis.personalityCore,
|
| 73 |
+
careerCore: cachedAnalysis.careerCore,
|
| 74 |
+
wealthCore: cachedAnalysis.wealthCore,
|
| 75 |
+
marriageCore: cachedAnalysis.marriageCore,
|
| 76 |
+
healthCore: cachedAnalysis.healthCore,
|
| 77 |
+
klineData: cachedAnalysis.klineData,
|
| 78 |
+
peakYears: cachedAnalysis.peakYears,
|
| 79 |
+
troughYears: cachedAnalysis.troughYears,
|
| 80 |
+
cryptoCore: cachedAnalysis.cryptoCore,
|
| 81 |
+
luckyElements: cachedAnalysis.luckyElements,
|
| 82 |
+
physicalTraits: cachedAnalysis.physicalTraits,
|
| 83 |
+
modelUsed: cachedAnalysis.modelUsed,
|
| 84 |
+
generatedAt: nowIso(),
|
| 85 |
+
fromCache: true
|
| 86 |
+
};
|
| 87 |
+
} else {
|
| 88 |
+
// 生成新的分析(使用降级算法)
|
| 89 |
+
console.log(`[CoreDocEngine] 生成新的核心分析 - 使用降级算法`);
|
| 90 |
+
|
| 91 |
+
const klineData = generateFallbackKLine(timelineData);
|
| 92 |
+
|
| 93 |
+
// 找出巅峰年和低谷年
|
| 94 |
+
const sortedByScore = [...klineData].sort((a, b) => b.score - a.score);
|
| 95 |
+
const peakYears = sortedByScore.slice(0, 5).map(p => ({
|
| 96 |
+
year: p.year,
|
| 97 |
+
age: p.age,
|
| 98 |
+
score: p.score,
|
| 99 |
+
reason: p.reason
|
| 100 |
+
}));
|
| 101 |
+
const troughYears = sortedByScore.slice(-5).reverse().map(p => ({
|
| 102 |
+
year: p.year,
|
| 103 |
+
age: p.age,
|
| 104 |
+
score: p.score,
|
| 105 |
+
reason: p.reason
|
| 106 |
+
}));
|
| 107 |
+
|
| 108 |
+
// 构建核心文档
|
| 109 |
+
coreDocument = {
|
| 110 |
+
profileId: profile.id,
|
| 111 |
+
baziHash,
|
| 112 |
+
chartPoints: klineData,
|
| 113 |
+
personalityCore: {
|
| 114 |
+
content: '基于四柱八字的性格分析(降级版)',
|
| 115 |
+
score: 5
|
| 116 |
+
},
|
| 117 |
+
careerCore: {
|
| 118 |
+
content: '基于四柱八字的事业分析(降级版)',
|
| 119 |
+
score: 5
|
| 120 |
+
},
|
| 121 |
+
wealthCore: {
|
| 122 |
+
content: '基于四柱八字的财运分析(降级版)',
|
| 123 |
+
score: 5
|
| 124 |
+
},
|
| 125 |
+
marriageCore: {
|
| 126 |
+
content: '基于四柱八字的婚姻分析(降级版)',
|
| 127 |
+
score: 5
|
| 128 |
+
},
|
| 129 |
+
healthCore: {
|
| 130 |
+
content: '基于四柱八字的健康分析(降级版)',
|
| 131 |
+
score: 5,
|
| 132 |
+
bodyParts: []
|
| 133 |
+
},
|
| 134 |
+
klineData,
|
| 135 |
+
peakYears,
|
| 136 |
+
troughYears,
|
| 137 |
+
cryptoCore: {
|
| 138 |
+
content: '暂无币圈分析',
|
| 139 |
+
score: 5
|
| 140 |
+
},
|
| 141 |
+
luckyElements: {
|
| 142 |
+
colors: [],
|
| 143 |
+
directions: [],
|
| 144 |
+
zodiac: [],
|
| 145 |
+
numbers: []
|
| 146 |
+
},
|
| 147 |
+
physicalTraits: {
|
| 148 |
+
appearance: '',
|
| 149 |
+
bodyType: '',
|
| 150 |
+
skin: '',
|
| 151 |
+
characterSummary: ''
|
| 152 |
+
},
|
| 153 |
+
modelUsed: 'fallback_v1',
|
| 154 |
+
generatedAt: nowIso(),
|
| 155 |
+
fromCache: false
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
// 保存到缓存
|
| 159 |
+
cacheAnalysis({
|
| 160 |
+
baziHash,
|
| 161 |
+
gender: profile.gender,
|
| 162 |
+
structuralData: {
|
| 163 |
+
bazi: [profile.yearPillar, profile.monthPillar, profile.dayPillar, profile.hourPillar],
|
| 164 |
+
summaryScore: 5
|
| 165 |
+
},
|
| 166 |
+
personalityCore: coreDocument.personalityCore,
|
| 167 |
+
careerCore: coreDocument.careerCore,
|
| 168 |
+
wealthCore: coreDocument.wealthCore,
|
| 169 |
+
marriageCore: coreDocument.marriageCore,
|
| 170 |
+
healthCore: coreDocument.healthCore,
|
| 171 |
+
klineData,
|
| 172 |
+
peakYears,
|
| 173 |
+
troughYears,
|
| 174 |
+
cryptoCore: coreDocument.cryptoCore,
|
| 175 |
+
luckyElements: coreDocument.luckyElements,
|
| 176 |
+
physicalTraits: coreDocument.physicalTraits,
|
| 177 |
+
modelUsed: 'fallback_v1',
|
| 178 |
+
version: 1
|
| 179 |
+
});
|
| 180 |
+
|
| 181 |
+
console.log(`[CoreDocEngine] 核心分析已缓存 - Hash: ${baziHash}`);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
// 6. 更新档案状态为"就绪"
|
| 185 |
+
updateProfileCoreDocumentStatus(profile.id, 'ready');
|
| 186 |
+
console.log(`[CoreDocEngine] 核心文档生成完成 - Profile ID: ${profile.id}`);
|
| 187 |
+
|
| 188 |
+
return coreDocument;
|
| 189 |
+
|
| 190 |
+
} catch (error) {
|
| 191 |
+
console.error(`[CoreDocEngine] 生成核心文档失败:`, error);
|
| 192 |
+
|
| 193 |
+
// 更新状态为"失败"
|
| 194 |
+
updateProfileCoreDocumentStatus(profile.id, 'failed');
|
| 195 |
+
|
| 196 |
+
throw new Error(`生成核心文档失败: ${error.message}`);
|
| 197 |
+
}
|
| 198 |
+
};
|
| 199 |
+
|
| 200 |
+
/**
|
| 201 |
+
* 获取核心文档
|
| 202 |
+
* @param {string} profileId - 档案ID
|
| 203 |
+
* @returns {Promise<object>} 核心文档对象,包含验证状态
|
| 204 |
+
*/
|
| 205 |
+
export const getCoreDocument = async (profileId) => {
|
| 206 |
+
try {
|
| 207 |
+
console.log(`[CoreDocEngine] 获取核心文档 - Profile ID: ${profileId}`);
|
| 208 |
+
|
| 209 |
+
// 1. 获取档案
|
| 210 |
+
const profile = getUserProfileById(profileId);
|
| 211 |
+
if (!profile) {
|
| 212 |
+
throw new Error('档案不存在');
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// 2. 计算八字哈希
|
| 216 |
+
const baziHash = computeBaziHash(
|
| 217 |
+
profile.yearPillar,
|
| 218 |
+
profile.monthPillar,
|
| 219 |
+
profile.dayPillar,
|
| 220 |
+
profile.hourPillar
|
| 221 |
+
);
|
| 222 |
+
|
| 223 |
+
// 3. 查询缓存
|
| 224 |
+
const cachedAnalysis = getCachedAnalysis(baziHash, profile.gender);
|
| 225 |
+
|
| 226 |
+
// 4. 如果找到缓存,构建文档并返回
|
| 227 |
+
if (cachedAnalysis && cachedAnalysis.klineData && cachedAnalysis.klineData.length > 0) {
|
| 228 |
+
const document = {
|
| 229 |
+
profileId: profile.id,
|
| 230 |
+
baziHash,
|
| 231 |
+
chartPoints: cachedAnalysis.klineData,
|
| 232 |
+
personalityCore: cachedAnalysis.personalityCore,
|
| 233 |
+
careerCore: cachedAnalysis.careerCore,
|
| 234 |
+
wealthCore: cachedAnalysis.wealthCore,
|
| 235 |
+
marriageCore: cachedAnalysis.marriageCore,
|
| 236 |
+
healthCore: cachedAnalysis.healthCore,
|
| 237 |
+
klineData: cachedAnalysis.klineData,
|
| 238 |
+
peakYears: cachedAnalysis.peakYears,
|
| 239 |
+
troughYears: cachedAnalysis.troughYears,
|
| 240 |
+
cryptoCore: cachedAnalysis.cryptoCore,
|
| 241 |
+
luckyElements: cachedAnalysis.luckyElements,
|
| 242 |
+
physicalTraits: cachedAnalysis.physicalTraits,
|
| 243 |
+
modelUsed: cachedAnalysis.modelUsed,
|
| 244 |
+
generatedAt: cachedAnalysis.createdAt,
|
| 245 |
+
fromCache: true
|
| 246 |
+
};
|
| 247 |
+
|
| 248 |
+
// 验证文档完整性
|
| 249 |
+
const validation = validateCoreDocument(document);
|
| 250 |
+
|
| 251 |
+
console.log(`[CoreDocEngine] 核心文档已从缓存返回 - 验证分数: ${validation.score}`);
|
| 252 |
+
|
| 253 |
+
return {
|
| 254 |
+
document,
|
| 255 |
+
validation,
|
| 256 |
+
status: 'ready'
|
| 257 |
+
};
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
// 5. 如果没有缓存,生成新文档
|
| 261 |
+
console.log(`[CoreDocEngine] 缓存未找到,生成新核心文档`);
|
| 262 |
+
const document = await generateCoreDocument(profile);
|
| 263 |
+
const validation = validateCoreDocument(document);
|
| 264 |
+
|
| 265 |
+
return {
|
| 266 |
+
document,
|
| 267 |
+
validation,
|
| 268 |
+
status: 'ready'
|
| 269 |
+
};
|
| 270 |
+
|
| 271 |
+
} catch (error) {
|
| 272 |
+
console.error(`[CoreDocEngine] 获取核心文档失败:`, error);
|
| 273 |
+
throw new Error(`获取核心文档失败: ${error.message}`);
|
| 274 |
+
}
|
| 275 |
+
};
|
| 276 |
+
|
| 277 |
+
/**
|
| 278 |
+
* 验证核心文档完整性
|
| 279 |
+
* @param {object} doc - 核心文档对象
|
| 280 |
+
* @returns {object} 验证结果 { valid: boolean, missing: string[], score: number }
|
| 281 |
+
*/
|
| 282 |
+
export const validateCoreDocument = (doc) => {
|
| 283 |
+
const missing = [];
|
| 284 |
+
let score = 0;
|
| 285 |
+
const maxScore = 100;
|
| 286 |
+
|
| 287 |
+
// 1. 检查 chartPoints 是否存在且有约100项
|
| 288 |
+
if (!doc.chartPoints || !Array.isArray(doc.chartPoints)) {
|
| 289 |
+
missing.push('chartPoints');
|
| 290 |
+
} else if (doc.chartPoints.length === 0) {
|
| 291 |
+
missing.push('chartPoints (empty)');
|
| 292 |
+
} else if (doc.chartPoints.length < 90) {
|
| 293 |
+
missing.push('chartPoints (不足100年)');
|
| 294 |
+
score += 10; // 部分分数
|
| 295 |
+
} else {
|
| 296 |
+
score += 30; // chartPoints 占30分
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
// 2. 检查必需字段:personalityCore
|
| 300 |
+
if (!doc.personalityCore || !doc.personalityCore.content) {
|
| 301 |
+
missing.push('personality_core');
|
| 302 |
+
} else {
|
| 303 |
+
score += 15;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
// 3. 检查必需字段:careerCore
|
| 307 |
+
if (!doc.careerCore || !doc.careerCore.content) {
|
| 308 |
+
missing.push('career_core');
|
| 309 |
+
} else {
|
| 310 |
+
score += 15;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
// 4. 检查必需字段:klineData
|
| 314 |
+
if (!doc.klineData || !Array.isArray(doc.klineData)) {
|
| 315 |
+
missing.push('kline_data');
|
| 316 |
+
} else if (doc.klineData.length === 0) {
|
| 317 |
+
missing.push('kline_data (empty)');
|
| 318 |
+
} else {
|
| 319 |
+
score += 20;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
// 5. 检查可选字段:wealthCore
|
| 323 |
+
if (doc.wealthCore && doc.wealthCore.content) {
|
| 324 |
+
score += 5;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
// 6. 检查可选字段:marriageCore
|
| 328 |
+
if (doc.marriageCore && doc.marriageCore.content) {
|
| 329 |
+
score += 5;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
// 7. 检查可选字段:healthCore
|
| 333 |
+
if (doc.healthCore && doc.healthCore.content) {
|
| 334 |
+
score += 5;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
// 8. 检查可选字段:peakYears 和 troughYears
|
| 338 |
+
if (doc.peakYears && Array.isArray(doc.peakYears) && doc.peakYears.length > 0) {
|
| 339 |
+
score += 3;
|
| 340 |
+
}
|
| 341 |
+
if (doc.troughYears && Array.isArray(doc.troughYears) && doc.troughYears.length > 0) {
|
| 342 |
+
score += 2;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
// 9. 检查可选字段:luckyElements
|
| 346 |
+
if (doc.luckyElements && Object.keys(doc.luckyElements).length > 0) {
|
| 347 |
+
score += 3;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
// 10. 检查可选字段:physicalTraits
|
| 351 |
+
if (doc.physicalTraits && Object.keys(doc.physicalTraits).length > 0) {
|
| 352 |
+
score += 2;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
const valid = missing.length === 0 && score >= 80;
|
| 356 |
+
|
| 357 |
+
return {
|
| 358 |
+
valid,
|
| 359 |
+
missing,
|
| 360 |
+
score,
|
| 361 |
+
maxScore,
|
| 362 |
+
message: valid
|
| 363 |
+
? '核心文档完整'
|
| 364 |
+
: `核心文档不完整,缺失字段: ${missing.join(', ')}`
|
| 365 |
+
};
|
| 366 |
+
};
|
| 367 |
+
|
| 368 |
+
/**
|
| 369 |
+
* 强制重新生成核心文档
|
| 370 |
+
* @param {string} profileId - 档案ID
|
| 371 |
+
* @param {string} reason - 重新生成原因
|
| 372 |
+
* @returns {Promise<object>} 新的核心文档对象
|
| 373 |
+
*/
|
| 374 |
+
export const regenerateCoreDocument = async (profileId, reason = '手动触发') => {
|
| 375 |
+
try {
|
| 376 |
+
console.log(`[CoreDocEngine] 重新生成核心文档 - Profile ID: ${profileId}, 原因: ${reason}`);
|
| 377 |
+
|
| 378 |
+
// 1. 获取档案
|
| 379 |
+
const profile = getUserProfileById(profileId);
|
| 380 |
+
if (!profile) {
|
| 381 |
+
throw new Error('档案不存在');
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
// 2. 计算八字哈希
|
| 385 |
+
const baziHash = computeBaziHash(
|
| 386 |
+
profile.yearPillar,
|
| 387 |
+
profile.monthPillar,
|
| 388 |
+
profile.dayPillar,
|
| 389 |
+
profile.hourPillar
|
| 390 |
+
);
|
| 391 |
+
|
| 392 |
+
// 3. 删除现有缓存
|
| 393 |
+
const db = getDb();
|
| 394 |
+
const deleteStmt = db.prepare(`
|
| 395 |
+
DELETE FROM bazi_analysis_cache
|
| 396 |
+
WHERE bazi_hash = ? AND gender = ?
|
| 397 |
+
`);
|
| 398 |
+
const result = deleteStmt.run(baziHash, profile.gender);
|
| 399 |
+
|
| 400 |
+
if (result.changes > 0) {
|
| 401 |
+
console.log(`[CoreDocEngine] 已删除旧缓存 - Hash: ${baziHash}, 删除条数: ${result.changes}`);
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
// 4. 记录重新生成日志
|
| 405 |
+
console.log(`[CoreDocEngine] 重新生成原因: ${reason}`);
|
| 406 |
+
|
| 407 |
+
// 5. 生成新文档(跳过缓存检查)
|
| 408 |
+
const newDocument = await generateCoreDocument(profile, true);
|
| 409 |
+
|
| 410 |
+
console.log(`[CoreDocEngine] 核心文档重新生成完成 - Profile ID: ${profileId}`);
|
| 411 |
+
|
| 412 |
+
return {
|
| 413 |
+
document: newDocument,
|
| 414 |
+
regenerated: true,
|
| 415 |
+
reason,
|
| 416 |
+
timestamp: nowIso()
|
| 417 |
+
};
|
| 418 |
+
|
| 419 |
+
} catch (error) {
|
| 420 |
+
console.error(`[CoreDocEngine] 重新生成核心文档失败:`, error);
|
| 421 |
+
throw new Error(`重新生成核心文档失败: ${error.message}`);
|
| 422 |
+
}
|
| 423 |
+
};
|
| 424 |
+
|
| 425 |
+
/**
|
| 426 |
+
* 批量生成核心文档(用于系统维护)
|
| 427 |
+
* @param {string[]} profileIds - 档案ID数组
|
| 428 |
+
* @returns {Promise<object>} 批量生成结果统计
|
| 429 |
+
*/
|
| 430 |
+
export const batchGenerateCoreDocuments = async (profileIds) => {
|
| 431 |
+
console.log(`[CoreDocEngine] 批量生成核心文档 - 数量: ${profileIds.length}`);
|
| 432 |
+
|
| 433 |
+
const results = {
|
| 434 |
+
total: profileIds.length,
|
| 435 |
+
success: 0,
|
| 436 |
+
failed: 0,
|
| 437 |
+
errors: []
|
| 438 |
+
};
|
| 439 |
+
|
| 440 |
+
for (const profileId of profileIds) {
|
| 441 |
+
try {
|
| 442 |
+
await generateCoreDocument({ id: profileId });
|
| 443 |
+
results.success++;
|
| 444 |
+
} catch (error) {
|
| 445 |
+
results.failed++;
|
| 446 |
+
results.errors.push({
|
| 447 |
+
profileId,
|
| 448 |
+
error: error.message
|
| 449 |
+
});
|
| 450 |
+
}
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
console.log(`[CoreDocEngine] 批量生成完成 - 成功: ${results.success}, 失败: ${results.failed}`);
|
| 454 |
+
|
| 455 |
+
return results;
|
| 456 |
+
};
|
| 457 |
+
|
| 458 |
+
export default {
|
| 459 |
+
generateCoreDocument,
|
| 460 |
+
getCoreDocument,
|
| 461 |
+
validateCoreDocument,
|
| 462 |
+
regenerateCoreDocument,
|
| 463 |
+
batchGenerateCoreDocuments
|
| 464 |
+
};
|
server/database.js
CHANGED
|
@@ -172,12 +172,26 @@ const initDatabase = () => {
|
|
| 172 |
first_da_yun TEXT,
|
| 173 |
birth_place TEXT,
|
| 174 |
is_default INTEGER DEFAULT 0 CHECK (is_default IN (0, 1)),
|
|
|
|
|
|
|
|
|
|
| 175 |
created_at TEXT NOT NULL,
|
| 176 |
updated_at TEXT,
|
| 177 |
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 178 |
)
|
| 179 |
`);
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
// 分享奖励表(基于信任,无限制)
|
| 182 |
db.exec(`
|
| 183 |
CREATE TABLE IF NOT EXISTS share_rewards (
|
|
@@ -317,6 +331,58 @@ const initDatabase = () => {
|
|
| 317 |
)
|
| 318 |
`);
|
| 319 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
// 创建索引
|
| 321 |
db.exec(`
|
| 322 |
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
@@ -353,6 +419,17 @@ const initDatabase = () => {
|
|
| 353 |
|
| 354 |
-- 日度K线缓存索引
|
| 355 |
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_kline_profile_yearmonth ON daily_kline_cache(profile_id, year, month);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
`);
|
| 357 |
|
| 358 |
// 名人案例表新增字段 (Schema Migration)
|
|
@@ -1049,8 +1126,8 @@ export const incrementCelebrityCaseView = (id) => {
|
|
| 1049 |
|
| 1050 |
// 创建用户档案
|
| 1051 |
export const createUserProfile = (input) => {
|
| 1052 |
-
// 检查用户档案数量限制
|
| 1053 |
-
const countStmt = db.prepare('SELECT COUNT(*) as count FROM user_profiles WHERE user_id = ?');
|
| 1054 |
const { count } = countStmt.get(input.userId);
|
| 1055 |
|
| 1056 |
if (count >= 10) {
|
|
@@ -1093,11 +1170,11 @@ export const createUserProfile = (input) => {
|
|
| 1093 |
return { ...input, id: input.id, createdAt: now, updatedAt: now };
|
| 1094 |
};
|
| 1095 |
|
| 1096 |
-
// 获取用户的所有档案
|
| 1097 |
export const getUserProfiles = (userId, limit = 10) => {
|
| 1098 |
const stmt = db.prepare(`
|
| 1099 |
SELECT * FROM user_profiles
|
| 1100 |
-
WHERE user_id = ?
|
| 1101 |
ORDER BY is_default DESC, created_at DESC
|
| 1102 |
LIMIT ?
|
| 1103 |
`);
|
|
@@ -1115,14 +1192,15 @@ export const getUserProfiles = (userId, limit = 10) => {
|
|
| 1115 |
firstDaYun: row.first_da_yun,
|
| 1116 |
birthPlace: row.birth_place,
|
| 1117 |
isDefault: row.is_default === 1,
|
|
|
|
| 1118 |
createdAt: row.created_at,
|
| 1119 |
updatedAt: row.updated_at,
|
| 1120 |
}));
|
| 1121 |
};
|
| 1122 |
|
| 1123 |
-
// 根据ID获取档案
|
| 1124 |
export const getUserProfileById = (id) => {
|
| 1125 |
-
const stmt = db.prepare('SELECT * FROM user_profiles WHERE id = ?');
|
| 1126 |
const row = stmt.get(id);
|
| 1127 |
if (!row) return null;
|
| 1128 |
|
|
@@ -1140,6 +1218,7 @@ export const getUserProfileById = (id) => {
|
|
| 1140 |
firstDaYun: row.first_da_yun,
|
| 1141 |
birthPlace: row.birth_place,
|
| 1142 |
isDefault: row.is_default === 1,
|
|
|
|
| 1143 |
createdAt: row.created_at,
|
| 1144 |
updatedAt: row.updated_at,
|
| 1145 |
};
|
|
@@ -1187,13 +1266,28 @@ export const setDefaultProfile = (userId, profileId) => {
|
|
| 1187 |
return result.changes > 0;
|
| 1188 |
};
|
| 1189 |
|
| 1190 |
-
// 删除用户档案
|
| 1191 |
export const deleteUserProfile = (id) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1192 |
const stmt = db.prepare('DELETE FROM user_profiles WHERE id = ?');
|
| 1193 |
const result = stmt.run(id);
|
| 1194 |
return result.changes > 0;
|
| 1195 |
};
|
| 1196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1197 |
// ============ Share Rewards ============
|
| 1198 |
|
| 1199 |
// 创建分享奖励记录
|
|
@@ -1670,3 +1764,233 @@ export const redeemVoucher = (code, userId) => {
|
|
| 1670 |
message: `成功兑换 ${voucher.points} 点券`,
|
| 1671 |
};
|
| 1672 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
first_da_yun TEXT,
|
| 173 |
birth_place TEXT,
|
| 174 |
is_default INTEGER DEFAULT 0 CHECK (is_default IN (0, 1)),
|
| 175 |
+
is_deleted INTEGER DEFAULT 0 CHECK (is_deleted IN (0, 1)),
|
| 176 |
+
deleted_at TEXT,
|
| 177 |
+
core_document_status TEXT DEFAULT 'pending' CHECK (core_document_status IN ('pending', 'generating', 'ready', 'failed')),
|
| 178 |
created_at TEXT NOT NULL,
|
| 179 |
updated_at TEXT,
|
| 180 |
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 181 |
)
|
| 182 |
`);
|
| 183 |
|
| 184 |
+
// 添加软删除和核心文档状态字段(如果不存在)
|
| 185 |
+
try {
|
| 186 |
+
db.exec(`ALTER TABLE user_profiles ADD COLUMN is_deleted INTEGER DEFAULT 0`);
|
| 187 |
+
} catch (e) { /* 列已存在 */ }
|
| 188 |
+
try {
|
| 189 |
+
db.exec(`ALTER TABLE user_profiles ADD COLUMN deleted_at TEXT`);
|
| 190 |
+
} catch (e) { /* 列已存在 */ }
|
| 191 |
+
try {
|
| 192 |
+
db.exec(`ALTER TABLE user_profiles ADD COLUMN core_document_status TEXT DEFAULT 'pending'`);
|
| 193 |
+
} catch (e) { /* 列已存在 */ }
|
| 194 |
+
|
| 195 |
// 分享奖励表(基于信任,无限制)
|
| 196 |
db.exec(`
|
| 197 |
CREATE TABLE IF NOT EXISTS share_rewards (
|
|
|
|
| 331 |
)
|
| 332 |
`);
|
| 333 |
|
| 334 |
+
// 邮箱订阅表
|
| 335 |
+
db.exec(`
|
| 336 |
+
CREATE TABLE IF NOT EXISTS email_subscriptions (
|
| 337 |
+
id TEXT PRIMARY KEY,
|
| 338 |
+
user_id TEXT NOT NULL UNIQUE,
|
| 339 |
+
email_verified INTEGER DEFAULT 0,
|
| 340 |
+
verification_token TEXT,
|
| 341 |
+
verification_sent_at TEXT,
|
| 342 |
+
verified_at TEXT,
|
| 343 |
+
sub_daily_fortune INTEGER DEFAULT 0,
|
| 344 |
+
sub_monthly_fortune INTEGER DEFAULT 0,
|
| 345 |
+
sub_yearly_fortune INTEGER DEFAULT 0,
|
| 346 |
+
sub_birthday_reminder INTEGER DEFAULT 0,
|
| 347 |
+
sub_low_points INTEGER DEFAULT 1,
|
| 348 |
+
sub_feature_updates INTEGER DEFAULT 1,
|
| 349 |
+
sub_promotions INTEGER DEFAULT 0,
|
| 350 |
+
binding_reward_claimed INTEGER DEFAULT 0,
|
| 351 |
+
subscription_reward_claimed INTEGER DEFAULT 0,
|
| 352 |
+
created_at TEXT NOT NULL,
|
| 353 |
+
updated_at TEXT,
|
| 354 |
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 355 |
+
)
|
| 356 |
+
`);
|
| 357 |
+
|
| 358 |
+
// 邮件日志表
|
| 359 |
+
db.exec(`
|
| 360 |
+
CREATE TABLE IF NOT EXISTS email_logs (
|
| 361 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 362 |
+
user_id TEXT,
|
| 363 |
+
email_type TEXT NOT NULL,
|
| 364 |
+
recipient TEXT NOT NULL,
|
| 365 |
+
subject TEXT NOT NULL,
|
| 366 |
+
status TEXT DEFAULT 'pending',
|
| 367 |
+
error_message TEXT,
|
| 368 |
+
sent_at TEXT,
|
| 369 |
+
created_at TEXT NOT NULL
|
| 370 |
+
)
|
| 371 |
+
`);
|
| 372 |
+
|
| 373 |
+
// 密码重置令牌表
|
| 374 |
+
db.exec(`
|
| 375 |
+
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
| 376 |
+
id TEXT PRIMARY KEY,
|
| 377 |
+
user_id TEXT NOT NULL,
|
| 378 |
+
token TEXT NOT NULL UNIQUE,
|
| 379 |
+
expires_at TEXT NOT NULL,
|
| 380 |
+
used INTEGER DEFAULT 0,
|
| 381 |
+
created_at TEXT NOT NULL,
|
| 382 |
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 383 |
+
)
|
| 384 |
+
`);
|
| 385 |
+
|
| 386 |
// 创建索引
|
| 387 |
db.exec(`
|
| 388 |
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
|
|
| 419 |
|
| 420 |
-- 日度K线缓存索引
|
| 421 |
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_kline_profile_yearmonth ON daily_kline_cache(profile_id, year, month);
|
| 422 |
+
|
| 423 |
+
-- 邮箱订阅索引
|
| 424 |
+
CREATE INDEX IF NOT EXISTS idx_email_subscriptions_user_id ON email_subscriptions(user_id);
|
| 425 |
+
|
| 426 |
+
-- 邮件日志索引
|
| 427 |
+
CREATE INDEX IF NOT EXISTS idx_email_logs_user_id ON email_logs(user_id);
|
| 428 |
+
CREATE INDEX IF NOT EXISTS idx_email_logs_created_at ON email_logs(created_at);
|
| 429 |
+
|
| 430 |
+
-- 密码重置令牌索引
|
| 431 |
+
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token);
|
| 432 |
+
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);
|
| 433 |
`);
|
| 434 |
|
| 435 |
// 名人案例表新增字段 (Schema Migration)
|
|
|
|
| 1126 |
|
| 1127 |
// 创建用户档案
|
| 1128 |
export const createUserProfile = (input) => {
|
| 1129 |
+
// 检查用户档案数量限制(排除已删除)
|
| 1130 |
+
const countStmt = db.prepare('SELECT COUNT(*) as count FROM user_profiles WHERE user_id = ? AND (is_deleted = 0 OR is_deleted IS NULL)');
|
| 1131 |
const { count } = countStmt.get(input.userId);
|
| 1132 |
|
| 1133 |
if (count >= 10) {
|
|
|
|
| 1170 |
return { ...input, id: input.id, createdAt: now, updatedAt: now };
|
| 1171 |
};
|
| 1172 |
|
| 1173 |
+
// 获取用户的所有档案(排除已删除)
|
| 1174 |
export const getUserProfiles = (userId, limit = 10) => {
|
| 1175 |
const stmt = db.prepare(`
|
| 1176 |
SELECT * FROM user_profiles
|
| 1177 |
+
WHERE user_id = ? AND (is_deleted = 0 OR is_deleted IS NULL)
|
| 1178 |
ORDER BY is_default DESC, created_at DESC
|
| 1179 |
LIMIT ?
|
| 1180 |
`);
|
|
|
|
| 1192 |
firstDaYun: row.first_da_yun,
|
| 1193 |
birthPlace: row.birth_place,
|
| 1194 |
isDefault: row.is_default === 1,
|
| 1195 |
+
coreDocumentStatus: row.core_document_status || 'pending',
|
| 1196 |
createdAt: row.created_at,
|
| 1197 |
updatedAt: row.updated_at,
|
| 1198 |
}));
|
| 1199 |
};
|
| 1200 |
|
| 1201 |
+
// 根据ID获取档案(排��已删除)
|
| 1202 |
export const getUserProfileById = (id) => {
|
| 1203 |
+
const stmt = db.prepare('SELECT * FROM user_profiles WHERE id = ? AND (is_deleted = 0 OR is_deleted IS NULL)');
|
| 1204 |
const row = stmt.get(id);
|
| 1205 |
if (!row) return null;
|
| 1206 |
|
|
|
|
| 1218 |
firstDaYun: row.first_da_yun,
|
| 1219 |
birthPlace: row.birth_place,
|
| 1220 |
isDefault: row.is_default === 1,
|
| 1221 |
+
coreDocumentStatus: row.core_document_status || 'pending',
|
| 1222 |
createdAt: row.created_at,
|
| 1223 |
updatedAt: row.updated_at,
|
| 1224 |
};
|
|
|
|
| 1266 |
return result.changes > 0;
|
| 1267 |
};
|
| 1268 |
|
| 1269 |
+
// 软删除用户档案(前端调用)
|
| 1270 |
export const deleteUserProfile = (id) => {
|
| 1271 |
+
const now = new Date().toISOString();
|
| 1272 |
+
const stmt = db.prepare('UPDATE user_profiles SET is_deleted = 1, deleted_at = ?, updated_at = ? WHERE id = ?');
|
| 1273 |
+
const result = stmt.run(now, now, id);
|
| 1274 |
+
return result.changes > 0;
|
| 1275 |
+
};
|
| 1276 |
+
|
| 1277 |
+
// 硬删除用户档案(管理员用)
|
| 1278 |
+
export const hardDeleteUserProfile = (id) => {
|
| 1279 |
const stmt = db.prepare('DELETE FROM user_profiles WHERE id = ?');
|
| 1280 |
const result = stmt.run(id);
|
| 1281 |
return result.changes > 0;
|
| 1282 |
};
|
| 1283 |
|
| 1284 |
+
// 更新档案核心文档状态
|
| 1285 |
+
export const updateProfileCoreDocumentStatus = (id, status) => {
|
| 1286 |
+
const stmt = db.prepare('UPDATE user_profiles SET core_document_status = ?, updated_at = ? WHERE id = ?');
|
| 1287 |
+
const result = stmt.run(status, new Date().toISOString(), id);
|
| 1288 |
+
return result.changes > 0;
|
| 1289 |
+
};
|
| 1290 |
+
|
| 1291 |
// ============ Share Rewards ============
|
| 1292 |
|
| 1293 |
// 创建分享奖励记录
|
|
|
|
| 1764 |
message: `成功兑换 ${voucher.points} 点券`,
|
| 1765 |
};
|
| 1766 |
};
|
| 1767 |
+
|
| 1768 |
+
// ============ Email Subscriptions ============
|
| 1769 |
+
|
| 1770 |
+
// 创建邮箱订阅记录
|
| 1771 |
+
export const createEmailSubscription = (userId) => {
|
| 1772 |
+
const id = `email_sub_${userId}_${Date.now()}`;
|
| 1773 |
+
const now = new Date().toISOString();
|
| 1774 |
+
|
| 1775 |
+
const stmt = db.prepare(`
|
| 1776 |
+
INSERT INTO email_subscriptions (
|
| 1777 |
+
id, user_id, created_at, updated_at
|
| 1778 |
+
) VALUES (?, ?, ?, ?)
|
| 1779 |
+
`);
|
| 1780 |
+
|
| 1781 |
+
stmt.run(id, userId, now, now);
|
| 1782 |
+
|
| 1783 |
+
return {
|
| 1784 |
+
id,
|
| 1785 |
+
userId,
|
| 1786 |
+
emailVerified: false,
|
| 1787 |
+
verificationToken: null,
|
| 1788 |
+
verificationSentAt: null,
|
| 1789 |
+
verifiedAt: null,
|
| 1790 |
+
subDailyFortune: false,
|
| 1791 |
+
subMonthlyFortune: false,
|
| 1792 |
+
subYearlyFortune: false,
|
| 1793 |
+
subBirthdayReminder: false,
|
| 1794 |
+
subLowPoints: true,
|
| 1795 |
+
subFeatureUpdates: true,
|
| 1796 |
+
subPromotions: false,
|
| 1797 |
+
bindingRewardClaimed: false,
|
| 1798 |
+
subscriptionRewardClaimed: false,
|
| 1799 |
+
createdAt: now,
|
| 1800 |
+
updatedAt: now,
|
| 1801 |
+
};
|
| 1802 |
+
};
|
| 1803 |
+
|
| 1804 |
+
// 获取邮箱订阅记录
|
| 1805 |
+
export const getEmailSubscription = (userId) => {
|
| 1806 |
+
const stmt = db.prepare('SELECT * FROM email_subscriptions WHERE user_id = ?');
|
| 1807 |
+
const row = stmt.get(userId);
|
| 1808 |
+
if (!row) return null;
|
| 1809 |
+
|
| 1810 |
+
return {
|
| 1811 |
+
id: row.id,
|
| 1812 |
+
userId: row.user_id,
|
| 1813 |
+
emailVerified: row.email_verified === 1,
|
| 1814 |
+
verificationToken: row.verification_token,
|
| 1815 |
+
verificationSentAt: row.verification_sent_at,
|
| 1816 |
+
verifiedAt: row.verified_at,
|
| 1817 |
+
subDailyFortune: row.sub_daily_fortune === 1,
|
| 1818 |
+
subMonthlyFortune: row.sub_monthly_fortune === 1,
|
| 1819 |
+
subYearlyFortune: row.sub_yearly_fortune === 1,
|
| 1820 |
+
subBirthdayReminder: row.sub_birthday_reminder === 1,
|
| 1821 |
+
subLowPoints: row.sub_low_points === 1,
|
| 1822 |
+
subFeatureUpdates: row.sub_feature_updates === 1,
|
| 1823 |
+
subPromotions: row.sub_promotions === 1,
|
| 1824 |
+
bindingRewardClaimed: row.binding_reward_claimed === 1,
|
| 1825 |
+
subscriptionRewardClaimed: row.subscription_reward_claimed === 1,
|
| 1826 |
+
createdAt: row.created_at,
|
| 1827 |
+
updatedAt: row.updated_at,
|
| 1828 |
+
};
|
| 1829 |
+
};
|
| 1830 |
+
|
| 1831 |
+
// 更新邮箱订阅设置
|
| 1832 |
+
export const updateEmailSubscription = (userId, data) => {
|
| 1833 |
+
const fields = [];
|
| 1834 |
+
const values = [];
|
| 1835 |
+
|
| 1836 |
+
const fieldMap = {
|
| 1837 |
+
emailVerified: 'email_verified',
|
| 1838 |
+
verificationToken: 'verification_token',
|
| 1839 |
+
verificationSentAt: 'verification_sent_at',
|
| 1840 |
+
verifiedAt: 'verified_at',
|
| 1841 |
+
subDailyFortune: 'sub_daily_fortune',
|
| 1842 |
+
subMonthlyFortune: 'sub_monthly_fortune',
|
| 1843 |
+
subYearlyFortune: 'sub_yearly_fortune',
|
| 1844 |
+
subBirthdayReminder: 'sub_birthday_reminder',
|
| 1845 |
+
subLowPoints: 'sub_low_points',
|
| 1846 |
+
subFeatureUpdates: 'sub_feature_updates',
|
| 1847 |
+
subPromotions: 'sub_promotions',
|
| 1848 |
+
bindingRewardClaimed: 'binding_reward_claimed',
|
| 1849 |
+
subscriptionRewardClaimed: 'subscription_reward_claimed',
|
| 1850 |
+
};
|
| 1851 |
+
|
| 1852 |
+
Object.entries(data).forEach(([key, value]) => {
|
| 1853 |
+
if (value !== undefined && fieldMap[key]) {
|
| 1854 |
+
const dbKey = fieldMap[key];
|
| 1855 |
+
fields.push(`${dbKey} = ?`);
|
| 1856 |
+
// Convert boolean to integer for SQLite
|
| 1857 |
+
if (typeof value === 'boolean') {
|
| 1858 |
+
values.push(value ? 1 : 0);
|
| 1859 |
+
} else {
|
| 1860 |
+
values.push(value);
|
| 1861 |
+
}
|
| 1862 |
+
}
|
| 1863 |
+
});
|
| 1864 |
+
|
| 1865 |
+
if (fields.length === 0) return null;
|
| 1866 |
+
|
| 1867 |
+
fields.push('updated_at = ?');
|
| 1868 |
+
values.push(new Date().toISOString());
|
| 1869 |
+
values.push(userId);
|
| 1870 |
+
|
| 1871 |
+
const stmt = db.prepare(`
|
| 1872 |
+
UPDATE email_subscriptions
|
| 1873 |
+
SET ${fields.join(', ')}
|
| 1874 |
+
WHERE user_id = ?
|
| 1875 |
+
`);
|
| 1876 |
+
|
| 1877 |
+
stmt.run(...values);
|
| 1878 |
+
return getEmailSubscription(userId);
|
| 1879 |
+
};
|
| 1880 |
+
|
| 1881 |
+
// ============ Email Logs ============
|
| 1882 |
+
|
| 1883 |
+
// 创建邮件日志
|
| 1884 |
+
export const createEmailLog = (data) => {
|
| 1885 |
+
const stmt = db.prepare(`
|
| 1886 |
+
INSERT INTO email_logs (
|
| 1887 |
+
user_id, email_type, recipient, subject, status, error_message, sent_at, created_at
|
| 1888 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
| 1889 |
+
`);
|
| 1890 |
+
|
| 1891 |
+
const now = new Date().toISOString();
|
| 1892 |
+
const result = stmt.run(
|
| 1893 |
+
data.userId || null,
|
| 1894 |
+
data.emailType,
|
| 1895 |
+
data.recipient,
|
| 1896 |
+
data.subject,
|
| 1897 |
+
data.status || 'pending',
|
| 1898 |
+
data.errorMessage || null,
|
| 1899 |
+
data.sentAt || null,
|
| 1900 |
+
now
|
| 1901 |
+
);
|
| 1902 |
+
|
| 1903 |
+
return {
|
| 1904 |
+
id: result.lastInsertRowid,
|
| 1905 |
+
userId: data.userId,
|
| 1906 |
+
emailType: data.emailType,
|
| 1907 |
+
recipient: data.recipient,
|
| 1908 |
+
subject: data.subject,
|
| 1909 |
+
status: data.status || 'pending',
|
| 1910 |
+
errorMessage: data.errorMessage,
|
| 1911 |
+
sentAt: data.sentAt,
|
| 1912 |
+
createdAt: now,
|
| 1913 |
+
};
|
| 1914 |
+
};
|
| 1915 |
+
|
| 1916 |
+
// 获取用户邮件日志
|
| 1917 |
+
export const getEmailLogs = (userId, limit = 50, offset = 0) => {
|
| 1918 |
+
const stmt = db.prepare(`
|
| 1919 |
+
SELECT * FROM email_logs
|
| 1920 |
+
WHERE user_id = ?
|
| 1921 |
+
ORDER BY created_at DESC
|
| 1922 |
+
LIMIT ? OFFSET ?
|
| 1923 |
+
`);
|
| 1924 |
+
|
| 1925 |
+
return stmt.all(userId, limit, offset).map(row => ({
|
| 1926 |
+
id: row.id,
|
| 1927 |
+
userId: row.user_id,
|
| 1928 |
+
emailType: row.email_type,
|
| 1929 |
+
recipient: row.recipient,
|
| 1930 |
+
subject: row.subject,
|
| 1931 |
+
status: row.status,
|
| 1932 |
+
errorMessage: row.error_message,
|
| 1933 |
+
sentAt: row.sent_at,
|
| 1934 |
+
createdAt: row.created_at,
|
| 1935 |
+
}));
|
| 1936 |
+
};
|
| 1937 |
+
|
| 1938 |
+
// ============ Password Reset Tokens ============
|
| 1939 |
+
|
| 1940 |
+
// 创建密码重置令牌
|
| 1941 |
+
export const createPasswordResetToken = (userId, token, expiresAt) => {
|
| 1942 |
+
const id = `reset_${userId}_${Date.now()}`;
|
| 1943 |
+
const now = new Date().toISOString();
|
| 1944 |
+
|
| 1945 |
+
const stmt = db.prepare(`
|
| 1946 |
+
INSERT INTO password_reset_tokens (
|
| 1947 |
+
id, user_id, token, expires_at, created_at
|
| 1948 |
+
) VALUES (?, ?, ?, ?, ?)
|
| 1949 |
+
`);
|
| 1950 |
+
|
| 1951 |
+
stmt.run(id, userId, token, expiresAt, now);
|
| 1952 |
+
|
| 1953 |
+
return {
|
| 1954 |
+
id,
|
| 1955 |
+
userId,
|
| 1956 |
+
token,
|
| 1957 |
+
expiresAt,
|
| 1958 |
+
used: false,
|
| 1959 |
+
createdAt: now,
|
| 1960 |
+
};
|
| 1961 |
+
};
|
| 1962 |
+
|
| 1963 |
+
// 获取密码重置令牌
|
| 1964 |
+
export const getPasswordResetToken = (token) => {
|
| 1965 |
+
const stmt = db.prepare(`
|
| 1966 |
+
SELECT * FROM password_reset_tokens
|
| 1967 |
+
WHERE token = ? AND used = 0 AND expires_at > ?
|
| 1968 |
+
`);
|
| 1969 |
+
|
| 1970 |
+
const row = stmt.get(token, new Date().toISOString());
|
| 1971 |
+
if (!row) return null;
|
| 1972 |
+
|
| 1973 |
+
return {
|
| 1974 |
+
id: row.id,
|
| 1975 |
+
userId: row.user_id,
|
| 1976 |
+
token: row.token,
|
| 1977 |
+
expiresAt: row.expires_at,
|
| 1978 |
+
used: row.used === 1,
|
| 1979 |
+
createdAt: row.created_at,
|
| 1980 |
+
};
|
| 1981 |
+
};
|
| 1982 |
+
|
| 1983 |
+
// 标记令牌已使用
|
| 1984 |
+
export const markPasswordResetTokenUsed = (token) => {
|
| 1985 |
+
const stmt = db.prepare('UPDATE password_reset_tokens SET used = 1 WHERE token = ?');
|
| 1986 |
+
const result = stmt.run(token);
|
| 1987 |
+
return result.changes > 0;
|
| 1988 |
+
};
|
| 1989 |
+
|
| 1990 |
+
// 清理过期令牌
|
| 1991 |
+
export const cleanupExpiredTokens = () => {
|
| 1992 |
+
const stmt = db.prepare('DELETE FROM password_reset_tokens WHERE expires_at <= ? OR used = 1');
|
| 1993 |
+
const result = stmt.run(new Date().toISOString());
|
| 1994 |
+
return result.changes;
|
| 1995 |
+
};
|
| 1996 |
+
|
server/emailScheduler.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cron from 'node-cron';
|
| 2 |
+
import { Lunar } from 'lunar-javascript';
|
| 3 |
+
import { getDb } from './database.js';
|
| 4 |
+
import { sendFortuneReminder, sendLowPointsReminder } from './emailService.js';
|
| 5 |
+
|
| 6 |
+
// Start all scheduled tasks
|
| 7 |
+
export function startEmailScheduler() {
|
| 8 |
+
console.log('✓ 邮件定时任务调度器已启动');
|
| 9 |
+
|
| 10 |
+
// Daily tasks at 8 AM (fortune + birthday)
|
| 11 |
+
cron.schedule('0 8 * * *', async () => {
|
| 12 |
+
console.log('开始执行每日定时任务 (8:00 AM)...');
|
| 13 |
+
await sendDailyFortuneReminders();
|
| 14 |
+
await sendBirthdayReminders();
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
// Monthly at 9 AM on 1st
|
| 18 |
+
cron.schedule('0 9 1 * *', async () => {
|
| 19 |
+
console.log('开始执行每月定时任务 (9:00 AM, 1st)...');
|
| 20 |
+
await sendMonthlyFortuneReminders();
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
// Yearly at 9 AM on Dec 20
|
| 24 |
+
cron.schedule('0 9 20 12 *', async () => {
|
| 25 |
+
console.log('开始执行流年定时任务 (9:00 AM, Dec 20)...');
|
| 26 |
+
await sendYearlyFortuneReminders();
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
// Low points check every Monday 10 AM
|
| 30 |
+
cron.schedule('0 10 * * 1', async () => {
|
| 31 |
+
console.log('开始执行积分不足检查 (10:00 AM, Monday)...');
|
| 32 |
+
await sendLowPointsReminders();
|
| 33 |
+
});
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Helper functions:
|
| 37 |
+
|
| 38 |
+
async function sendDailyFortuneReminders() {
|
| 39 |
+
try {
|
| 40 |
+
const db = getDb();
|
| 41 |
+
|
| 42 |
+
// Query users with sub_daily_fortune = 1 and email_verified = 1
|
| 43 |
+
const stmt = db.prepare(`
|
| 44 |
+
SELECT u.id, u.email, es.user_id
|
| 45 |
+
FROM users u
|
| 46 |
+
INNER JOIN email_subscriptions es ON u.id = es.user_id
|
| 47 |
+
WHERE es.sub_daily_fortune = 1 AND es.email_verified = 1
|
| 48 |
+
`);
|
| 49 |
+
|
| 50 |
+
const users = stmt.all();
|
| 51 |
+
console.log(`找到 ${users.length} 位订阅每日运势的用户`);
|
| 52 |
+
|
| 53 |
+
let successCount = 0;
|
| 54 |
+
let errorCount = 0;
|
| 55 |
+
|
| 56 |
+
for (const user of users) {
|
| 57 |
+
try {
|
| 58 |
+
// Get user's profiles
|
| 59 |
+
const profileStmt = db.prepare(`
|
| 60 |
+
SELECT id, name FROM user_profiles
|
| 61 |
+
WHERE user_id = ? AND (is_deleted = 0 OR is_deleted IS NULL)
|
| 62 |
+
ORDER BY is_default DESC, created_at DESC
|
| 63 |
+
LIMIT 1
|
| 64 |
+
`);
|
| 65 |
+
|
| 66 |
+
const profile = profileStmt.get(user.id);
|
| 67 |
+
|
| 68 |
+
if (profile) {
|
| 69 |
+
await sendFortuneReminder(user.email, 'daily', profile.name);
|
| 70 |
+
successCount++;
|
| 71 |
+
console.log(`✓ 已发送每日运势提醒至 ${user.email} (${profile.name})`);
|
| 72 |
+
} else {
|
| 73 |
+
console.log(`⚠ 用户 ${user.email} 没有可用的档案,跳过`);
|
| 74 |
+
}
|
| 75 |
+
} catch (error) {
|
| 76 |
+
errorCount++;
|
| 77 |
+
console.error(`✗ 发送每日运势提醒失败 (${user.email}):`, error.message);
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
console.log(`每日运势提醒完成: 成功 ${successCount}, 失败 ${errorCount}`);
|
| 82 |
+
} catch (error) {
|
| 83 |
+
console.error('执行每日运势提醒任务失败:', error);
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
async function sendMonthlyFortuneReminders() {
|
| 88 |
+
try {
|
| 89 |
+
const db = getDb();
|
| 90 |
+
|
| 91 |
+
// Query users with sub_monthly_fortune = 1 and email_verified = 1
|
| 92 |
+
const stmt = db.prepare(`
|
| 93 |
+
SELECT u.id, u.email, es.user_id
|
| 94 |
+
FROM users u
|
| 95 |
+
INNER JOIN email_subscriptions es ON u.id = es.user_id
|
| 96 |
+
WHERE es.sub_monthly_fortune = 1 AND es.email_verified = 1
|
| 97 |
+
`);
|
| 98 |
+
|
| 99 |
+
const users = stmt.all();
|
| 100 |
+
console.log(`找到 ${users.length} 位订阅每月运势的用户`);
|
| 101 |
+
|
| 102 |
+
let successCount = 0;
|
| 103 |
+
let errorCount = 0;
|
| 104 |
+
|
| 105 |
+
for (const user of users) {
|
| 106 |
+
try {
|
| 107 |
+
// Get user's profiles
|
| 108 |
+
const profileStmt = db.prepare(`
|
| 109 |
+
SELECT id, name FROM user_profiles
|
| 110 |
+
WHERE user_id = ? AND (is_deleted = 0 OR is_deleted IS NULL)
|
| 111 |
+
ORDER BY is_default DESC, created_at DESC
|
| 112 |
+
LIMIT 1
|
| 113 |
+
`);
|
| 114 |
+
|
| 115 |
+
const profile = profileStmt.get(user.id);
|
| 116 |
+
|
| 117 |
+
if (profile) {
|
| 118 |
+
await sendFortuneReminder(user.email, 'monthly', profile.name);
|
| 119 |
+
successCount++;
|
| 120 |
+
console.log(`✓ 已发送每月运势提醒至 ${user.email} (${profile.name})`);
|
| 121 |
+
} else {
|
| 122 |
+
console.log(`⚠ 用户 ${user.email} 没有可用的档案,跳过`);
|
| 123 |
+
}
|
| 124 |
+
} catch (error) {
|
| 125 |
+
errorCount++;
|
| 126 |
+
console.error(`✗ 发送每月运势提醒失败 (${user.email}):`, error.message);
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
console.log(`每月运势提醒完成: 成功 ${successCount}, 失败 ${errorCount}`);
|
| 131 |
+
} catch (error) {
|
| 132 |
+
console.error('执行每月运势提醒任务失败:', error);
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
async function sendYearlyFortuneReminders() {
|
| 137 |
+
try {
|
| 138 |
+
const db = getDb();
|
| 139 |
+
|
| 140 |
+
// Query users with sub_yearly_fortune = 1 and email_verified = 1
|
| 141 |
+
const stmt = db.prepare(`
|
| 142 |
+
SELECT u.id, u.email, es.user_id
|
| 143 |
+
FROM users u
|
| 144 |
+
INNER JOIN email_subscriptions es ON u.id = es.user_id
|
| 145 |
+
WHERE es.sub_yearly_fortune = 1 AND es.email_verified = 1
|
| 146 |
+
`);
|
| 147 |
+
|
| 148 |
+
const users = stmt.all();
|
| 149 |
+
console.log(`找到 ${users.length} 位订阅流年运势的用户`);
|
| 150 |
+
|
| 151 |
+
let successCount = 0;
|
| 152 |
+
let errorCount = 0;
|
| 153 |
+
|
| 154 |
+
for (const user of users) {
|
| 155 |
+
try {
|
| 156 |
+
// Get user's profiles
|
| 157 |
+
const profileStmt = db.prepare(`
|
| 158 |
+
SELECT id, name FROM user_profiles
|
| 159 |
+
WHERE user_id = ? AND (is_deleted = 0 OR is_deleted IS NULL)
|
| 160 |
+
ORDER BY is_default DESC, created_at DESC
|
| 161 |
+
LIMIT 1
|
| 162 |
+
`);
|
| 163 |
+
|
| 164 |
+
const profile = profileStmt.get(user.id);
|
| 165 |
+
|
| 166 |
+
if (profile) {
|
| 167 |
+
await sendFortuneReminder(user.email, 'yearly', profile.name);
|
| 168 |
+
successCount++;
|
| 169 |
+
console.log(`✓ 已发送流年运势提醒至 ${user.email} (${profile.name})`);
|
| 170 |
+
} else {
|
| 171 |
+
console.log(`⚠ 用户 ${user.email} 没有可用的档案,跳过`);
|
| 172 |
+
}
|
| 173 |
+
} catch (error) {
|
| 174 |
+
errorCount++;
|
| 175 |
+
console.error(`✗ 发送流年运势提醒失败 (${user.email}):`, error.message);
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
console.log(`流年运势提醒完成: 成功 ${successCount}, 失败 ${errorCount}`);
|
| 180 |
+
} catch (error) {
|
| 181 |
+
console.error('执行流年运势提醒任务失败:', error);
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
async function sendLowPointsReminders() {
|
| 186 |
+
try {
|
| 187 |
+
const db = getDb();
|
| 188 |
+
|
| 189 |
+
// Query users with points < 100 and sub_low_points = 1 and email_verified = 1
|
| 190 |
+
const stmt = db.prepare(`
|
| 191 |
+
SELECT u.id, u.email, u.points
|
| 192 |
+
FROM users u
|
| 193 |
+
INNER JOIN email_subscriptions es ON u.id = es.user_id
|
| 194 |
+
WHERE u.points < 100 AND es.sub_low_points = 1 AND es.email_verified = 1
|
| 195 |
+
`);
|
| 196 |
+
|
| 197 |
+
const users = stmt.all();
|
| 198 |
+
console.log(`找到 ${users.length} 位积分不足的用户`);
|
| 199 |
+
|
| 200 |
+
let successCount = 0;
|
| 201 |
+
let errorCount = 0;
|
| 202 |
+
|
| 203 |
+
for (const user of users) {
|
| 204 |
+
try {
|
| 205 |
+
await sendLowPointsReminder(user.email, user.points);
|
| 206 |
+
successCount++;
|
| 207 |
+
console.log(`✓ 已发送积分不足提醒至 ${user.email} (剩余 ${user.points} 点)`);
|
| 208 |
+
} catch (error) {
|
| 209 |
+
errorCount++;
|
| 210 |
+
console.error(`✗ 发送积分不足提醒失败 (${user.email}):`, error.message);
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
console.log(`积分不足提醒完成: 成功 ${successCount}, 失败 ${errorCount}`);
|
| 215 |
+
} catch (error) {
|
| 216 |
+
console.error('执行积分不足提醒任务失败:', error);
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
async function sendBirthdayReminders() {
|
| 221 |
+
try {
|
| 222 |
+
const db = getDb();
|
| 223 |
+
|
| 224 |
+
// Get today's lunar date
|
| 225 |
+
const today = new Date();
|
| 226 |
+
const todayLunar = Lunar.fromDate(today);
|
| 227 |
+
const todayLunarMonth = todayLunar.getMonth();
|
| 228 |
+
const todayLunarDay = todayLunar.getDay();
|
| 229 |
+
|
| 230 |
+
console.log(`今日农历: ${todayLunarMonth}月${todayLunarDay}日`);
|
| 231 |
+
|
| 232 |
+
// Query users with sub_birthday_reminder = 1 and email_verified = 1
|
| 233 |
+
const stmt = db.prepare(`
|
| 234 |
+
SELECT u.id, u.email, es.user_id
|
| 235 |
+
FROM users u
|
| 236 |
+
INNER JOIN email_subscriptions es ON u.id = es.user_id
|
| 237 |
+
WHERE es.sub_birthday_reminder = 1 AND es.email_verified = 1
|
| 238 |
+
`);
|
| 239 |
+
|
| 240 |
+
const users = stmt.all();
|
| 241 |
+
console.log(`检查 ${users.length} 位订阅生日提醒的用户`);
|
| 242 |
+
|
| 243 |
+
let successCount = 0;
|
| 244 |
+
let errorCount = 0;
|
| 245 |
+
let birthdayCount = 0;
|
| 246 |
+
|
| 247 |
+
for (const user of users) {
|
| 248 |
+
try {
|
| 249 |
+
// Get all user's profiles (check all in case they have multiple people)
|
| 250 |
+
const profileStmt = db.prepare(`
|
| 251 |
+
SELECT id, name, birth_year, month_pillar, day_pillar
|
| 252 |
+
FROM user_profiles
|
| 253 |
+
WHERE user_id = ? AND (is_deleted = 0 OR is_deleted IS NULL)
|
| 254 |
+
AND birth_year IS NOT NULL
|
| 255 |
+
`);
|
| 256 |
+
|
| 257 |
+
const profiles = profileStmt.all(user.id);
|
| 258 |
+
|
| 259 |
+
for (const profile of profiles) {
|
| 260 |
+
try {
|
| 261 |
+
// Try to extract lunar birthday from birth_year and pillars
|
| 262 |
+
// For a more accurate implementation, we would need the full birth date
|
| 263 |
+
// For now, we'll use a simplified approach:
|
| 264 |
+
// Convert the Gregorian birth year to lunar and check the month/day
|
| 265 |
+
|
| 266 |
+
// Since we don't have the exact birth date stored, we'll check if:
|
| 267 |
+
// 1. The profile has complete pillar information
|
| 268 |
+
// 2. We can derive a lunar birthday from the available data
|
| 269 |
+
|
| 270 |
+
// This is a simplified version - in production you'd want to store
|
| 271 |
+
// the actual birth date (both Gregorian and Lunar) in the database
|
| 272 |
+
|
| 273 |
+
// For now, let's skip profiles without complete birth info
|
| 274 |
+
if (!profile.birth_year || !profile.month_pillar || !profile.day_pillar) {
|
| 275 |
+
continue;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
// Extract month and day from pillars if possible
|
| 279 |
+
// The month_pillar and day_pillar contain heavenly stems and earthly branches
|
| 280 |
+
// which can be used to derive the lunar date
|
| 281 |
+
|
| 282 |
+
// This is a simplified approach - we'd need more sophisticated parsing
|
| 283 |
+
// For a basic implementation, we can't accurately determine lunar birthday
|
| 284 |
+
// from pillars alone without the full birth date
|
| 285 |
+
|
| 286 |
+
// TODO: Add a birth_date field to user_profiles table for accurate birthday tracking
|
| 287 |
+
console.log(`⚠ 生日提醒功能需要完整的出生日期信息 (用户: ${user.email}, 档案: ${profile.name})`);
|
| 288 |
+
|
| 289 |
+
} catch (profileError) {
|
| 290 |
+
console.error(`检查档案生日失败 (${profile.name}):`, profileError.message);
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
} catch (error) {
|
| 294 |
+
errorCount++;
|
| 295 |
+
console.error(`✗ 检查生日失败 (${user.email}):`, error.message);
|
| 296 |
+
}
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
console.log(`生日提醒检查完成: 今日生日 ${birthdayCount}, 成功发送 ${successCount}, 失败 ${errorCount}`);
|
| 300 |
+
console.log(`提示: 生日提醒功能需要在 user_profiles 表中添加完整的 birth_date 字段以实现精确匹配`);
|
| 301 |
+
} catch (error) {
|
| 302 |
+
console.error('执行生日提醒任务失败:', error);
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
export default {
|
| 307 |
+
startEmailScheduler
|
| 308 |
+
};
|
server/emailService.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import nodemailer from 'nodemailer';
|
| 2 |
+
|
| 3 |
+
const BASE_URL = process.env.BASE_URL || 'https://www.life-kline.com';
|
| 4 |
+
|
| 5 |
+
// Create transporter with config
|
| 6 |
+
const transporter = nodemailer.createTransport({
|
| 7 |
+
host: process.env.MAIL_SMTP_HOST || 'mail.life-kline.cn',
|
| 8 |
+
port: parseInt(process.env.MAIL_SMTP_PORT) || 25,
|
| 9 |
+
secure: process.env.MAIL_SMTP_SECURE === 'true',
|
| 10 |
+
tls: {
|
| 11 |
+
rejectUnauthorized: false
|
| 12 |
+
},
|
| 13 |
+
auth: {
|
| 14 |
+
user: process.env.MAIL_FROM || 'code@life-kline.com',
|
| 15 |
+
pass: process.env.MAIL_PASSWORD
|
| 16 |
+
}
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
// Email template wrapper with branding and footer
|
| 20 |
+
function emailTemplate(content, title = '人生k线') {
|
| 21 |
+
return `
|
| 22 |
+
<!DOCTYPE html>
|
| 23 |
+
<html lang="zh-CN">
|
| 24 |
+
<head>
|
| 25 |
+
<meta charset="UTF-8">
|
| 26 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 27 |
+
<title>${title}</title>
|
| 28 |
+
</head>
|
| 29 |
+
<body style="margin: 0; padding: 0; font-family: 'Microsoft YaHei', 'PingFang SC', Arial, sans-serif; background-color: #f5f5f5;">
|
| 30 |
+
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 20px 0;">
|
| 31 |
+
<tr>
|
| 32 |
+
<td align="center">
|
| 33 |
+
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); max-width: 100%;">
|
| 34 |
+
<!-- Header -->
|
| 35 |
+
<tr>
|
| 36 |
+
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px 40px; text-align: center;">
|
| 37 |
+
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: bold;">人生k线</h1>
|
| 38 |
+
<p style="margin: 10px 0 0 0; color: #f0f0f0; font-size: 14px;">命理加强版</p>
|
| 39 |
+
</td>
|
| 40 |
+
</tr>
|
| 41 |
+
<!-- Content -->
|
| 42 |
+
<tr>
|
| 43 |
+
<td style="padding: 40px;">
|
| 44 |
+
${content}
|
| 45 |
+
</td>
|
| 46 |
+
</tr>
|
| 47 |
+
<!-- Footer -->
|
| 48 |
+
<tr>
|
| 49 |
+
<td style="background-color: #f9f9f9; padding: 30px 40px; text-align: center; border-top: 1px solid #e0e0e0;">
|
| 50 |
+
<p style="margin: 0 0 10px 0; color: #666666; font-size: 13px;">
|
| 51 |
+
这是一封来自人生k线的系统邮件,请勿直接回复。
|
| 52 |
+
</p>
|
| 53 |
+
<p style="margin: 0 0 15px 0; color: #666666; font-size: 13px;">
|
| 54 |
+
如有任何问题,请联系我们的客服团队。
|
| 55 |
+
</p>
|
| 56 |
+
<p style="margin: 0; color: #999999; font-size: 12px;">
|
| 57 |
+
<a href="${BASE_URL}/unsubscribe" style="color: #667eea; text-decoration: none;">取消订阅</a> |
|
| 58 |
+
<a href="${BASE_URL}" style="color: #667eea; text-decoration: none;">访问网站</a>
|
| 59 |
+
</p>
|
| 60 |
+
<p style="margin: 15px 0 0 0; color: #999999; font-size: 11px;">
|
| 61 |
+
© ${new Date().getFullYear()} 人生k线. All rights reserved.
|
| 62 |
+
</p>
|
| 63 |
+
</td>
|
| 64 |
+
</tr>
|
| 65 |
+
</table>
|
| 66 |
+
</td>
|
| 67 |
+
</tr>
|
| 68 |
+
</table>
|
| 69 |
+
</body>
|
| 70 |
+
</html>
|
| 71 |
+
`.trim();
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// Button component for emails
|
| 75 |
+
function buttonHTML(text, url, color = '#667eea') {
|
| 76 |
+
return `
|
| 77 |
+
<div style="text-align: center; margin: 30px 0;">
|
| 78 |
+
<a href="${url}" style="display: inline-block; background-color: ${color}; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 6px; font-size: 16px; font-weight: bold; box-shadow: 0 4px 6px rgba(102, 126, 234, 0.3);">
|
| 79 |
+
${text}
|
| 80 |
+
</a>
|
| 81 |
+
</div>
|
| 82 |
+
`;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// Base send function
|
| 86 |
+
export async function sendEmail(to, subject, html, text) {
|
| 87 |
+
try {
|
| 88 |
+
const info = await transporter.sendMail({
|
| 89 |
+
from: `"人生k线" <${process.env.MAIL_FROM || 'code@life-kline.com'}>`,
|
| 90 |
+
to,
|
| 91 |
+
subject: `人生k线命理加强版 - ${subject}`,
|
| 92 |
+
html,
|
| 93 |
+
text
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
console.log('Email sent successfully:', info.messageId);
|
| 97 |
+
return { success: true, messageId: info.messageId };
|
| 98 |
+
} catch (error) {
|
| 99 |
+
console.error('Error sending email:', error);
|
| 100 |
+
throw error;
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// Send verification email
|
| 105 |
+
export async function sendVerificationEmail(email, token) {
|
| 106 |
+
const verificationUrl = `${BASE_URL}/verify-email?token=${token}`;
|
| 107 |
+
|
| 108 |
+
const content = `
|
| 109 |
+
<h2 style="color: #333333; font-size: 24px; margin: 0 0 20px 0;">验证您的邮箱</h2>
|
| 110 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">
|
| 111 |
+
您好!
|
| 112 |
+
</p>
|
| 113 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">
|
| 114 |
+
感谢您注册人生k线。请点击下方按钮验证您的邮箱地址:
|
| 115 |
+
</p>
|
| 116 |
+
${buttonHTML('验证邮箱', verificationUrl)}
|
| 117 |
+
<p style="color: #999999; font-size: 14px; line-height: 1.6; margin: 20px 0 0 0;">
|
| 118 |
+
此验证链接将在 <strong>24小时</strong> 后失效。如果您没有注册人生k线账户,请忽略此邮件。
|
| 119 |
+
</p>
|
| 120 |
+
<p style="color: #999999; font-size: 13px; line-height: 1.6; margin: 20px 0 0 0; padding: 15px; background-color: #f9f9f9; border-left: 3px solid #667eea; border-radius: 4px;">
|
| 121 |
+
如果按钮无法点击,请复制以下链接到浏览���访问:<br>
|
| 122 |
+
<a href="${verificationUrl}" style="color: #667eea; word-break: break-all;">${verificationUrl}</a>
|
| 123 |
+
</p>
|
| 124 |
+
`;
|
| 125 |
+
|
| 126 |
+
const text = `
|
| 127 |
+
验证您的邮箱
|
| 128 |
+
|
| 129 |
+
您好!
|
| 130 |
+
|
| 131 |
+
感谢您注册人生k线。请访问以下链接验证您的邮箱地址:
|
| 132 |
+
${verificationUrl}
|
| 133 |
+
|
| 134 |
+
此验证链接将在24小时后失效。如果您没有注册人生k线账户,请忽略此邮件。
|
| 135 |
+
`.trim();
|
| 136 |
+
|
| 137 |
+
return sendEmail(email, '验证您的邮箱', emailTemplate(content), text);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// Send password reset email
|
| 141 |
+
export async function sendPasswordResetEmail(email, token) {
|
| 142 |
+
const resetUrl = `${BASE_URL}/reset-password?token=${token}`;
|
| 143 |
+
|
| 144 |
+
const content = `
|
| 145 |
+
<h2 style="color: #333333; font-size: 24px; margin: 0 0 20px 0;">重置您的密码</h2>
|
| 146 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">
|
| 147 |
+
您好!
|
| 148 |
+
</p>
|
| 149 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">
|
| 150 |
+
我们收到了重置您账户密码的请求。请点击下方按钮重置密码:
|
| 151 |
+
</p>
|
| 152 |
+
${buttonHTML('重置密码', resetUrl, '#e74c3c')}
|
| 153 |
+
<p style="color: #999999; font-size: 14px; line-height: 1.6; margin: 20px 0 0 0;">
|
| 154 |
+
此重置链接将在 <strong>1小时</strong> 后失效。如果您没有请求重置密码,请忽略此邮件,您的账户仍然安全。
|
| 155 |
+
</p>
|
| 156 |
+
<p style="color: #999999; font-size: 13px; line-height: 1.6; margin: 20px 0 0 0; padding: 15px; background-color: #f9f9f9; border-left: 3px solid #e74c3c; border-radius: 4px;">
|
| 157 |
+
如果按钮无法点击,请复制以下链接到浏览器访问:<br>
|
| 158 |
+
<a href="${resetUrl}" style="color: #e74c3c; word-break: break-all;">${resetUrl}</a>
|
| 159 |
+
</p>
|
| 160 |
+
`;
|
| 161 |
+
|
| 162 |
+
const text = `
|
| 163 |
+
重置您的密码
|
| 164 |
+
|
| 165 |
+
您好!
|
| 166 |
+
|
| 167 |
+
我们收到了重置您账户密码的请求。请访问以下链接重置密码:
|
| 168 |
+
${resetUrl}
|
| 169 |
+
|
| 170 |
+
此重置链接将在1小时后失效。如果您没有请求重置密码,请忽略此邮件,您的账户仍然安全。
|
| 171 |
+
`.trim();
|
| 172 |
+
|
| 173 |
+
return sendEmail(email, '重置密码', emailTemplate(content), text);
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// Send welcome email
|
| 177 |
+
export async function sendWelcomeEmail(email) {
|
| 178 |
+
const loginUrl = `${BASE_URL}/login`;
|
| 179 |
+
|
| 180 |
+
const content = `
|
| 181 |
+
<h2 style="color: #333333; font-size: 24px; margin: 0 0 20px 0;">欢迎加入人生k线!</h2>
|
| 182 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">
|
| 183 |
+
您好!
|
| 184 |
+
</p>
|
| 185 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">
|
| 186 |
+
欢迎加入人生k线命理加强版!我们很高兴您成为我们的一员。
|
| 187 |
+
</p>
|
| 188 |
+
<div style="background-color: #f0f4ff; padding: 20px; border-radius: 6px; margin: 20px 0;">
|
| 189 |
+
<h3 style="color: #667eea; font-size: 18px; margin: 0 0 15px 0;">您可以使用以下功能:</h3>
|
| 190 |
+
<ul style="color: #666666; font-size: 15px; line-height: 1.8; margin: 0; padding-left: 20px;">
|
| 191 |
+
<li>创建和管理您的命理档案</li>
|
| 192 |
+
<li>查看每日、每月、每年运势</li>
|
| 193 |
+
<li>获取个性化的命理分析</li>
|
| 194 |
+
<li>设置运势提醒,不错过重要时刻</li>
|
| 195 |
+
</ul>
|
| 196 |
+
</div>
|
| 197 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 20px 0;">
|
| 198 |
+
<strong style="color: #667eea;">新用户奖励:</strong>我们已为您准备了初始积分,快去探索吧!
|
| 199 |
+
</p>
|
| 200 |
+
${buttonHTML('立即开始', loginUrl)}
|
| 201 |
+
<p style="color: #999999; font-size: 14px; line-height: 1.6; margin: 20px 0 0 0; text-align: center;">
|
| 202 |
+
祝您使用愉快!
|
| 203 |
+
</p>
|
| 204 |
+
`;
|
| 205 |
+
|
| 206 |
+
const text = `
|
| 207 |
+
欢迎加入人生k线!
|
| 208 |
+
|
| 209 |
+
您好!
|
| 210 |
+
|
| 211 |
+
欢迎加入人生k线命理加强版!我们很高兴您成为我们的一员。
|
| 212 |
+
|
| 213 |
+
您可以使用以下功能:
|
| 214 |
+
- 创建和管理您的命理档案
|
| 215 |
+
- 查看每日、每月、每年运势
|
| 216 |
+
- 获取个性化的命理分析
|
| 217 |
+
- 设置运势提醒,不错过重要时刻
|
| 218 |
+
|
| 219 |
+
新用户奖励:我们已为您准备了初始积分,快去探索吧!
|
| 220 |
+
|
| 221 |
+
立即访问:${loginUrl}
|
| 222 |
+
|
| 223 |
+
祝您使用愉快!
|
| 224 |
+
`.trim();
|
| 225 |
+
|
| 226 |
+
return sendEmail(email, '欢迎加入人生k线!', emailTemplate(content), text);
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
// Send fortune reminder
|
| 230 |
+
export async function sendFortuneReminder(email, type, profileName) {
|
| 231 |
+
const loginUrl = `${BASE_URL}/fortune`;
|
| 232 |
+
|
| 233 |
+
const typeMap = {
|
| 234 |
+
daily: { title: '每日运势', emoji: '📅', desc: '新的一天开始了' },
|
| 235 |
+
monthly: { title: '每月运势', emoji: '📆', desc: '新的月份已经到来' },
|
| 236 |
+
yearly: { title: '流年运势', emoji: '🎊', desc: '新的一年开启新篇章' },
|
| 237 |
+
birthday: { title: '生日运势', emoji: '🎂', desc: '祝您生日快乐' }
|
| 238 |
+
};
|
| 239 |
+
|
| 240 |
+
const typeInfo = typeMap[type] || typeMap.daily;
|
| 241 |
+
|
| 242 |
+
const content = `
|
| 243 |
+
<h2 style="color: #333333; font-size: 24px; margin: 0 0 20px 0;">
|
| 244 |
+
${typeInfo.emoji} ${typeInfo.title}提醒
|
| 245 |
+
</h2>
|
| 246 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">
|
| 247 |
+
您好,<strong>${profileName}</strong>!
|
| 248 |
+
</p>
|
| 249 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">
|
| 250 |
+
${typeInfo.desc},您的${typeInfo.title}已更新,快来查看吧!
|
| 251 |
+
</p>
|
| 252 |
+
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 25px; border-radius: 8px; margin: 25px 0; text-align: center;">
|
| 253 |
+
<p style="color: #ffffff; font-size: 18px; margin: 0; font-weight: bold;">
|
| 254 |
+
了解运势,把握机遇
|
| 255 |
+
</p>
|
| 256 |
+
<p style="color: #f0f0f0; font-size: 14px; margin: 10px 0 0 0;">
|
| 257 |
+
点击下方按钮查看详细运势分析
|
| 258 |
+
</p>
|
| 259 |
+
</div>
|
| 260 |
+
${buttonHTML('查看运势', loginUrl)}
|
| 261 |
+
<p style="color: #999999; font-size: 14px; line-height: 1.6; margin: 20px 0 0 0; text-align: center;">
|
| 262 |
+
祝您好运常伴!
|
| 263 |
+
</p>
|
| 264 |
+
`;
|
| 265 |
+
|
| 266 |
+
const text = `
|
| 267 |
+
${typeInfo.title}提醒
|
| 268 |
+
|
| 269 |
+
您好,${profileName}!
|
| 270 |
+
|
| 271 |
+
${typeInfo.desc},您的${typeInfo.title}已更新,快来查看吧!
|
| 272 |
+
|
| 273 |
+
了解运势,把握机遇
|
| 274 |
+
|
| 275 |
+
访问链接查看详细运势分析:${loginUrl}
|
| 276 |
+
|
| 277 |
+
祝您好运常伴!
|
| 278 |
+
`.trim();
|
| 279 |
+
|
| 280 |
+
return sendEmail(email, `${typeInfo.title}提醒`, emailTemplate(content), text);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
// Send low points reminder
|
| 284 |
+
export async function sendLowPointsReminder(email, currentPoints) {
|
| 285 |
+
const rechargeUrl = `${BASE_URL}/recharge`;
|
| 286 |
+
|
| 287 |
+
const content = `
|
| 288 |
+
<h2 style="color: #333333; font-size: 24px; margin: 0 0 20px 0;">您的积分即将耗尽</h2>
|
| 289 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">
|
| 290 |
+
您好!
|
| 291 |
+
</p>
|
| 292 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">
|
| 293 |
+
您的账户积分即将不足,可能会影响您继续使用人生k线的服务。
|
| 294 |
+
</p>
|
| 295 |
+
<div style="background-color: #fff3cd; border: 2px solid #ffc107; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
|
| 296 |
+
<p style="color: #856404; font-size: 14px; margin: 0 0 10px 0;">当前剩余积分</p>
|
| 297 |
+
<p style="color: #856404; font-size: 36px; font-weight: bold; margin: 0;">
|
| 298 |
+
${currentPoints}
|
| 299 |
+
</p>
|
| 300 |
+
</div>
|
| 301 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 20px 0;">
|
| 302 |
+
为了不影响您的使用体验,建议您及时充值。我们提供多种充值方案供您选择。
|
| 303 |
+
</p>
|
| 304 |
+
${buttonHTML('立即充值', rechargeUrl, '#ffc107')}
|
| 305 |
+
<p style="color: #999999; font-size: 14px; line-height: 1.6; margin: 20px 0 0 0; text-align: center;">
|
| 306 |
+
感谢您对人生k线的支持!
|
| 307 |
+
</p>
|
| 308 |
+
`;
|
| 309 |
+
|
| 310 |
+
const text = `
|
| 311 |
+
您的积分即将耗尽
|
| 312 |
+
|
| 313 |
+
您好!
|
| 314 |
+
|
| 315 |
+
您的账户积分即将不足,可能会影响您继续使用人生k线的服务。
|
| 316 |
+
|
| 317 |
+
当前剩余积分:${currentPoints}
|
| 318 |
+
|
| 319 |
+
为了不影响您的使用体验,建议您及时充值。我们提供多种充值方案供您选择。
|
| 320 |
+
|
| 321 |
+
访问充值页面:${rechargeUrl}
|
| 322 |
+
|
| 323 |
+
感谢您对人生k线的支持!
|
| 324 |
+
`.trim();
|
| 325 |
+
|
| 326 |
+
return sendEmail(email, '您的积分即将耗尽', emailTemplate(content), text);
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
// Send feature update email
|
| 330 |
+
export async function sendFeatureUpdateEmail(email, features) {
|
| 331 |
+
const loginUrl = `${BASE_URL}/login`;
|
| 332 |
+
|
| 333 |
+
const featuresList = features.map(feature => `
|
| 334 |
+
<div style="margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid #e0e0e0;">
|
| 335 |
+
<h4 style="color: #667eea; font-size: 18px; margin: 0 0 10px 0;">
|
| 336 |
+
${feature.icon || '✨'} ${feature.title}
|
| 337 |
+
</h4>
|
| 338 |
+
<p style="color: #666666; font-size: 15px; line-height: 1.6; margin: 0;">
|
| 339 |
+
${feature.description}
|
| 340 |
+
</p>
|
| 341 |
+
</div>
|
| 342 |
+
`).join('');
|
| 343 |
+
|
| 344 |
+
const content = `
|
| 345 |
+
<h2 style="color: #333333; font-size: 24px; margin: 0 0 20px 0;">人生k线有新功能啦!</h2>
|
| 346 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">
|
| 347 |
+
您好!
|
| 348 |
+
</p>
|
| 349 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">
|
| 350 |
+
我们很高兴地通知您,人生k线推出了新功能,为您带来更好的使用体验!
|
| 351 |
+
</p>
|
| 352 |
+
<div style="background-color: #f9f9f9; padding: 25px; border-radius: 8px; margin: 25px 0;">
|
| 353 |
+
${featuresList}
|
| 354 |
+
</div>
|
| 355 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 20px 0;">
|
| 356 |
+
快来体验这些全新功能吧!
|
| 357 |
+
</p>
|
| 358 |
+
${buttonHTML('立即体验', loginUrl)}
|
| 359 |
+
<p style="color: #999999; font-size: 14px; line-height: 1.6; margin: 20px 0 0 0; text-align: center;">
|
| 360 |
+
感谢您一直以来的支持!
|
| 361 |
+
</p>
|
| 362 |
+
`;
|
| 363 |
+
|
| 364 |
+
const featuresText = features.map(f => `- ${f.title}\n ${f.description}`).join('\n\n');
|
| 365 |
+
|
| 366 |
+
const text = `
|
| 367 |
+
人生k线有新功能啦!
|
| 368 |
+
|
| 369 |
+
您好!
|
| 370 |
+
|
| 371 |
+
我们很高兴地通知您,人生k线推出了新功能,为您带来更好的使用体验!
|
| 372 |
+
|
| 373 |
+
新功能列表:
|
| 374 |
+
${featuresText}
|
| 375 |
+
|
| 376 |
+
快来体验这些全新功能吧!
|
| 377 |
+
|
| 378 |
+
访问链接:${loginUrl}
|
| 379 |
+
|
| 380 |
+
感谢您一直以来的支持!
|
| 381 |
+
`.trim();
|
| 382 |
+
|
| 383 |
+
return sendEmail(email, '人生k线有新功能啦!', emailTemplate(content), text);
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
// Send promotional email
|
| 387 |
+
export async function sendPromotionalEmail(email, promotion) {
|
| 388 |
+
const promotionUrl = `${BASE_URL}/promotion/${promotion.id}`;
|
| 389 |
+
|
| 390 |
+
const content = `
|
| 391 |
+
<h2 style="color: #333333; font-size: 24px; margin: 0 0 20px 0;">${promotion.title}</h2>
|
| 392 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">
|
| 393 |
+
您好!
|
| 394 |
+
</p>
|
| 395 |
+
${promotion.banner ? `
|
| 396 |
+
<div style="margin: 20px 0; text-align: center;">
|
| 397 |
+
<img src="${promotion.banner}" alt="${promotion.title}" style="max-width: 100%; height: auto; border-radius: 8px;">
|
| 398 |
+
</div>
|
| 399 |
+
` : ''}
|
| 400 |
+
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 20px 0;">
|
| 401 |
+
${promotion.description}
|
| 402 |
+
</p>
|
| 403 |
+
${promotion.highlights ? `
|
| 404 |
+
<div style="background-color: #f0f4ff; padding: 20px; border-radius: 6px; margin: 20px 0;">
|
| 405 |
+
<h3 style="color: #667eea; font-size: 18px; margin: 0 0 15px 0;">活动亮点:</h3>
|
| 406 |
+
<ul style="color: #666666; font-size: 15px; line-height: 1.8; margin: 0; padding-left: 20px;">
|
| 407 |
+
${promotion.highlights.map(h => `<li>${h}</li>`).join('')}
|
| 408 |
+
</ul>
|
| 409 |
+
</div>
|
| 410 |
+
` : ''}
|
| 411 |
+
${promotion.validUntil ? `
|
| 412 |
+
<p style="color: #e74c3c; font-size: 15px; line-height: 1.6; margin: 20px 0; text-align: center;">
|
| 413 |
+
<strong>活动截止时间:${new Date(promotion.validUntil).toLocaleDateString('zh-CN')}</strong>
|
| 414 |
+
</p>
|
| 415 |
+
` : ''}
|
| 416 |
+
${buttonHTML(promotion.buttonText || '立即参与', promotionUrl, '#f39c12')}
|
| 417 |
+
<p style="color: #999999; font-size: 14px; line-height: 1.6; margin: 20px 0 0 0; text-align: center;">
|
| 418 |
+
机会难得,不容错过!
|
| 419 |
+
</p>
|
| 420 |
+
`;
|
| 421 |
+
|
| 422 |
+
const highlightsText = promotion.highlights ? promotion.highlights.map(h => `- ${h}`).join('\n') : '';
|
| 423 |
+
|
| 424 |
+
const text = `
|
| 425 |
+
${promotion.title}
|
| 426 |
+
|
| 427 |
+
您好!
|
| 428 |
+
|
| 429 |
+
${promotion.description}
|
| 430 |
+
|
| 431 |
+
${highlightsText ? `活动亮点:\n${highlightsText}\n\n` : ''}${promotion.validUntil ? `活动截止时间:${new Date(promotion.validUntil).toLocaleDateString('zh-CN')}\n\n` : ''}访问链接:${promotionUrl}
|
| 432 |
+
|
| 433 |
+
机会难得,不容错过!
|
| 434 |
+
`.trim();
|
| 435 |
+
|
| 436 |
+
return sendEmail(email, `[活动] ${promotion.title}`, emailTemplate(content), text);
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
// Verify transporter configuration
|
| 440 |
+
transporter.verify((error, success) => {
|
| 441 |
+
if (error) {
|
| 442 |
+
console.error('SMTP configuration error:', error);
|
| 443 |
+
} else {
|
| 444 |
+
console.log('Email service is ready to send emails');
|
| 445 |
+
}
|
| 446 |
+
});
|
| 447 |
+
|
| 448 |
+
export default {
|
| 449 |
+
sendEmail,
|
| 450 |
+
sendVerificationEmail,
|
| 451 |
+
sendPasswordResetEmail,
|
| 452 |
+
sendWelcomeEmail,
|
| 453 |
+
sendFortuneReminder,
|
| 454 |
+
sendLowPointsReminder,
|
| 455 |
+
sendFeatureUpdateEmail,
|
| 456 |
+
sendPromotionalEmail
|
| 457 |
+
};
|
server/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import path from 'path';
|
|
| 5 |
import { fileURLToPath } from 'url';
|
| 6 |
import { nanoid } from 'nanoid';
|
| 7 |
import fetch from 'node-fetch';
|
|
|
|
| 8 |
|
| 9 |
// 使用新的 SQLite 数据库
|
| 10 |
import {
|
|
@@ -45,6 +46,7 @@ import {
|
|
| 45 |
setDefaultProfile,
|
| 46 |
getUserPreferences,
|
| 47 |
updateUserPreferences,
|
|
|
|
| 48 |
// 新增运势相关函数
|
| 49 |
saveDailyFortuneDetail,
|
| 50 |
getDailyFortuneDetail,
|
|
@@ -60,16 +62,26 @@ import {
|
|
| 60 |
getTrendingCelebrityCases,
|
| 61 |
getCelebrityCaseById,
|
| 62 |
incrementCelebrityCaseView,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
} from './database.js';
|
| 64 |
import { hashPassword, verifyPassword, signToken, requireAuth, getTokenFromReq, verifyToken } from './auth.js';
|
|
|
|
| 65 |
import { BAZI_SYSTEM_INSTRUCTION, buildUserPrompt } from './prompt.js';
|
| 66 |
import { handleAnalyzeStream } from './analyzeStream.js';
|
| 67 |
import { handleParallelAnalyzeStream } from './analyzeParallelStream.js';
|
| 68 |
import { calculateLifeTimeline, calculate36MonthTimeline, generate36MonthFallbackKLine, calculate7MonthTimeline, generate7MonthFallbackKLine, calculate61DayTimeline, generate61DayFallbackKLine } from './baziCalculator.js';
|
| 69 |
-
import { getCacheStats } from './cacheManager.js';
|
| 70 |
import { POINTS_CONFIG, getFeatureCost, checkUserPoints, deductUserPoints } from './pointsManager.js';
|
| 71 |
import { AGENT_DAILY_FORTUNE_PROMPT } from './agentPrompts.js';
|
| 72 |
import { generateCelebrityAnalysis } from './celebrityAnalyzer.js';
|
|
|
|
|
|
|
| 73 |
|
| 74 |
dotenv.config();
|
| 75 |
|
|
@@ -87,6 +99,8 @@ const FALLBACK_MODELS = [
|
|
| 87 |
|
| 88 |
const FREE_INIT_POINTS = process.env.FREE_INIT_POINTS ? parseInt(process.env.FREE_INIT_POINTS, 10) : 1000;
|
| 89 |
const COST_PER_ANALYSIS = process.env.COST_PER_ANALYSIS ? parseInt(process.env.COST_PER_ANALYSIS, 10) : 50;
|
|
|
|
|
|
|
| 90 |
|
| 91 |
const app = express();
|
| 92 |
app.set('trust proxy', 1);
|
|
@@ -1307,6 +1321,83 @@ app.post('/api/profiles/:id/set-default', requireAuth(JWT_SECRET), (req, res) =>
|
|
| 1307 |
}
|
| 1308 |
});
|
| 1309 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1310 |
// GET /api/preferences - Get user preferences
|
| 1311 |
app.get('/api/preferences', requireAuth(JWT_SECRET), (req, res) => {
|
| 1312 |
try {
|
|
@@ -1353,6 +1444,381 @@ app.put('/api/preferences', requireAuth(JWT_SECRET), (req, res) => {
|
|
| 1353 |
}
|
| 1354 |
});
|
| 1355 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1356 |
// ============ Fortune API ============
|
| 1357 |
|
| 1358 |
// POST /api/fortune/daily - Get or generate daily fortune
|
|
@@ -2401,4 +2867,7 @@ app.get('*', (_req, res) => res.sendFile(path.join(distDir, 'index.html')));
|
|
| 2401 |
|
| 2402 |
app.listen(PORT, () => {
|
| 2403 |
process.stdout.write(`server listening on ${PORT}\n`);
|
|
|
|
|
|
|
|
|
|
| 2404 |
});
|
|
|
|
| 5 |
import { fileURLToPath } from 'url';
|
| 6 |
import { nanoid } from 'nanoid';
|
| 7 |
import fetch from 'node-fetch';
|
| 8 |
+
import crypto from 'crypto';
|
| 9 |
|
| 10 |
// 使用新的 SQLite 数据库
|
| 11 |
import {
|
|
|
|
| 46 |
setDefaultProfile,
|
| 47 |
getUserPreferences,
|
| 48 |
updateUserPreferences,
|
| 49 |
+
updateProfileCoreDocumentStatus,
|
| 50 |
// 新增运势相关函数
|
| 51 |
saveDailyFortuneDetail,
|
| 52 |
getDailyFortuneDetail,
|
|
|
|
| 62 |
getTrendingCelebrityCases,
|
| 63 |
getCelebrityCaseById,
|
| 64 |
incrementCelebrityCaseView,
|
| 65 |
+
// Email related
|
| 66 |
+
createEmailSubscription,
|
| 67 |
+
getEmailSubscription,
|
| 68 |
+
updateEmailSubscription,
|
| 69 |
+
createPasswordResetToken,
|
| 70 |
+
getPasswordResetToken,
|
| 71 |
+
markPasswordResetTokenUsed,
|
| 72 |
} from './database.js';
|
| 73 |
import { hashPassword, verifyPassword, signToken, requireAuth, getTokenFromReq, verifyToken } from './auth.js';
|
| 74 |
+
import { sendVerificationEmail, sendPasswordResetEmail } from './emailService.js';
|
| 75 |
import { BAZI_SYSTEM_INSTRUCTION, buildUserPrompt } from './prompt.js';
|
| 76 |
import { handleAnalyzeStream } from './analyzeStream.js';
|
| 77 |
import { handleParallelAnalyzeStream } from './analyzeParallelStream.js';
|
| 78 |
import { calculateLifeTimeline, calculate36MonthTimeline, generate36MonthFallbackKLine, calculate7MonthTimeline, generate7MonthFallbackKLine, calculate61DayTimeline, generate61DayFallbackKLine } from './baziCalculator.js';
|
| 79 |
+
import { getCacheStats, computeBaziHash, getCachedAnalysis } from './cacheManager.js';
|
| 80 |
import { POINTS_CONFIG, getFeatureCost, checkUserPoints, deductUserPoints } from './pointsManager.js';
|
| 81 |
import { AGENT_DAILY_FORTUNE_PROMPT } from './agentPrompts.js';
|
| 82 |
import { generateCelebrityAnalysis } from './celebrityAnalyzer.js';
|
| 83 |
+
import { regenerateCoreDocument } from './coreDocumentEngine.js';
|
| 84 |
+
import { startEmailScheduler } from './emailScheduler.js';
|
| 85 |
|
| 86 |
dotenv.config();
|
| 87 |
|
|
|
|
| 99 |
|
| 100 |
const FREE_INIT_POINTS = process.env.FREE_INIT_POINTS ? parseInt(process.env.FREE_INIT_POINTS, 10) : 1000;
|
| 101 |
const COST_PER_ANALYSIS = process.env.COST_PER_ANALYSIS ? parseInt(process.env.COST_PER_ANALYSIS, 10) : 50;
|
| 102 |
+
const EMAIL_BINDING_REWARD = process.env.EMAIL_BINDING_REWARD ? parseInt(process.env.EMAIL_BINDING_REWARD, 10) : 1000;
|
| 103 |
+
const EMAIL_SUBSCRIPTION_REWARD = process.env.EMAIL_SUBSCRIPTION_REWARD ? parseInt(process.env.EMAIL_SUBSCRIPTION_REWARD, 10) : 1000;
|
| 104 |
|
| 105 |
const app = express();
|
| 106 |
app.set('trust proxy', 1);
|
|
|
|
| 1321 |
}
|
| 1322 |
});
|
| 1323 |
|
| 1324 |
+
// POST /api/profiles/:id/regenerate - Trigger core document regeneration
|
| 1325 |
+
app.post('/api/profiles/:id/regenerate', requireAuth(JWT_SECRET), async (req, res) => {
|
| 1326 |
+
try {
|
| 1327 |
+
const { id } = req.params;
|
| 1328 |
+
const { reason } = req.body;
|
| 1329 |
+
const userId = req.auth.sub;
|
| 1330 |
+
|
| 1331 |
+
// Verify profile belongs to user
|
| 1332 |
+
const profile = getUserProfileById(id);
|
| 1333 |
+
if (!profile || profile.userId !== userId) {
|
| 1334 |
+
return res.status(404).json({ error: 'Profile not found' });
|
| 1335 |
+
}
|
| 1336 |
+
|
| 1337 |
+
console.log(`[API] Triggering core document regeneration for profile ${id}, reason: ${reason || '手动触发'}`);
|
| 1338 |
+
|
| 1339 |
+
// Trigger async regeneration via coreDocumentEngine
|
| 1340 |
+
// This runs in the background, so we return immediately
|
| 1341 |
+
regenerateCoreDocument(id, reason || '手动触发')
|
| 1342 |
+
.then(result => {
|
| 1343 |
+
console.log(`[API] Core document regeneration completed for profile ${id}`);
|
| 1344 |
+
})
|
| 1345 |
+
.catch(error => {
|
| 1346 |
+
console.error(`[API] Core document regeneration failed for profile ${id}:`, error);
|
| 1347 |
+
});
|
| 1348 |
+
|
| 1349 |
+
// Return success immediately
|
| 1350 |
+
res.json({
|
| 1351 |
+
success: true,
|
| 1352 |
+
message: '核心文档正在重新生成',
|
| 1353 |
+
status: 'generating'
|
| 1354 |
+
});
|
| 1355 |
+
} catch (error) {
|
| 1356 |
+
console.error('Regenerate error:', error);
|
| 1357 |
+
res.status(500).json({ error: '重新生成失败' });
|
| 1358 |
+
}
|
| 1359 |
+
});
|
| 1360 |
+
|
| 1361 |
+
// GET /api/profiles/:id/core-document - Get core document for a profile
|
| 1362 |
+
app.get('/api/profiles/:id/core-document', requireAuth(JWT_SECRET), async (req, res) => {
|
| 1363 |
+
try {
|
| 1364 |
+
const { id } = req.params;
|
| 1365 |
+
const userId = req.auth.sub;
|
| 1366 |
+
|
| 1367 |
+
const profile = getUserProfileById(id);
|
| 1368 |
+
if (!profile || profile.userId !== userId) {
|
| 1369 |
+
return res.status(404).json({ error: 'Profile not found' });
|
| 1370 |
+
}
|
| 1371 |
+
|
| 1372 |
+
// Get cached analysis using baziHash
|
| 1373 |
+
const baziHash = computeBaziHash(
|
| 1374 |
+
profile.yearPillar,
|
| 1375 |
+
profile.monthPillar,
|
| 1376 |
+
profile.dayPillar,
|
| 1377 |
+
profile.hourPillar
|
| 1378 |
+
);
|
| 1379 |
+
|
| 1380 |
+
const cached = getCachedAnalysis(baziHash, profile.gender);
|
| 1381 |
+
|
| 1382 |
+
if (cached) {
|
| 1383 |
+
res.json({
|
| 1384 |
+
success: true,
|
| 1385 |
+
coreDocument: cached,
|
| 1386 |
+
status: 'ready'
|
| 1387 |
+
});
|
| 1388 |
+
} else {
|
| 1389 |
+
res.json({
|
| 1390 |
+
success: true,
|
| 1391 |
+
coreDocument: null,
|
| 1392 |
+
status: profile.coreDocumentStatus || 'pending'
|
| 1393 |
+
});
|
| 1394 |
+
}
|
| 1395 |
+
} catch (error) {
|
| 1396 |
+
console.error('Get core document error:', error);
|
| 1397 |
+
res.status(500).json({ error: '获取核心文档失败' });
|
| 1398 |
+
}
|
| 1399 |
+
});
|
| 1400 |
+
|
| 1401 |
// GET /api/preferences - Get user preferences
|
| 1402 |
app.get('/api/preferences', requireAuth(JWT_SECRET), (req, res) => {
|
| 1403 |
try {
|
|
|
|
| 1444 |
}
|
| 1445 |
});
|
| 1446 |
|
| 1447 |
+
// ============ Email API ============
|
| 1448 |
+
|
| 1449 |
+
// POST /api/email/send-verification - Send verification email to logged-in user
|
| 1450 |
+
app.post('/api/email/send-verification', requireAuth(JWT_SECRET), async (req, res) => {
|
| 1451 |
+
try {
|
| 1452 |
+
const userId = req.auth.sub;
|
| 1453 |
+
const user = getUserById(userId);
|
| 1454 |
+
|
| 1455 |
+
if (!user) {
|
| 1456 |
+
return res.status(404).json({ error: 'USER_NOT_FOUND', message: '用户不存在' });
|
| 1457 |
+
}
|
| 1458 |
+
|
| 1459 |
+
// Generate verification token
|
| 1460 |
+
const token = crypto.randomBytes(32).toString('hex');
|
| 1461 |
+
|
| 1462 |
+
// Create or update email subscription record
|
| 1463 |
+
let subscription = getEmailSubscription(userId);
|
| 1464 |
+
if (!subscription) {
|
| 1465 |
+
createEmailSubscription({
|
| 1466 |
+
userId,
|
| 1467 |
+
email: user.email,
|
| 1468 |
+
verificationToken: token,
|
| 1469 |
+
emailVerified: 0,
|
| 1470 |
+
});
|
| 1471 |
+
} else {
|
| 1472 |
+
updateEmailSubscription(userId, {
|
| 1473 |
+
verificationToken: token,
|
| 1474 |
+
});
|
| 1475 |
+
}
|
| 1476 |
+
|
| 1477 |
+
// Send verification email
|
| 1478 |
+
await sendVerificationEmail(user.email, token);
|
| 1479 |
+
|
| 1480 |
+
logEvent('info', '发送验证邮件', { email: user.email }, userId, req.ip);
|
| 1481 |
+
|
| 1482 |
+
return res.json({ success: true, message: '验证邮件已发送' });
|
| 1483 |
+
|
| 1484 |
+
} catch (error) {
|
| 1485 |
+
console.error('发送验证邮件失败:', error);
|
| 1486 |
+
return res.status(500).json({ error: 'INTERNAL_ERROR', message: '发送失败,请稍后重试' });
|
| 1487 |
+
}
|
| 1488 |
+
});
|
| 1489 |
+
|
| 1490 |
+
// POST /api/email/verify - Verify email with token
|
| 1491 |
+
app.post('/api/email/verify', async (req, res) => {
|
| 1492 |
+
try {
|
| 1493 |
+
const { token } = req.body;
|
| 1494 |
+
|
| 1495 |
+
if (!token) {
|
| 1496 |
+
return res.status(400).json({ error: 'MISSING_TOKEN', message: '缺少验证令牌' });
|
| 1497 |
+
}
|
| 1498 |
+
|
| 1499 |
+
// Find subscription by token
|
| 1500 |
+
const db = getDb();
|
| 1501 |
+
const subscription = db.prepare('SELECT * FROM email_subscriptions WHERE verification_token = ?').get(token);
|
| 1502 |
+
|
| 1503 |
+
if (!subscription) {
|
| 1504 |
+
return res.status(404).json({ error: 'INVALID_TOKEN', message: '无效的验证令牌' });
|
| 1505 |
+
}
|
| 1506 |
+
|
| 1507 |
+
if (subscription.email_verified === 1) {
|
| 1508 |
+
return res.status(400).json({ error: 'ALREADY_VERIFIED', message: '邮箱已验证' });
|
| 1509 |
+
}
|
| 1510 |
+
|
| 1511 |
+
// Update verification status
|
| 1512 |
+
const now = new Date().toISOString();
|
| 1513 |
+
db.prepare(`
|
| 1514 |
+
UPDATE email_subscriptions
|
| 1515 |
+
SET email_verified = 1, verified_at = ?
|
| 1516 |
+
WHERE user_id = ?
|
| 1517 |
+
`).run(now, subscription.user_id);
|
| 1518 |
+
|
| 1519 |
+
// Award points if not already claimed
|
| 1520 |
+
let pointsAwarded = 0;
|
| 1521 |
+
if (subscription.binding_reward_claimed === 0) {
|
| 1522 |
+
const user = getUserById(subscription.user_id);
|
| 1523 |
+
const newPoints = user.points + EMAIL_BINDING_REWARD;
|
| 1524 |
+
updateUserPoints(subscription.user_id, newPoints);
|
| 1525 |
+
|
| 1526 |
+
db.prepare('UPDATE email_subscriptions SET binding_reward_claimed = 1 WHERE user_id = ?')
|
| 1527 |
+
.run(subscription.user_id);
|
| 1528 |
+
|
| 1529 |
+
pointsAwarded = EMAIL_BINDING_REWARD;
|
| 1530 |
+
|
| 1531 |
+
logEvent('info', '邮箱验证奖励', { points: pointsAwarded }, subscription.user_id, req.ip);
|
| 1532 |
+
}
|
| 1533 |
+
|
| 1534 |
+
logEvent('info', '邮箱验证成功', { email: subscription.email }, subscription.user_id, req.ip);
|
| 1535 |
+
|
| 1536 |
+
const updatedUser = getUserById(subscription.user_id);
|
| 1537 |
+
|
| 1538 |
+
return res.json({
|
| 1539 |
+
success: true,
|
| 1540 |
+
pointsAwarded,
|
| 1541 |
+
newPoints: updatedUser.points
|
| 1542 |
+
});
|
| 1543 |
+
|
| 1544 |
+
} catch (error) {
|
| 1545 |
+
console.error('邮箱验证失败:', error);
|
| 1546 |
+
return res.status(500).json({ error: 'INTERNAL_ERROR', message: '验证失败' });
|
| 1547 |
+
}
|
| 1548 |
+
});
|
| 1549 |
+
|
| 1550 |
+
// GET /api/email/subscription - Get user's subscription settings
|
| 1551 |
+
app.get('/api/email/subscription', requireAuth(JWT_SECRET), (req, res) => {
|
| 1552 |
+
try {
|
| 1553 |
+
const userId = req.auth.sub;
|
| 1554 |
+
const subscription = getEmailSubscription(userId);
|
| 1555 |
+
|
| 1556 |
+
return res.json({
|
| 1557 |
+
subscription: subscription || null,
|
| 1558 |
+
emailVerified: subscription ? subscription.emailVerified === 1 : false
|
| 1559 |
+
});
|
| 1560 |
+
|
| 1561 |
+
} catch (error) {
|
| 1562 |
+
console.error('获取订阅设置失败:', error);
|
| 1563 |
+
return res.status(500).json({ error: 'INTERNAL_ERROR', message: '获取失败' });
|
| 1564 |
+
}
|
| 1565 |
+
});
|
| 1566 |
+
|
| 1567 |
+
// PUT /api/email/subscription - Update subscription settings
|
| 1568 |
+
app.put('/api/email/subscription', requireAuth(JWT_SECRET), (req, res) => {
|
| 1569 |
+
try {
|
| 1570 |
+
const userId = req.auth.sub;
|
| 1571 |
+
const {
|
| 1572 |
+
subDailyFortune,
|
| 1573 |
+
subMonthlyFortune,
|
| 1574 |
+
subYearlyFortune,
|
| 1575 |
+
subBirthdayReminder,
|
| 1576 |
+
subLowPoints,
|
| 1577 |
+
subFeatureUpdates,
|
| 1578 |
+
subPromotions
|
| 1579 |
+
} = req.body;
|
| 1580 |
+
|
| 1581 |
+
let subscription = getEmailSubscription(userId);
|
| 1582 |
+
|
| 1583 |
+
if (!subscription) {
|
| 1584 |
+
const user = getUserById(userId);
|
| 1585 |
+
createEmailSubscription({
|
| 1586 |
+
userId,
|
| 1587 |
+
email: user.email,
|
| 1588 |
+
emailVerified: 0,
|
| 1589 |
+
});
|
| 1590 |
+
subscription = getEmailSubscription(userId);
|
| 1591 |
+
}
|
| 1592 |
+
|
| 1593 |
+
// Check if this is first time enabling any subscription
|
| 1594 |
+
const wasAnySubEnabled = subscription.subDailyFortune || subscription.subMonthlyFortune ||
|
| 1595 |
+
subscription.subYearlyFortune || subscription.subBirthdayReminder ||
|
| 1596 |
+
subscription.subLowPoints || subscription.subFeatureUpdates ||
|
| 1597 |
+
subscription.subPromotions;
|
| 1598 |
+
|
| 1599 |
+
const isAnySubEnabled = subDailyFortune || subMonthlyFortune || subYearlyFortune ||
|
| 1600 |
+
subBirthdayReminder || subLowPoints || subFeatureUpdates || subPromotions;
|
| 1601 |
+
|
| 1602 |
+
// Update subscription settings
|
| 1603 |
+
const updateData = {};
|
| 1604 |
+
if (subDailyFortune !== undefined) updateData.subDailyFortune = subDailyFortune ? 1 : 0;
|
| 1605 |
+
if (subMonthlyFortune !== undefined) updateData.subMonthlyFortune = subMonthlyFortune ? 1 : 0;
|
| 1606 |
+
if (subYearlyFortune !== undefined) updateData.subYearlyFortune = subYearlyFortune ? 1 : 0;
|
| 1607 |
+
if (subBirthdayReminder !== undefined) updateData.subBirthdayReminder = subBirthdayReminder ? 1 : 0;
|
| 1608 |
+
if (subLowPoints !== undefined) updateData.subLowPoints = subLowPoints ? 1 : 0;
|
| 1609 |
+
if (subFeatureUpdates !== undefined) updateData.subFeatureUpdates = subFeatureUpdates ? 1 : 0;
|
| 1610 |
+
if (subPromotions !== undefined) updateData.subPromotions = subPromotions ? 1 : 0;
|
| 1611 |
+
|
| 1612 |
+
updateEmailSubscription(userId, updateData);
|
| 1613 |
+
|
| 1614 |
+
// Award points for first subscription
|
| 1615 |
+
let pointsAwarded = 0;
|
| 1616 |
+
if (!wasAnySubEnabled && isAnySubEnabled && subscription.subscription_reward_claimed === 0) {
|
| 1617 |
+
const user = getUserById(userId);
|
| 1618 |
+
const newPoints = user.points + EMAIL_SUBSCRIPTION_REWARD;
|
| 1619 |
+
updateUserPoints(userId, newPoints);
|
| 1620 |
+
|
| 1621 |
+
const db = getDb();
|
| 1622 |
+
db.prepare('UPDATE email_subscriptions SET subscription_reward_claimed = 1 WHERE user_id = ?')
|
| 1623 |
+
.run(userId);
|
| 1624 |
+
|
| 1625 |
+
pointsAwarded = EMAIL_SUBSCRIPTION_REWARD;
|
| 1626 |
+
|
| 1627 |
+
logEvent('info', '订阅邮件奖励', { points: pointsAwarded }, userId, req.ip);
|
| 1628 |
+
}
|
| 1629 |
+
|
| 1630 |
+
const updatedSubscription = getEmailSubscription(userId);
|
| 1631 |
+
|
| 1632 |
+
logEvent('info', '更新邮件订阅', updateData, userId, req.ip);
|
| 1633 |
+
|
| 1634 |
+
return res.json({
|
| 1635 |
+
success: true,
|
| 1636 |
+
subscription: updatedSubscription,
|
| 1637 |
+
pointsAwarded: pointsAwarded > 0 ? pointsAwarded : undefined
|
| 1638 |
+
});
|
| 1639 |
+
|
| 1640 |
+
} catch (error) {
|
| 1641 |
+
console.error('更新订阅设置失败:', error);
|
| 1642 |
+
return res.status(500).json({ error: 'INTERNAL_ERROR', message: '更新失败' });
|
| 1643 |
+
}
|
| 1644 |
+
});
|
| 1645 |
+
|
| 1646 |
+
// POST /api/auth/forgot-password - Request password reset
|
| 1647 |
+
app.post('/api/auth/forgot-password', async (req, res) => {
|
| 1648 |
+
try {
|
| 1649 |
+
const { email } = req.body;
|
| 1650 |
+
|
| 1651 |
+
if (!email) {
|
| 1652 |
+
return res.status(400).json({ error: 'MISSING_EMAIL', message: '请输入邮箱' });
|
| 1653 |
+
}
|
| 1654 |
+
|
| 1655 |
+
const user = getUserByEmail(sanitizeEmail(email));
|
| 1656 |
+
if (!user) {
|
| 1657 |
+
// Don't reveal if email exists for security
|
| 1658 |
+
return res.json({ success: true, message: '重置邮件已发送' });
|
| 1659 |
+
}
|
| 1660 |
+
|
| 1661 |
+
// Generate reset token
|
| 1662 |
+
const token = crypto.randomBytes(32).toString('hex');
|
| 1663 |
+
const expiresAt = new Date(Date.now() + 3600000).toISOString(); // 1 hour
|
| 1664 |
+
|
| 1665 |
+
// Create password reset token
|
| 1666 |
+
createPasswordResetToken({
|
| 1667 |
+
userId: user.id,
|
| 1668 |
+
token,
|
| 1669 |
+
expiresAt,
|
| 1670 |
+
});
|
| 1671 |
+
|
| 1672 |
+
// Send reset email
|
| 1673 |
+
await sendPasswordResetEmail(user.email, token);
|
| 1674 |
+
|
| 1675 |
+
logEvent('info', '请求密码重置', { email: user.email }, user.id, req.ip);
|
| 1676 |
+
|
| 1677 |
+
return res.json({ success: true, message: '重置邮件已发送' });
|
| 1678 |
+
|
| 1679 |
+
} catch (error) {
|
| 1680 |
+
console.error('请求密码重置失败:', error);
|
| 1681 |
+
return res.status(500).json({ error: 'INTERNAL_ERROR', message: '发送失败,请稍后重试' });
|
| 1682 |
+
}
|
| 1683 |
+
});
|
| 1684 |
+
|
| 1685 |
+
// POST /api/auth/reset-password - Reset password with token
|
| 1686 |
+
app.post('/api/auth/reset-password', async (req, res) => {
|
| 1687 |
+
try {
|
| 1688 |
+
const { token, newPassword } = req.body;
|
| 1689 |
+
|
| 1690 |
+
if (!token || !newPassword) {
|
| 1691 |
+
return res.status(400).json({ error: 'INVALID_INPUT', message: '缺少必要参数' });
|
| 1692 |
+
}
|
| 1693 |
+
|
| 1694 |
+
if (newPassword.length < 6) {
|
| 1695 |
+
return res.status(400).json({ error: 'PASSWORD_TOO_SHORT', message: '密码至少6个字符' });
|
| 1696 |
+
}
|
| 1697 |
+
|
| 1698 |
+
// Get token from database
|
| 1699 |
+
const resetToken = getPasswordResetToken(token);
|
| 1700 |
+
|
| 1701 |
+
if (!resetToken) {
|
| 1702 |
+
return res.status(404).json({ error: 'INVALID_TOKEN', message: '无效的重置令牌' });
|
| 1703 |
+
}
|
| 1704 |
+
|
| 1705 |
+
if (resetToken.used === 1) {
|
| 1706 |
+
return res.status(400).json({ error: 'TOKEN_USED', message: '该令牌已被使用' });
|
| 1707 |
+
}
|
| 1708 |
+
|
| 1709 |
+
if (new Date(resetToken.expiresAt) < new Date()) {
|
| 1710 |
+
return res.status(400).json({ error: 'TOKEN_EXPIRED', message: '令牌已过期' });
|
| 1711 |
+
}
|
| 1712 |
+
|
| 1713 |
+
// Update password
|
| 1714 |
+
const passwordHash = await hashPassword(newPassword);
|
| 1715 |
+
const db = getDb();
|
| 1716 |
+
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?')
|
| 1717 |
+
.run(passwordHash, resetToken.userId);
|
| 1718 |
+
|
| 1719 |
+
// Mark token as used
|
| 1720 |
+
markPasswordResetTokenUsed(token);
|
| 1721 |
+
|
| 1722 |
+
logEvent('info', '密码重置成功', {}, resetToken.userId, req.ip);
|
| 1723 |
+
|
| 1724 |
+
return res.json({ success: true, message: '密码已重置' });
|
| 1725 |
+
|
| 1726 |
+
} catch (error) {
|
| 1727 |
+
console.error('密码重置失败:', error);
|
| 1728 |
+
return res.status(500).json({ error: 'INTERNAL_ERROR', message: '重置失败' });
|
| 1729 |
+
}
|
| 1730 |
+
});
|
| 1731 |
+
|
| 1732 |
+
// POST /api/email/claim-binding-reward - Claim email binding reward
|
| 1733 |
+
app.post('/api/email/claim-binding-reward', requireAuth(JWT_SECRET), (req, res) => {
|
| 1734 |
+
try {
|
| 1735 |
+
const userId = req.auth.sub;
|
| 1736 |
+
const subscription = getEmailSubscription(userId);
|
| 1737 |
+
|
| 1738 |
+
if (!subscription) {
|
| 1739 |
+
return res.status(404).json({ error: 'NO_SUBSCRIPTION', message: '未找到邮箱订阅记录' });
|
| 1740 |
+
}
|
| 1741 |
+
|
| 1742 |
+
if (subscription.emailVerified !== 1) {
|
| 1743 |
+
return res.status(400).json({ error: 'EMAIL_NOT_VERIFIED', message: '邮箱未验证' });
|
| 1744 |
+
}
|
| 1745 |
+
|
| 1746 |
+
if (subscription.bindingRewardClaimed === 1) {
|
| 1747 |
+
return res.status(400).json({ error: 'ALREADY_CLAIMED', message: '奖励已领取' });
|
| 1748 |
+
}
|
| 1749 |
+
|
| 1750 |
+
// Award points
|
| 1751 |
+
const user = getUserById(userId);
|
| 1752 |
+
const newPoints = user.points + EMAIL_BINDING_REWARD;
|
| 1753 |
+
updateUserPoints(userId, newPoints);
|
| 1754 |
+
|
| 1755 |
+
// Mark as claimed
|
| 1756 |
+
const db = getDb();
|
| 1757 |
+
db.prepare('UPDATE email_subscriptions SET binding_reward_claimed = 1 WHERE user_id = ?')
|
| 1758 |
+
.run(userId);
|
| 1759 |
+
|
| 1760 |
+
logEvent('info', '领取邮箱绑定奖励', { points: EMAIL_BINDING_REWARD }, userId, req.ip);
|
| 1761 |
+
|
| 1762 |
+
return res.json({
|
| 1763 |
+
success: true,
|
| 1764 |
+
pointsAwarded: EMAIL_BINDING_REWARD,
|
| 1765 |
+
newPoints
|
| 1766 |
+
});
|
| 1767 |
+
|
| 1768 |
+
} catch (error) {
|
| 1769 |
+
console.error('领取绑定奖励失败:', error);
|
| 1770 |
+
return res.status(500).json({ error: 'INTERNAL_ERROR', message: '领取失败' });
|
| 1771 |
+
}
|
| 1772 |
+
});
|
| 1773 |
+
|
| 1774 |
+
// POST /api/email/claim-subscription-reward - Claim subscription reward
|
| 1775 |
+
app.post('/api/email/claim-subscription-reward', requireAuth(JWT_SECRET), (req, res) => {
|
| 1776 |
+
try {
|
| 1777 |
+
const userId = req.auth.sub;
|
| 1778 |
+
const subscription = getEmailSubscription(userId);
|
| 1779 |
+
|
| 1780 |
+
if (!subscription) {
|
| 1781 |
+
return res.status(404).json({ error: 'NO_SUBSCRIPTION', message: '未找到邮箱订阅记录' });
|
| 1782 |
+
}
|
| 1783 |
+
|
| 1784 |
+
// Check if at least one subscription is enabled
|
| 1785 |
+
const hasSubscription = subscription.subDailyFortune || subscription.subMonthlyFortune ||
|
| 1786 |
+
subscription.subYearlyFortune || subscription.subBirthdayReminder ||
|
| 1787 |
+
subscription.subLowPoints || subscription.subFeatureUpdates ||
|
| 1788 |
+
subscription.subPromotions;
|
| 1789 |
+
|
| 1790 |
+
if (!hasSubscription) {
|
| 1791 |
+
return res.status(400).json({ error: 'NO_ACTIVE_SUBSCRIPTION', message: '请至少启用一项订阅' });
|
| 1792 |
+
}
|
| 1793 |
+
|
| 1794 |
+
if (subscription.subscriptionRewardClaimed === 1) {
|
| 1795 |
+
return res.status(400).json({ error: 'ALREADY_CLAIMED', message: '奖励已领取' });
|
| 1796 |
+
}
|
| 1797 |
+
|
| 1798 |
+
// Award points
|
| 1799 |
+
const user = getUserById(userId);
|
| 1800 |
+
const newPoints = user.points + EMAIL_SUBSCRIPTION_REWARD;
|
| 1801 |
+
updateUserPoints(userId, newPoints);
|
| 1802 |
+
|
| 1803 |
+
// Mark as claimed
|
| 1804 |
+
const db = getDb();
|
| 1805 |
+
db.prepare('UPDATE email_subscriptions SET subscription_reward_claimed = 1 WHERE user_id = ?')
|
| 1806 |
+
.run(userId);
|
| 1807 |
+
|
| 1808 |
+
logEvent('info', '领取订阅奖励', { points: EMAIL_SUBSCRIPTION_REWARD }, userId, req.ip);
|
| 1809 |
+
|
| 1810 |
+
return res.json({
|
| 1811 |
+
success: true,
|
| 1812 |
+
pointsAwarded: EMAIL_SUBSCRIPTION_REWARD,
|
| 1813 |
+
newPoints
|
| 1814 |
+
});
|
| 1815 |
+
|
| 1816 |
+
} catch (error) {
|
| 1817 |
+
console.error('领取订阅奖励失败:', error);
|
| 1818 |
+
return res.status(500).json({ error: 'INTERNAL_ERROR', message: '领取失败' });
|
| 1819 |
+
}
|
| 1820 |
+
});
|
| 1821 |
+
|
| 1822 |
// ============ Fortune API ============
|
| 1823 |
|
| 1824 |
// POST /api/fortune/daily - Get or generate daily fortune
|
|
|
|
| 2867 |
|
| 2868 |
app.listen(PORT, () => {
|
| 2869 |
process.stdout.write(`server listening on ${PORT}\n`);
|
| 2870 |
+
|
| 2871 |
+
// Start email scheduler
|
| 2872 |
+
startEmailScheduler();
|
| 2873 |
});
|
types/lunar-javascript.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
declare module 'lunar-javascript' {
|
| 2 |
+
export class Solar {
|
| 3 |
+
static fromYmd(year: number, month: number, day: number): Solar;
|
| 4 |
+
static fromDate(date: Date): Solar;
|
| 5 |
+
getYear(): number;
|
| 6 |
+
getMonth(): number;
|
| 7 |
+
getDay(): number;
|
| 8 |
+
getLunar(): Lunar;
|
| 9 |
+
toYmd(): string;
|
| 10 |
+
toString(): string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export class Lunar {
|
| 14 |
+
static fromYmd(year: number, month: number, day: number): Lunar;
|
| 15 |
+
static fromDate(date: Date): Lunar;
|
| 16 |
+
getYear(): number;
|
| 17 |
+
getMonth(): number;
|
| 18 |
+
getDay(): number;
|
| 19 |
+
getYearInGanZhi(): string;
|
| 20 |
+
getMonthInGanZhi(): string;
|
| 21 |
+
getDayInGanZhi(): string;
|
| 22 |
+
getTimeInGanZhi(): string;
|
| 23 |
+
getYearGan(): string;
|
| 24 |
+
getYearZhi(): string;
|
| 25 |
+
getMonthGan(): string;
|
| 26 |
+
getMonthZhi(): string;
|
| 27 |
+
getDayGan(): string;
|
| 28 |
+
getDayZhi(): string;
|
| 29 |
+
getSolar(): Solar;
|
| 30 |
+
getYearInChinese(): string;
|
| 31 |
+
getMonthInChinese(): string;
|
| 32 |
+
getDayInChinese(): string;
|
| 33 |
+
isLeapMonth(): boolean;
|
| 34 |
+
toFullString(): string;
|
| 35 |
+
toString(): string;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export class LunarYear {
|
| 39 |
+
static fromYear(year: number): LunarYear;
|
| 40 |
+
getYear(): number;
|
| 41 |
+
getMonths(): LunarMonth[];
|
| 42 |
+
getLeapMonth(): number;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export class LunarMonth {
|
| 46 |
+
getYear(): number;
|
| 47 |
+
getMonth(): number;
|
| 48 |
+
isLeap(): boolean;
|
| 49 |
+
getDayCount(): number;
|
| 50 |
+
}
|
| 51 |
+
}
|