feat: Complete AI interpretation system and fix database issues

- Fixed all database connection errors in aiInterpretation.cjs
- Updated better-sqlite3 API calls from callback to sync methods
- Removed AI interpretation buttons from history page
- Added pagination to history page (10 records per page)
- Fixed mobile responsive design for AI interpretation results
- Updated Koyeb deployment configuration to use npm instead of pnpm
- Resolved API limit issues for history records
This commit is contained in:
patdelphi
2025-08-22 15:57:53 +08:00
parent 7910dd4bbf
commit b0594d5131
32 changed files with 3641 additions and 8500 deletions

View File

@@ -6,8 +6,8 @@ services:
type: web type: web
git: git:
branch: master branch: master
build_command: npm install -g pnpm@9.0.0 && pnpm install --frozen-lockfile && pnpm run build build_command: npm ci && npm run build
run_command: pnpm start run_command: npm start
instance_type: nano instance_type: nano
ports: ports:
- port: 8000 - port: 8000

1
dist/assets/index-6fDJrSnT.css vendored Normal file

File diff suppressed because one or more lines are too long

718
dist/assets/index-B6tpII-u.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

18
dist/assets/index.es-DlHrZ7Is.js vendored Normal file

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -4,8 +4,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script type="module" crossorigin src="/assets/index-CGu5zB0q.js"></script> <script type="module" crossorigin src="/assets/index-B6tpII-u.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CW1NqJRG.css"> <link rel="stylesheet" crossorigin href="/assets/index-6fDJrSnT.css">
</head> </head>
<body> <body>

1869
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"version": "3.0.0", "version": "3.0.0",
"description": "神机阁 - AI驱动的中华传统命理分析平台提供八字、紫微斗数、易经占卜等专业分析服务", "description": "神机阁 - AI驱动的中华传统命理分析平台提供八字、紫微斗数、易经占卜等专业分析服务",
"type": "module", "type": "module",
"packageManager": "pnpm@9.0.0", "packageManager": "npm@10.0.0",
"scripts": { "scripts": {
"dev": "concurrently \"npm run server\" \"vite\"", "dev": "concurrently \"npm run server\" \"vite\"",
"server": "nodemon server/index.cjs", "server": "nodemon server/index.cjs",
@@ -69,9 +69,11 @@
"react-day-picker": "8.10.1", "react-day-picker": "8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"react-router-dom": "^6", "react-router-dom": "^6",
"recharts": "^2.12.4", "recharts": "^2.12.4",
"remark-gfm": "^4.0.1",
"sonner": "^1.7.2", "sonner": "^1.7.2",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",

7105
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -77,6 +77,28 @@ CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON user_sessions(token_hash); CREATE INDEX IF NOT EXISTS idx_sessions_token ON user_sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON user_sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_sessions_expires ON user_sessions(expires_at);
-- AI解读结果表
CREATE TABLE IF NOT EXISTS ai_interpretations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
analysis_id INTEGER NOT NULL, -- 关联到numerology_readings表的id
analysis_type TEXT NOT NULL CHECK (analysis_type IN ('bazi', 'ziwei', 'yijing')),
content TEXT NOT NULL, -- AI解读的完整内容
model TEXT, -- 使用的AI模型
tokens_used INTEGER, -- 消耗的token数量
success BOOLEAN DEFAULT 1,
error_message TEXT, -- 如果失败,记录错误信息
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (analysis_id) REFERENCES numerology_readings(id) ON DELETE CASCADE
);
-- 创建AI解读相关索引
CREATE INDEX IF NOT EXISTS idx_ai_interpretations_user_id ON ai_interpretations(user_id);
CREATE INDEX IF NOT EXISTS idx_ai_interpretations_analysis_id ON ai_interpretations(analysis_id);
CREATE INDEX IF NOT EXISTS idx_ai_interpretations_created_at ON ai_interpretations(created_at DESC);
-- 触发器自动更新updated_at字段 -- 触发器自动更新updated_at字段
CREATE TRIGGER IF NOT EXISTS update_users_timestamp CREATE TRIGGER IF NOT EXISTS update_users_timestamp
AFTER UPDATE ON users AFTER UPDATE ON users
@@ -99,6 +121,13 @@ CREATE TRIGGER IF NOT EXISTS update_numerology_readings_timestamp
UPDATE numerology_readings SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; UPDATE numerology_readings SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END; END;
CREATE TRIGGER IF NOT EXISTS update_ai_interpretations_timestamp
AFTER UPDATE ON ai_interpretations
FOR EACH ROW
BEGIN
UPDATE ai_interpretations SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
-- 清理过期会话的触发器 -- 清理过期会话的触发器
CREATE TRIGGER IF NOT EXISTS cleanup_expired_sessions CREATE TRIGGER IF NOT EXISTS cleanup_expired_sessions
AFTER INSERT ON user_sessions AFTER INSERT ON user_sessions

View File

@@ -10,6 +10,7 @@ const analysisRoutes = require('./routes/analysis.cjs');
const historyRoutes = require('./routes/history.cjs'); const historyRoutes = require('./routes/history.cjs');
const profileRoutes = require('./routes/profile.cjs'); const profileRoutes = require('./routes/profile.cjs');
const downloadRoutes = require('./routes/download.cjs'); const downloadRoutes = require('./routes/download.cjs');
const aiInterpretationRoutes = require('./routes/aiInterpretation.cjs');
// 导入中间件 // 导入中间件
const { errorHandler } = require('./middleware/errorHandler.cjs'); const { errorHandler } = require('./middleware/errorHandler.cjs');
@@ -94,6 +95,7 @@ app.use('/api/analysis', analysisRoutes);
app.use('/api/history', historyRoutes); app.use('/api/history', historyRoutes);
app.use('/api/profile', profileRoutes); app.use('/api/profile', profileRoutes);
app.use('/api/download', downloadRoutes); app.use('/api/download', downloadRoutes);
app.use('/api/ai-interpretation', aiInterpretationRoutes);
// 静态文件服务 (用于生产环境) // 静态文件服务 (用于生产环境)
// 强制在 Koyeb 部署时启用静态文件服务 // 强制在 Koyeb 部署时启用静态文件服务

View File

@@ -0,0 +1,219 @@
const express = require('express');
const { authenticate } = require('../middleware/auth.cjs');
const { getDB } = require('../database/index.cjs');
const router = express.Router();
// 保存AI解读结果
router.post('/save', authenticate, async (req, res) => {
try {
const { analysis_id, analysis_type, content, model, tokens_used, success, error_message } = req.body;
const user_id = req.user.id;
// 验证必需参数
if (!analysis_id || !analysis_type || (!content && success !== false)) {
return res.status(400).json({
error: '缺少必需参数analysis_id, analysis_type, content'
});
}
// 验证analysis_id是否属于当前用户
const db = getDB();
const analysisExists = db.prepare(
'SELECT id FROM numerology_readings WHERE id = ? AND user_id = ?'
).get(analysis_id, user_id);
if (!analysisExists) {
return res.status(404).json({
error: '分析记录不存在或无权限访问'
});
}
// 检查是否已存在AI解读记录
const existingInterpretation = db.prepare(
'SELECT id FROM ai_interpretations WHERE analysis_id = ? AND user_id = ?'
).get(analysis_id, user_id);
if (existingInterpretation) {
// 更新现有记录
const updateStmt = db.prepare(`
UPDATE ai_interpretations
SET content = ?, model = ?, tokens_used = ?, success = ?, error_message = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
updateStmt.run(content, model, tokens_used, success ? 1 : 0, error_message, existingInterpretation.id);
res.json({
success: true,
message: 'AI解读结果更新成功',
id: existingInterpretation.id
});
} else {
// 创建新记录
const insertStmt = db.prepare(`
INSERT INTO ai_interpretations (user_id, analysis_id, analysis_type, content, model, tokens_used, success, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = insertStmt.run(user_id, analysis_id, analysis_type, content, model, tokens_used, success ? 1 : 0, error_message);
res.json({
success: true,
message: 'AI解读结果保存成功',
id: result.lastInsertRowid
});
}
} catch (error) {
console.error('保存AI解读结果失败:', error);
res.status(500).json({
error: '保存AI解读结果失败',
details: error.message
});
}
});
// 获取AI解读结果
router.get('/get/:analysis_id', authenticate, async (req, res) => {
try {
const { analysis_id } = req.params;
const user_id = req.user.id;
const db = getDB();
const interpretation = db.prepare(`
SELECT ai.*, nr.name, nr.reading_type, nr.created_at as analysis_created_at
FROM ai_interpretations ai
JOIN numerology_readings nr ON ai.analysis_id = nr.id
WHERE ai.analysis_id = ? AND ai.user_id = ?
ORDER BY ai.created_at DESC
LIMIT 1
`).get(analysis_id, user_id);
if (!interpretation) {
return res.status(404).json({
error: 'AI解读结果不存在'
});
}
res.json({
success: true,
data: {
id: interpretation.id,
analysis_id: interpretation.analysis_id,
analysis_type: interpretation.analysis_type,
content: interpretation.content,
model: interpretation.model,
tokens_used: interpretation.tokens_used,
success: interpretation.success === 1,
error_message: interpretation.error_message,
created_at: interpretation.created_at,
updated_at: interpretation.updated_at,
analysis_name: interpretation.name,
analysis_created_at: interpretation.analysis_created_at
}
});
} catch (error) {
console.error('获取AI解读结果失败:', error);
res.status(500).json({
error: '获取AI解读结果失败',
details: error.message
});
}
});
// 获取用户的所有AI解读记录
router.get('/list', authenticate, async (req, res) => {
try {
const user_id = req.user.id;
const { page = 1, limit = 20, analysis_type } = req.query;
const offset = (page - 1) * limit;
const db = getDB();
let whereClause = 'WHERE ai.user_id = ?';
let params = [user_id];
if (analysis_type) {
whereClause += ' AND ai.analysis_type = ?';
params.push(analysis_type);
}
const interpretations = db.prepare(`
SELECT ai.*, nr.name, nr.birth_date, nr.reading_type, nr.created_at as analysis_created_at
FROM ai_interpretations ai
JOIN numerology_readings nr ON ai.analysis_id = nr.id
${whereClause}
ORDER BY ai.created_at DESC
LIMIT ? OFFSET ?
`).all(...params, limit, offset);
// 获取总数
const totalResult = db.prepare(`
SELECT COUNT(*) as count
FROM ai_interpretations ai
JOIN numerology_readings nr ON ai.analysis_id = nr.id
${whereClause}
`).get(...params);
const total = totalResult.count;
res.json({
success: true,
data: interpretations.map(item => ({
id: item.id,
analysis_id: item.analysis_id,
analysis_type: item.analysis_type,
content: item.content,
model: item.model,
tokens_used: item.tokens_used,
success: item.success === 1,
error_message: item.error_message,
created_at: item.created_at,
updated_at: item.updated_at,
analysis_name: item.name,
analysis_birth_date: item.birth_date,
analysis_created_at: item.analysis_created_at
})),
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / limit)
}
});
} catch (error) {
console.error('获取AI解读列表失败:', error);
res.status(500).json({
error: '获取AI解读列表失败',
details: error.message
});
}
});
// 删除AI解读结果
router.delete('/delete/:analysis_id', authenticate, async (req, res) => {
try {
const { analysis_id } = req.params;
const user_id = req.user.id;
const db = getDB();
const deleteStmt = db.prepare(
'DELETE FROM ai_interpretations WHERE analysis_id = ? AND user_id = ?'
);
const result = deleteStmt.run(analysis_id, user_id);
if (result.changes === 0) {
return res.status(404).json({
error: 'AI解读结果不存在或无权限删除'
});
}
res.json({
success: true,
message: 'AI解读结果删除成功'
});
} catch (error) {
console.error('删除AI解读结果失败:', error);
res.status(500).json({
error: '删除AI解读结果失败',
details: error.message
});
}
});
module.exports = router;

View File

@@ -110,7 +110,7 @@ router.post('/', authenticate, async (req, res) => {
// 记录下载历史(可选) // 记录下载历史(可选)
try { try {
const db = dbManager.getDb(); const db = dbManager.getDatabase();
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO download_history (user_id, analysis_type, format, filename, created_at) INSERT INTO download_history (user_id, analysis_type, format, filename, created_at)
VALUES (?, ?, ?, ?, datetime('now')) VALUES (?, ?, ?, ?, datetime('now'))

View File

@@ -234,7 +234,6 @@ const BaziAnalysisDisplay: React.FC<BaziAnalysisDisplayProps> = ({ birthDate })
modern_applications: analysisResult.modern_applications || {} modern_applications: analysisResult.modern_applications || {}
}); });
} catch (err) { } catch (err) {
console.error('获取分析数据出错:', err);
setError(err instanceof Error ? err.message : '分析数据获取失败,请稍后重试'); setError(err instanceof Error ? err.message : '分析数据获取失败,请稍后重试');
} finally { } finally {
setIsLoading(false); setIsLoading(false);

View File

@@ -90,7 +90,6 @@ const CompleteBaziAnalysis: React.FC<CompleteBaziAnalysisProps> = ({ birthDate,
setAnalysisData(analysisResult); setAnalysisData(analysisResult);
} catch (err) { } catch (err) {
console.error('获取分析数据出错:', err);
setError(err instanceof Error ? err.message : '分析数据获取失败,请稍后重试'); setError(err instanceof Error ? err.message : '分析数据获取失败,请稍后重试');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -281,26 +280,25 @@ const CompleteBaziAnalysis: React.FC<CompleteBaziAnalysisProps> = ({ birthDate,
<div className="min-h-screen bg-gradient-to-br from-red-50 to-yellow-50 py-8"> <div className="min-h-screen bg-gradient-to-br from-red-50 to-yellow-50 py-8">
<div className="max-w-7xl mx-auto px-4 space-y-8" id="bazi-analysis-content" data-export-content> <div className="max-w-7xl mx-auto px-4 space-y-8" id="bazi-analysis-content" data-export-content>
{/* 下载和AI解读按钮 */} {/* 下载按钮 */}
<div className="flex justify-between items-start no-export" data-no-export> <div className="flex justify-end no-export" data-no-export>
<div className="flex-1"> <DownloadButton
<AIInterpretationButton analysisData={analysisData}
analysisData={analysisData} analysisType="bazi"
analysisType="bazi" userName={birthDate.name}
analysisId={`bazi-${birthDate.date}-${birthDate.time}`} targetElementId="bazi-analysis-content"
onConfigClick={() => setShowAIConfig(true)} className="sticky top-4 z-10"
className="sticky top-4 z-10" />
/> </div>
</div>
<div className="ml-4"> {/* AI解读按钮 - 独立占用全宽 */}
<DownloadButton <div className="w-full no-export" data-no-export>
analysisData={analysisData} <AIInterpretationButton
analysisType="bazi" analysisData={analysisData}
userName={birthDate.name} analysisType="bazi"
targetElementId="bazi-analysis-content" onConfigClick={() => setShowAIConfig(true)}
className="sticky top-4 z-10" className="w-full"
/> />
</div>
</div> </div>
{/* 标题和基本信息 */} {/* 标题和基本信息 */}

View File

@@ -87,7 +87,6 @@ const CompleteYijingAnalysis: React.FC<CompleteYijingAnalysisProps> = ({
setAnalysisData(analysisResult); setAnalysisData(analysisResult);
} catch (err) { } catch (err) {
console.error('获取分析数据出错:', err);
setError(err instanceof Error ? err.message : '分析数据获取失败,请稍后重试'); setError(err instanceof Error ? err.message : '分析数据获取失败,请稍后重试');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -269,26 +268,25 @@ const CompleteYijingAnalysis: React.FC<CompleteYijingAnalysisProps> = ({
<div className="min-h-screen bg-gradient-to-br from-red-50 to-yellow-50 py-8"> <div className="min-h-screen bg-gradient-to-br from-red-50 to-yellow-50 py-8">
<div className="max-w-7xl mx-auto px-4 space-y-8" id="yijing-analysis-content" data-export-content> <div className="max-w-7xl mx-auto px-4 space-y-8" id="yijing-analysis-content" data-export-content>
{/* 下载和AI解读按钮 */} {/* 下载按钮 */}
<div className="flex justify-between items-start no-export" data-no-export> <div className="flex justify-end no-export" data-no-export>
<div className="flex-1"> <DownloadButton
<AIInterpretationButton analysisData={analysisData}
analysisData={analysisData} analysisType="yijing"
analysisType="yijing" userName={question ? `占卜_${question.substring(0, 10)}` : 'user'}
analysisId={`yijing-${question || 'general'}-${Date.now()}`} targetElementId="yijing-analysis-content"
onConfigClick={() => setShowAIConfig(true)} className="sticky top-4 z-10"
className="sticky top-4 z-10" />
/> </div>
</div>
<div className="ml-4"> {/* AI解读按钮 - 独立占用全宽 */}
<DownloadButton <div className="w-full no-export" data-no-export>
analysisData={analysisData} <AIInterpretationButton
analysisType="yijing" analysisData={analysisData}
userName={question ? `占卜_${question.substring(0, 10)}` : 'user'} analysisType="yijing"
targetElementId="yijing-analysis-content" onConfigClick={() => setShowAIConfig(true)}
className="sticky top-4 z-10" className="w-full"
/> />
</div>
</div> </div>
{/* 标题和基本信息 */} {/* 标题和基本信息 */}

View File

@@ -292,7 +292,6 @@ const CompleteZiweiAnalysis: React.FC<CompleteZiweiAnalysisProps> = ({ birthDate
setAnalysisData(analysisResult); setAnalysisData(analysisResult);
} catch (err) { } catch (err) {
console.error('获取分析数据出错:', err);
setError(err instanceof Error ? err.message : '分析数据获取失败,请稍后重试'); setError(err instanceof Error ? err.message : '分析数据获取失败,请稍后重试');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -584,26 +583,25 @@ const CompleteZiweiAnalysis: React.FC<CompleteZiweiAnalysisProps> = ({ birthDate
<div className="min-h-screen bg-gradient-to-br from-purple-50 to-indigo-50 py-8"> <div className="min-h-screen bg-gradient-to-br from-purple-50 to-indigo-50 py-8">
<div className="max-w-7xl mx-auto px-4 space-y-8" id="ziwei-analysis-content" data-export-content> <div className="max-w-7xl mx-auto px-4 space-y-8" id="ziwei-analysis-content" data-export-content>
{/* 下载和AI解读按钮 */} {/* 下载按钮 */}
<div className="flex justify-between items-start no-export" data-no-export> <div className="flex justify-end no-export" data-no-export>
<div className="flex-1"> <DownloadButton
<AIInterpretationButton analysisData={analysisData}
analysisData={analysisData} analysisType="ziwei"
analysisType="ziwei" userName={birthDate.name}
analysisId={`ziwei-${birthDate.date}-${birthDate.time}`} targetElementId="ziwei-analysis-content"
onConfigClick={() => setShowAIConfig(true)} className="sticky top-4 z-10"
className="sticky top-4 z-10" />
/> </div>
</div>
<div className="ml-4"> {/* AI解读按钮 - 独立占用全宽 */}
<DownloadButton <div className="w-full no-export" data-no-export>
analysisData={analysisData} <AIInterpretationButton
analysisType="ziwei" analysisData={analysisData}
userName={birthDate.name} analysisType="ziwei"
targetElementId="ziwei-analysis-content" onConfigClick={() => setShowAIConfig(true)}
className="sticky top-4 z-10" className="w-full"
/> />
</div>
</div> </div>
{/* 标题和基本信息 */} {/* 标题和基本信息 */}

View File

@@ -33,7 +33,6 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
]; ];
const toggleMobileMenu = () => { const toggleMobileMenu = () => {
console.log('Toggle mobile menu:', !isMobileMenuOpen);
setIsMobileMenuOpen(!isMobileMenuOpen); setIsMobileMenuOpen(!isMobileMenuOpen);
}; };

View File

@@ -21,9 +21,10 @@ const AIConfigModal: React.FC<AIConfigModalProps> = ({
apiKey: '', apiKey: '',
apiUrl: '', apiUrl: '',
modelName: '', modelName: '',
maxTokens: 2000, maxTokens: 4000,
temperature: 0.7, temperature: 0.7,
timeout: 30000 timeout: 30000,
stream: true
}); });
const [showApiKey, setShowApiKey] = useState(false); const [showApiKey, setShowApiKey] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -40,7 +41,7 @@ const AIConfigModal: React.FC<AIConfigModalProps> = ({
}, [isOpen]); }, [isOpen]);
// 处理输入变化 // 处理输入变化
const handleInputChange = (field: keyof AIConfig, value: string | number) => { const handleInputChange = (field: keyof AIConfig, value: string | number | boolean) => {
setConfig(prev => ({ setConfig(prev => ({
...prev, ...prev,
[field]: value [field]: value

View File

@@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Brain, Loader2, Sparkles, AlertCircle, CheckCircle, Settings, RefreshCw, Eye, X } from 'lucide-react'; import { Brain, Loader2, Sparkles, AlertCircle, CheckCircle, Settings, RefreshCw, Eye, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ChineseButton } from './ChineseButton'; import { ChineseButton } from './ChineseButton';
import { ChineseCard, ChineseCardContent, ChineseCardHeader, ChineseCardTitle } from './ChineseCard'; import { ChineseCard, ChineseCardContent, ChineseCardHeader, ChineseCardTitle } from './ChineseCard';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
@@ -8,13 +10,15 @@ import {
saveAIInterpretation, saveAIInterpretation,
getAIInterpretation, getAIInterpretation,
AIInterpretationResult, AIInterpretationResult,
AIInterpretationRequest AIInterpretationRequest,
convertAnalysisToMarkdown
} from '../../services/aiInterpretationService'; } from '../../services/aiInterpretationService';
import { getAIConfig, validateAIConfig, getPromptTemplate } from '../../config/aiConfig'; import { getAIConfig, validateAIConfig, getPromptTemplate } from '../../config/aiConfig';
import { toast } from 'sonner'; import { toast } from 'sonner';
interface AIInterpretationButtonProps { interface AIInterpretationButtonProps {
analysisData: any; analysisData?: any; // 分析数据对象(可选)
analysisMarkdown?: string; // 直接传递的MD内容可选
analysisType: 'bazi' | 'ziwei' | 'yijing'; analysisType: 'bazi' | 'ziwei' | 'yijing';
analysisId?: string; // 用于缓存解读结果 analysisId?: string; // 用于缓存解读结果
className?: string; className?: string;
@@ -22,23 +26,26 @@ interface AIInterpretationButtonProps {
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
showConfigButton?: boolean; // 是否显示配置按钮 showConfigButton?: boolean; // 是否显示配置按钮
onConfigClick?: () => void; // 配置按钮点击回调 onConfigClick?: () => void; // 配置按钮点击回调
onAIInterpretationClick?: () => void; // AI解读按钮点击回调可选用于自定义行为
} }
const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
analysisData, analysisData,
analysisMarkdown,
analysisType, analysisType,
analysisId, analysisId,
className, className,
variant = 'default', variant = 'default',
size = 'md', size = 'md',
showConfigButton = true, showConfigButton = true,
onConfigClick onConfigClick,
onAIInterpretationClick
}) => { }) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [interpretation, setInterpretation] = useState<AIInterpretationResult | null>(null); const [interpretation, setInterpretation] = useState<AIInterpretationResult | null>(null);
const [showResult, setShowResult] = useState(false); const [showResult, setShowResult] = useState(false);
const [isConfigValid, setIsConfigValid] = useState(false); const [isConfigValid, setIsConfigValid] = useState(false);
const [debugInfo, setDebugInfo] = useState<any>(null);
const [requestStartTime, setRequestStartTime] = useState<number | null>(null); const [requestStartTime, setRequestStartTime] = useState<number | null>(null);
const [streamingContent, setStreamingContent] = useState<string>(''); // 流式内容 const [streamingContent, setStreamingContent] = useState<string>(''); // 流式内容
@@ -48,15 +55,64 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
setIsConfigValid(validateAIConfig(config)); setIsConfigValid(validateAIConfig(config));
}, []); }, []);
// 加载已保存的解读结果 // 生成唯一的分析ID包含分析数据的时间戳
useEffect(() => { const generateAnalysisId = () => {
if (analysisId) { if (analysisId) {
const savedInterpretation = getAIInterpretation(analysisId); return analysisId;
if (savedInterpretation) { }
setInterpretation(savedInterpretation);
// 尝试从分析数据中提取时间戳
let timestamp = '';
if (analysisData) {
// 检查多种可能的时间戳字段
const timeFields = [
analysisData.created_at,
analysisData.timestamp,
analysisData.analysis_time,
analysisData.basic_info?.created_at,
analysisData.basic_info?.timestamp,
analysisData.basic_info?.analysis_time
];
for (const field of timeFields) {
if (field) {
timestamp = new Date(field).getTime().toString();
break;
}
}
// 如果没有找到时间戳,使用数据的哈希值作为标识
if (!timestamp) {
const dataString = JSON.stringify(analysisData);
// 使用简单的哈希算法替代btoa避免Unicode字符问题
let hash = 0;
for (let i = 0; i < dataString.length; i++) {
const char = dataString.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // 转换为32位整数
}
timestamp = Math.abs(hash).toString(36).slice(0, 16); // 使用36进制表示
} }
} }
}, [analysisId]);
return `${analysisType}-${timestamp || Date.now()}`;
};
const uniqueAnalysisId = generateAnalysisId();
// 加载已保存的解读结果
useEffect(() => {
const loadSavedInterpretation = async () => {
if (uniqueAnalysisId) {
const savedInterpretation = await getAIInterpretation(uniqueAnalysisId);
if (savedInterpretation) {
setInterpretation(savedInterpretation);
}
}
};
loadSavedInterpretation();
}, [uniqueAnalysisId]);
// 处理AI解读请求 // 处理AI解读请求
const handleAIInterpretation = async () => { const handleAIInterpretation = async () => {
@@ -68,7 +124,7 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
return; return;
} }
if (!analysisData) { if (!analysisData && !analysisMarkdown) {
toast.error('没有可解读的分析数据'); toast.error('没有可解读的分析数据');
return; return;
} }
@@ -79,147 +135,47 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
// 获取用户配置的AI设置 // 获取用户配置的AI设置
const currentConfig = getAIConfig(); const currentConfig = getAIConfig();
setDebugInfo({
status: '开始请求',
startTime: new Date().toLocaleString(),
config: {
apiUrl: currentConfig.apiUrl,
modelName: currentConfig.modelName,
maxTokens: currentConfig.maxTokens,
temperature: currentConfig.temperature,
timeout: currentConfig.timeout,
apiKeyLength: currentConfig.apiKey?.length || 0
},
analysisType,
analysisDataSize: JSON.stringify(analysisData).length
});
try { try {
const request: AIInterpretationRequest = { const request: AIInterpretationRequest = {
analysisType, analysisType,
analysisContent: analysisData, analysisContent: analysisMarkdown || analysisData, // 优先使用MD字符串
onStreamUpdate: currentConfig.stream ? (content: string) => { onStreamUpdate: currentConfig.stream ? (content: string) => {
setStreamingContent(content); setStreamingContent(content);
setShowResult(true); // 开始流式输出时就显示结果区域 setShowResult(true); // 开始流式输出时就显示结果区域
} : undefined } : undefined
}; };
// 获取提示词用于调试显示
const analysisMarkdown = typeof request.analysisContent === 'string'
? request.analysisContent
: JSON.stringify(request.analysisContent, null, 2);
const promptTemplate = getPromptTemplate(request.analysisType);
const fullPrompt = promptTemplate.replace('{analysisContent}', analysisMarkdown);
// 生成curl命令用于调试
const requestBody = {
model: currentConfig.modelName,
messages: [{ role: 'user', content: fullPrompt }],
max_tokens: currentConfig.maxTokens,
temperature: currentConfig.temperature
};
const curlCommand = `curl -X POST "${currentConfig.apiUrl}" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer ${currentConfig.apiKey.substring(0, 10)}..." \\
-d '${JSON.stringify(requestBody, null, 2).replace(/'/g, "'\"'\"'")}'`;
setDebugInfo(prev => ({
...prev,
status: '发送请求中',
requestTime: new Date().toLocaleString(),
apiParams: {
model: currentConfig.modelName,
maxTokens: currentConfig.maxTokens,
temperature: currentConfig.temperature,
promptLength: fullPrompt.length,
promptPreview: fullPrompt.substring(0, 300) + '...',
fullPrompt: fullPrompt, // 完整的prompt用于调试
requestBody: JSON.stringify(requestBody, null, 2),
curlCommand: curlCommand
}
}));
const result = await requestAIInterpretation(request); const result = await requestAIInterpretation(request);
const endTime = Date.now();
const duration = requestStartTime ? endTime - requestStartTime : 0;
console.log('🐛 调试时间计算 (成功):', {
requestStartTime,
endTime,
duration,
durationSeconds: duration / 1000
});
setDebugInfo(prev => ({
...prev,
status: result.success ? '请求成功' : '请求失败',
endTime: new Date().toLocaleString(),
duration: `${duration}ms (${(duration / 1000).toFixed(1)}秒)`,
result: {
success: result.success,
contentLength: result.content?.length || 0,
error: result.error,
model: result.model,
tokensUsed: result.tokensUsed,
actualDuration: duration,
startTime: requestStartTime,
endTime: endTime
}
}));
if (result.success) { if (result.success) {
console.log('AI解读成功结果:', result);
setInterpretation(result); setInterpretation(result);
setShowResult(true); setShowResult(true);
setStreamingContent(''); // 清空流式内容,使用最终结果 setStreamingContent(''); // 清空流式内容,使用最终结果
// 保存解读结果 // 保存解读结果
if (analysisId) { if (uniqueAnalysisId) {
saveAIInterpretation(analysisId, result); try {
await saveAIInterpretation(uniqueAnalysisId, result, analysisType);
} catch (saveError) {
// 保存失败不影响用户体验,静默处理
}
} }
toast.success(`AI解读完成,耗时${duration}ms`); toast.success('AI解读完成');
} else { } else {
console.error('AI解读失败:', result.error);
toast.error(`AI解读失败: ${result.error}`); toast.error(`AI解读失败: ${result.error}`);
setStreamingContent(''); // 清空流式内容 setStreamingContent(''); // 清空流式内容
} }
} catch (error: any) { } catch (error: any) {
const endTime = Date.now();
const duration = requestStartTime ? endTime - requestStartTime : 0;
console.log('🐛 调试时间计算:', {
requestStartTime,
endTime,
duration,
durationSeconds: duration / 1000
});
setDebugInfo(prev => ({
...prev,
status: '请求异常',
endTime: new Date().toLocaleString(),
duration: `${duration}ms (${(duration / 1000).toFixed(1)}秒)`,
error: {
name: error.name,
message: error.message,
stack: error.stack?.substring(0, 500),
actualDuration: duration,
startTime: requestStartTime,
endTime: endTime
}
}));
console.error('AI解读出错:', error);
toast.error(`解读过程出错: ${error.message || '未知错误'}`); toast.error(`解读过程出错: ${error.message || '未知错误'}`);
setStreamingContent(''); // 清空流式内容 setStreamingContent(''); // 清空流式内容
} finally { } finally {
setIsLoading(false); setIsLoading(false);
// 不要立即清除requestStartTime,保留用于调试 setRequestStartTime(null);
// setRequestStartTime(null);
} }
}; };
@@ -241,16 +197,25 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
}; };
return ( return (
<div className={cn('space-y-4', className)}> <div className={cn('w-full space-y-4', className)}>
{/* AI解读按钮区域 */} {/* AI解读按钮区域 */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2 flex-wrap gap-2">
<ChineseButton <ChineseButton
variant="outline" variant="outline"
size="md" size="md"
onClick={interpretation ? () => setShowResult(!showResult) : handleAIInterpretation} onClick={() => {
if (onAIInterpretationClick) {
onAIInterpretationClick();
}
if (interpretation) {
setShowResult(!showResult);
} else if (!onAIInterpretationClick) {
handleAIInterpretation();
}
}}
disabled={isLoading || (!isConfigValid && !interpretation)} disabled={isLoading || (!isConfigValid && !interpretation)}
className={cn( className={cn(
'px-3 sm:px-6 text-xs sm:text-sm', 'min-h-[40px] px-3 sm:px-6 text-xs sm:text-sm flex-shrink-0',
!isConfigValid && !interpretation && 'opacity-50 cursor-not-allowed' !isConfigValid && !interpretation && 'opacity-50 cursor-not-allowed'
)} )}
> >
@@ -259,7 +224,7 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
) : ( ) : (
<Eye className="mr-1 h-3 w-3 sm:h-4 sm:w-4" /> <Eye className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
)} )}
<span className="hidden sm:inline"> <span className="text-xs sm:text-sm">
{isLoading {isLoading
? 'AI解读中...' ? 'AI解读中...'
: interpretation : interpretation
@@ -273,13 +238,13 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
{interpretation && ( {interpretation && (
<ChineseButton <ChineseButton
variant="outline" variant="outline"
size={size} size="md"
onClick={handleReinterpret} onClick={handleReinterpret}
disabled={isLoading} disabled={isLoading}
className="flex items-center space-x-1" className="min-h-[40px] px-3 sm:px-4 flex items-center space-x-1 flex-shrink-0"
> >
<RefreshCw className={cn('h-3 w-3', isLoading && 'animate-spin')} /> <RefreshCw className={cn('h-3 w-3 sm:h-4 sm:w-4', isLoading && 'animate-spin')} />
<span className="text-xs"></span> <span className="text-xs sm:text-sm"></span>
</ChineseButton> </ChineseButton>
)} )}
@@ -287,12 +252,12 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
{showConfigButton && onConfigClick && ( {showConfigButton && onConfigClick && (
<ChineseButton <ChineseButton
variant="ghost" variant="ghost"
size={size} size="md"
onClick={onConfigClick} onClick={onConfigClick}
className="flex items-center space-x-1 text-gray-500 hover:text-gray-700" className="min-h-[40px] px-3 sm:px-4 flex items-center space-x-1 text-gray-500 hover:text-gray-700 flex-shrink-0"
> >
<Settings className="h-3 w-3" /> <Settings className="h-3 w-3 sm:h-4 sm:w-4" />
<span className="text-xs"></span> <span className="text-xs sm:text-sm"></span>
</ChineseButton> </ChineseButton>
)} )}
</div> </div>
@@ -308,123 +273,11 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
</div> </div>
)} )}
{/* 调试信息 */}
{debugInfo && (
<div className="text-xs text-gray-500 p-3 bg-gray-100 rounded border">
<div className="flex justify-between items-center mb-2">
<div className="font-bold">🔍 AI解读调试信息</div>
<button
onClick={() => setDebugInfo(null)}
className="text-gray-400 hover:text-gray-600 p-1"
title="清除调试信息"
>
<X className="h-3 w-3" />
</button>
</div>
<div className="space-y-1">
<div><strong>:</strong> {debugInfo.status}</div>
<div><strong>:</strong> {debugInfo.startTime}</div>
{debugInfo.endTime && <div><strong>:</strong> {debugInfo.endTime}</div>}
{debugInfo.duration && <div><strong>:</strong> {debugInfo.duration}</div>}
<div><strong>:</strong> {debugInfo.analysisType}</div>
<div><strong>:</strong> {debugInfo.analysisDataSize} </div>
{debugInfo.config && (
<details className="mt-2">
<summary className="cursor-pointer font-medium"></summary>
<div className="ml-2 mt-1 space-y-1">
<div><strong>API地址:</strong> {debugInfo.config.apiUrl}</div>
<div><strong>:</strong> {debugInfo.config.modelName}</div>
<div><strong>Token:</strong> {debugInfo.config.maxTokens}</div>
<div><strong>:</strong> {debugInfo.config.temperature}</div>
<div><strong>:</strong> {debugInfo.config.timeout}ms</div>
<div><strong>API Key长度:</strong> {debugInfo.config.apiKeyLength}</div>
</div>
</details>
)}
{debugInfo.apiParams && (
<details className="mt-2">
<summary className="cursor-pointer font-medium">API调用参数</summary>
<div className="ml-2 mt-1 space-y-1">
<div><strong>:</strong> {debugInfo.apiParams.model}</div>
<div><strong>Token:</strong> {debugInfo.apiParams.maxTokens}</div>
<div><strong>:</strong> {debugInfo.apiParams.temperature}</div>
<div><strong>Prompt长度:</strong> {debugInfo.apiParams.promptLength} </div>
<div><strong>Prompt预览:</strong></div>
<pre className="text-xs mt-1 p-2 bg-white rounded border whitespace-pre-wrap max-h-32 overflow-y-auto">{debugInfo.apiParams.promptPreview}</pre>
<details className="mt-2">
<summary className="cursor-pointer text-xs">Prompt</summary>
<pre className="text-xs mt-1 p-2 bg-white rounded border whitespace-pre-wrap max-h-64 overflow-y-auto">{debugInfo.apiParams.fullPrompt}</pre>
</details>
<details className="mt-2">
<summary className="cursor-pointer text-xs">JSON</summary>
<pre className="text-xs mt-1 p-2 bg-white rounded border whitespace-pre-wrap max-h-64 overflow-y-auto">{debugInfo.apiParams.requestBody}</pre>
</details>
<details className="mt-2">
<summary className="cursor-pointer text-xs font-medium text-blue-600">🔧 API调用指令 (curl)</summary>
<div className="mt-1">
<div className="text-xs text-gray-600 mb-1">API:</div>
<pre className="text-xs p-2 bg-black text-green-400 rounded border whitespace-pre-wrap max-h-64 overflow-y-auto font-mono">{debugInfo.apiParams.curlCommand}</pre>
<button
onClick={() => navigator.clipboard.writeText(debugInfo.apiParams.curlCommand)}
className="mt-1 px-2 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600"
>
curl命令
</button>
</div>
</details>
</div>
</details>
)}
{debugInfo.result && (
<details className="mt-2">
<summary className="cursor-pointer font-medium"></summary>
<div className="ml-2 mt-1 space-y-1">
<div><strong>:</strong> {debugInfo.result.success ? '是' : '否'}</div>
<div><strong>:</strong> {debugInfo.result.contentLength}</div>
<div><strong>使:</strong> {debugInfo.result.model || 'N/A'}</div>
<div><strong>Token:</strong> {debugInfo.result.tokensUsed || 'N/A'}</div>
{debugInfo.result.error && <div><strong>:</strong> {debugInfo.result.error}</div>}
<div className="mt-2 p-2 bg-yellow-50 rounded text-xs">
<div><strong>:</strong></div>
<div>: {debugInfo.result.startTime}</div>
<div>: {debugInfo.result.endTime}</div>
<div>: {debugInfo.result.actualDuration}ms</div>
</div>
</div>
</details>
)}
{debugInfo.error && (
<details className="mt-2">
<summary className="cursor-pointer font-medium text-red-600"></summary>
<div className="ml-2 mt-1 space-y-1 text-red-600">
<div><strong>:</strong> {debugInfo.error.name}</div>
<div><strong>:</strong> {debugInfo.error.message}</div>
{debugInfo.error.stack && (
<div><strong>:</strong> <pre className="text-xs mt-1 whitespace-pre-wrap">{debugInfo.error.stack}</pre></div>
)}
<div className="mt-2 p-2 bg-yellow-50 rounded text-xs text-black">
<div><strong>:</strong></div>
<div>: {debugInfo.error.startTime}</div>
<div>: {debugInfo.error.endTime}</div>
<div>: {debugInfo.error.actualDuration}ms</div>
</div>
</div>
</details>
)}
</div>
</div>
)}
{/* AI解读结果显示 */} {/* AI解读结果显示 */}
{(interpretation || streamingContent) && showResult && ( {(interpretation || streamingContent) && showResult && (
<ChineseCard className="border-2 border-purple-200 bg-gradient-to-br from-purple-50 to-blue-50"> <ChineseCard className="w-full border-2 border-purple-200 bg-gradient-to-br from-purple-50 to-blue-50">
<ChineseCardHeader> <ChineseCardHeader>
<ChineseCardTitle className="flex items-center space-x-2 text-purple-800"> <ChineseCardTitle className="flex items-center space-x-2 text-purple-800">
{isLoading ? ( {isLoading ? (
@@ -455,13 +308,70 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
</div> </div>
</div> </div>
) : ( ) : (
<div className="prose prose-sm max-w-none"> <div className="w-full prose prose-sm max-w-none prose-headings:text-gray-900 prose-p:text-gray-800 prose-strong:text-gray-900 prose-ul:text-gray-800 prose-ol:text-gray-800 prose-li:text-gray-800 prose-table:text-gray-800 prose-th:text-gray-900 prose-td:text-gray-800 break-words">
<div className="whitespace-pre-wrap text-gray-800 leading-relaxed"> <ReactMarkdown
{streamingContent || interpretation?.content} remarkPlugins={[remarkGfm]}
{isLoading && streamingContent && ( components={{
<span className="inline-block w-2 h-5 bg-purple-600 animate-pulse ml-1"></span> // 自定义表格样式
)} table: ({node, ...props}) => (
</div> <div className="overflow-x-auto my-4">
<table className="min-w-full border-collapse border border-gray-300 bg-white rounded-lg shadow-sm" {...props} />
</div>
),
th: ({node, ...props}) => (
<th className="border border-gray-300 bg-gray-50 px-4 py-2 text-left font-semibold text-gray-900" {...props} />
),
td: ({node, ...props}) => (
<td className="border border-gray-300 px-4 py-2 text-gray-800" {...props} />
),
// 自定义标题样式
h1: ({node, ...props}) => (
<h1 className="text-2xl font-bold text-purple-800 mb-4 mt-6 border-b border-purple-200 pb-2" {...props} />
),
h2: ({node, ...props}) => (
<h2 className="text-xl font-semibold text-purple-700 mb-3 mt-5" {...props} />
),
h3: ({node, ...props}) => (
<h3 className="text-lg font-medium text-purple-600 mb-2 mt-4" {...props} />
),
// 自定义列表样式
ul: ({node, ...props}) => (
<ul className="list-disc list-inside space-y-1 my-3 text-gray-800" {...props} />
),
ol: ({node, ...props}) => (
<ol className="list-decimal list-inside space-y-1 my-3 text-gray-800" {...props} />
),
// 自定义段落样式
p: ({node, ...props}) => (
<p className="mb-3 leading-relaxed text-gray-800" {...props} />
),
// 自定义强调样式
strong: ({node, ...props}) => (
<strong className="font-semibold text-purple-800" {...props} />
),
em: ({node, ...props}) => (
<em className="italic text-purple-700" {...props} />
),
// 自定义代码块样式
code: ({node, ...props}: any) => {
const isInline = !props.className?.includes('language-');
return isInline ? (
<code className="bg-gray-100 text-purple-800 px-1 py-0.5 rounded text-sm font-mono" {...props} />
) : (
<code className="block bg-gray-100 text-gray-800 p-3 rounded-lg text-sm font-mono overflow-x-auto" {...props} />
);
},
// 自定义引用样式
blockquote: ({node, ...props}) => (
<blockquote className="border-l-4 border-purple-300 pl-4 py-2 my-4 bg-purple-50 text-gray-800 italic" {...props} />
)
}}
>
{streamingContent || interpretation?.content || ''}
</ReactMarkdown>
{isLoading && streamingContent && (
<span className="inline-block w-2 h-5 bg-purple-600 animate-pulse ml-1"></span>
)}
</div> </div>
)} )}
</ChineseCardContent> </ChineseCardContent>

View File

@@ -173,6 +173,7 @@ const ChineseCardContent = React.forwardRef<HTMLDivElement, ChineseCardContentPr
return ( return (
<div <div
className={cn( className={cn(
'w-full',
'text-ink-900', 'text-ink-900',
'leading-relaxed', 'leading-relaxed',
className className

View File

@@ -82,12 +82,7 @@ const DownloadButton: React.FC<DownloadButtonProps> = ({
return true; return true;
}); });
console.log('DownloadButton配置:', {
targetElementId,
totalOptions: allFormatOptions.length,
availableOptions: formatOptions.length,
frontendOptionsAvailable: formatOptions.filter(o => o.mode === 'frontend').length
});
const handleDownload = async (format: DownloadFormat, mode: ExportMode = 'server') => { const handleDownload = async (format: DownloadFormat, mode: ExportMode = 'server') => {
if (disabled || isDownloading) return; if (disabled || isDownloading) return;
@@ -107,7 +102,6 @@ const DownloadButton: React.FC<DownloadButtonProps> = ({
await defaultDownload(format); await defaultDownload(format);
} }
} catch (error) { } catch (error) {
console.error('下载失败:', error);
// 显示错误提示 // 显示错误提示
if (typeof window !== 'undefined' && (window as any).toast) { if (typeof window !== 'undefined' && (window as any).toast) {
(window as any).toast.error(`下载失败: ${error instanceof Error ? error.message : '未知错误'}`); (window as any).toast.error(`下载失败: ${error instanceof Error ? error.message : '未知错误'}`);
@@ -120,30 +114,18 @@ const DownloadButton: React.FC<DownloadButtonProps> = ({
// 前端导出功能 // 前端导出功能
const frontendExport = async (format: DownloadFormat) => { const frontendExport = async (format: DownloadFormat) => {
console.log('开始前端导出,格式:', format, '目标元素ID:', targetElementId);
if (!targetElementId) { if (!targetElementId) {
const error = '未指定导出目标元素ID无法使用前端导出功能'; const error = '未指定导出目标元素ID无法使用前端导出功能';
console.error(error);
throw new Error(error); throw new Error(error);
} }
const element = document.getElementById(targetElementId); const element = document.getElementById(targetElementId);
console.log('查找目标元素:', targetElementId, '找到元素:', element);
if (!element) { if (!element) {
const error = `未找到ID为"${targetElementId}"的元素,请确认页面已完全加载`; const error = `未找到ID为"${targetElementId}"的元素,请确认页面已完全加载`;
console.error(error);
throw new Error(error); throw new Error(error);
} }
console.log('目标元素尺寸:', {
width: element.offsetWidth,
height: element.offsetHeight,
scrollWidth: element.scrollWidth,
scrollHeight: element.scrollHeight
});
if (format === 'png') { if (format === 'png') {
await exportToPNG(element); await exportToPNG(element);
} else if (format === 'pdf') { } else if (format === 'pdf') {
@@ -372,8 +354,6 @@ const DownloadButton: React.FC<DownloadButtonProps> = ({
} }
} catch (error) { } catch (error) {
console.error('下载失败:', error);
// 显示错误提示 // 显示错误提示
if (typeof window !== 'undefined' && (window as any).toast) { if (typeof window !== 'undefined' && (window as any).toast) {
(window as any).toast.error(error instanceof Error ? error.message : '下载失败,请重试'); (window as any).toast.error(error instanceof Error ? error.message : '下载失败,请重试');
@@ -416,7 +396,7 @@ const DownloadButton: React.FC<DownloadButtonProps> = ({
) : ( ) : (
<Download className="h-3 w-3 sm:h-4 sm:w-4" /> <Download className="h-3 w-3 sm:h-4 sm:w-4" />
)} )}
<span className="font-medium hidden sm:inline"> <span className="font-medium text-xs sm:text-sm">
{isDownloading ? `正在生成${getFormatLabel(downloadingFormat!)}...` : '下载'} {isDownloading ? `正在生成${getFormatLabel(downloadingFormat!)}...` : '下载'}
</span> </span>
<ChevronDown className={cn( <ChevronDown className={cn(

View File

@@ -80,7 +80,7 @@ export const getAIConfig = (): AIConfig => {
const parsedConfig = JSON.parse(savedConfig); const parsedConfig = JSON.parse(savedConfig);
return { ...defaultAIConfig, ...parsedConfig }; return { ...defaultAIConfig, ...parsedConfig };
} catch (error) { } catch (error) {
console.warn('解析AI配置失败使用默认配置:', error); // 解析失败,使用默认配置
} }
} }
return defaultAIConfig; return defaultAIConfig;
@@ -93,7 +93,7 @@ export const saveAIConfig = (config: Partial<AIConfig>): void => {
const newConfig = { ...currentConfig, ...config }; const newConfig = { ...currentConfig, ...config };
localStorage.setItem('ai-config', JSON.stringify(newConfig)); localStorage.setItem('ai-config', JSON.stringify(newConfig));
} catch (error) { } catch (error) {
console.error('保存AI配置失败:', error); // 静默处理保存错误
} }
}; };

View File

@@ -31,7 +31,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
setUser(null); setUser(null);
} }
} catch (error) { } catch (error) {
console.error('加载用户信息失败:', error); // 静默处理用户信息加载错误
setUser(null); setUser(null);
} finally { } finally {
setLoading(false); setLoading(false);

View File

@@ -7,9 +7,7 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ||
(window.location.hostname.includes('koyeb.app') ? `${window.location.origin}/api` : `${window.location.origin}/api`)); (window.location.hostname.includes('koyeb.app') ? `${window.location.origin}/api` : `${window.location.origin}/api`));
// 调试信息 // 调试信息
console.log('API_BASE_URL:', API_BASE_URL);
console.log('import.meta.env.DEV:', import.meta.env.DEV);
console.log('import.meta.env.PROD:', import.meta.env.PROD);
interface ApiResponse<T> { interface ApiResponse<T> {
data?: T; data?: T;
@@ -114,7 +112,7 @@ class LocalApiClient {
return { data: data.data || data }; return { data: data.data || data };
} catch (error) { } catch (error) {
console.error('API请求错误:', error); // API请求失败
return { return {
error: { error: {
code: 'NETWORK_ERROR', code: 'NETWORK_ERROR',

View File

@@ -60,8 +60,8 @@ const AnalysisPage: React.FC = () => {
}); });
} }
} catch (error) { } catch (error) {
console.error('加载档案失败:', error); // 静默处理加载错误
} }
}, [user]); }, [user]);
useEffect(() => { useEffect(() => {
@@ -167,15 +167,13 @@ const AnalysisPage: React.FC = () => {
}; };
await localApi.analysis.saveHistory(analysisType, analysisData, inputData); await localApi.analysis.saveHistory(analysisType, analysisData, inputData);
console.log('历史记录保存成功'); // 历史记录保存成功
} catch (historyError: any) { } catch (historyError: any) {
console.error('保存历史记录失败:', historyError); // 静默处理历史记录保存错误
// 历史记录保存失败不影响分析结果显示
} }
toast.success('分析完成!'); toast.success('分析完成!');
} catch (error: any) { } catch (error: any) {
console.error('分析失败:', error);
toast.error('分析失败:' + (error.message || '未知错误')); toast.error('分析失败:' + (error.message || '未知错误'));
} finally { } finally {
setLoading(false); setLoading(false);

View File

@@ -8,7 +8,7 @@ import { ChineseLoading } from '../components/ui/ChineseLoading';
import AnalysisResultDisplay from '../components/AnalysisResultDisplay'; import AnalysisResultDisplay from '../components/AnalysisResultDisplay';
import DownloadButton from '../components/ui/DownloadButton'; import DownloadButton from '../components/ui/DownloadButton';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { History, Calendar, User, Sparkles, Star, Compass, Eye, Trash2, Download } from 'lucide-react'; import { History, Calendar, User, Sparkles, Star, Compass, Eye, Trash2, Download, ChevronLeft, ChevronRight } from 'lucide-react';
import { NumerologyReading } from '../types'; import { NumerologyReading } from '../types';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
@@ -18,6 +18,10 @@ const HistoryPage: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedReading, setSelectedReading] = useState<NumerologyReading | null>(null); const [selectedReading, setSelectedReading] = useState<NumerologyReading | null>(null);
const [viewingResult, setViewingResult] = useState(false); const [viewingResult, setViewingResult] = useState(false);
// 分页相关状态
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
// 安全地从input_data中获取值的辅助函数 // 安全地从input_data中获取值的辅助函数
const getInputDataValue = (inputData: string | any, key: string, defaultValue: any = null) => { const getInputDataValue = (inputData: string | any, key: string, defaultValue: any = null) => {
@@ -37,7 +41,7 @@ const HistoryPage: React.FC = () => {
return defaultValue; return defaultValue;
} catch (error) { } catch (error) {
console.warn('解析input_data失败:', error); // 解析input_data失败
return defaultValue; return defaultValue;
} }
}; };
@@ -47,7 +51,7 @@ const HistoryPage: React.FC = () => {
try { try {
setLoading(true); setLoading(true);
const response = await localApi.history.getAll(); const response = await localApi.history.getAll({ limit: 1000 });
if (response.error) { if (response.error) {
throw new Error(response.error.message); throw new Error(response.error.message);
@@ -85,7 +89,6 @@ const HistoryPage: React.FC = () => {
setReadings(processedData); setReadings(processedData);
} catch (error: any) { } catch (error: any) {
console.error('加载历史记录失败:', error);
toast.error('加载历史记录失败:' + (error.message || '未知错误')); toast.error('加载历史记录失败:' + (error.message || '未知错误'));
} finally { } finally {
setLoading(false); setLoading(false);
@@ -115,7 +118,6 @@ const HistoryPage: React.FC = () => {
} }
toast.success('删除成功'); toast.success('删除成功');
} catch (error: any) { } catch (error: any) {
console.error('删除失败:', error);
toast.error('删除失败:' + (error.message || '未知错误')); toast.error('删除失败:' + (error.message || '未知错误'));
} }
}; };
@@ -127,6 +129,8 @@ const HistoryPage: React.FC = () => {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}; };
const getAnalysisTypeIcon = (type: string) => { const getAnalysisTypeIcon = (type: string) => {
switch (type) { switch (type) {
case 'bazi': return Sparkles; case 'bazi': return Sparkles;
@@ -154,6 +158,30 @@ const HistoryPage: React.FC = () => {
} }
}; };
// 分页相关计算
const totalPages = Math.ceil(readings.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const currentReadings = readings.slice(startIndex, endIndex);
// 分页处理函数
const handlePageChange = (page: number) => {
setCurrentPage(page);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handlePrevPage = () => {
if (currentPage > 1) {
handlePageChange(currentPage - 1);
}
};
const handleNextPage = () => {
if (currentPage < totalPages) {
handlePageChange(currentPage + 1);
}
};
if (viewingResult && selectedReading) { if (viewingResult && selectedReading) {
return ( return (
<div className="space-y-6" id="history-analysis-content" data-export-content> <div className="space-y-6" id="history-analysis-content" data-export-content>
@@ -196,7 +224,14 @@ const HistoryPage: React.FC = () => {
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6"> <div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
<div className="text-center"> <div className="text-center">
<h1 className="text-2xl md:text-3xl font-bold text-red-600 font-chinese mb-2"></h1> <h1 className="text-2xl md:text-3xl font-bold text-red-600 font-chinese mb-2"></h1>
<p className="text-gray-600 font-chinese"></p> <p className="text-gray-600 font-chinese">
{readings.length > 0 && (
<span className="ml-2 text-sm">
{readings.length} {totalPages > 1 && `,第 ${currentPage}/${totalPages}`}
</span>
)}
</p>
</div> </div>
<ChineseCard variant="elevated"> <ChineseCard variant="elevated">
@@ -231,7 +266,7 @@ const HistoryPage: React.FC = () => {
/> />
) : ( ) : (
<div className="grid gap-4"> <div className="grid gap-4">
{readings.map((reading) => { {currentReadings.map((reading) => {
const Icon = getAnalysisTypeIcon(reading.reading_type); const Icon = getAnalysisTypeIcon(reading.reading_type);
const colorClass = getAnalysisTypeColor(reading.reading_type); const colorClass = getAnalysisTypeColor(reading.reading_type);
@@ -264,15 +299,15 @@ const HistoryPage: React.FC = () => {
</div> </div>
</div> </div>
<div className="flex items-center space-x-1 sm:space-x-2 self-end sm:self-center"> <div className="flex items-center space-x-1 sm:space-x-2 self-end sm:self-center flex-wrap gap-2">
<ChineseButton <ChineseButton
variant="outline" variant="outline"
size="md" size="md"
onClick={() => handleViewReading(reading)} onClick={() => handleViewReading(reading)}
className="px-3 sm:px-6 text-xs sm:text-sm" className="min-h-[40px] px-2 sm:px-6 text-xs sm:text-sm flex-shrink-0"
> >
<Eye className="mr-1 h-3 w-3 sm:h-4 sm:w-4" /> <Eye className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
<span className="hidden sm:inline"></span> <span className="text-xs sm:text-sm"></span>
</ChineseButton> </ChineseButton>
<DownloadButton <DownloadButton
@@ -282,15 +317,17 @@ const HistoryPage: React.FC = () => {
}} }}
analysisType={reading.reading_type as 'bazi' | 'ziwei' | 'yijing'} analysisType={reading.reading_type as 'bazi' | 'ziwei' | 'yijing'}
userName={reading.name} userName={reading.name}
className="min-h-[44px] px-3 sm:px-6 py-2.5 text-xs sm:text-sm" className="min-h-[40px] px-2 sm:px-6 py-2.5 text-xs sm:text-sm flex-shrink-0"
/> />
<ChineseButton <ChineseButton
variant="ghost" variant="ghost"
size="md" size="md"
onClick={() => handleDeleteReading(reading.id)} onClick={() => handleDeleteReading(reading.id)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 px-2 sm:px-3" className="min-h-[40px] text-red-600 hover:text-red-700 hover:bg-red-50 px-2 sm:px-3 flex-shrink-0"
> >
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" /> <Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
<span className="text-xs sm:text-sm ml-1"></span>
</ChineseButton> </ChineseButton>
</div> </div>
</div> </div>
@@ -300,6 +337,69 @@ const HistoryPage: React.FC = () => {
})} })}
</div> </div>
)} )}
{/* 分页组件 */}
{readings.length > 0 && totalPages > 1 && (
<div className="flex items-center justify-center space-x-2 mt-6 pt-6 border-t border-gray-200">
<ChineseButton
variant="outline"
size="sm"
onClick={handlePrevPage}
disabled={currentPage === 1}
className="flex items-center space-x-1"
>
<ChevronLeft className="h-4 w-4" />
<span></span>
</ChineseButton>
<div className="flex items-center space-x-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => {
// 显示逻辑始终显示第1页、最后一页、当前页及其前后各1页
const showPage =
page === 1 ||
page === totalPages ||
Math.abs(page - currentPage) <= 1;
if (!showPage) {
// 显示省略号
if (page === 2 && currentPage > 4) {
return <span key={page} className="px-2 text-gray-400">...</span>;
}
if (page === totalPages - 1 && currentPage < totalPages - 3) {
return <span key={page} className="px-2 text-gray-400">...</span>;
}
return null;
}
return (
<ChineseButton
key={page}
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(page)}
className={cn(
"min-w-[40px] h-10",
currentPage === page && "bg-red-600 text-white hover:bg-red-700"
)}
>
{page}
</ChineseButton>
);
})}
</div>
<ChineseButton
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={currentPage === totalPages}
className="flex items-center space-x-1"
>
<span></span>
<ChevronRight className="h-4 w-4" />
</ChineseButton>
</div>
)}
</ChineseCardContent> </ChineseCardContent>
</ChineseCard> </ChineseCard>

View File

@@ -47,8 +47,7 @@ const ProfilePage: React.FC = () => {
}); });
} }
} catch (error: any) { } catch (error: any) {
console.error('加载档案失败:', error); // 静默处理加载错误
toast.error('加载档案失败');
} }
}, [user]); }, [user]);
@@ -83,7 +82,6 @@ const ProfilePage: React.FC = () => {
navigate('/analysis'); navigate('/analysis');
}, 1500); }, 1500);
} catch (error: any) { } catch (error: any) {
console.error('保存档案失败:', error);
toast.error('保存档案失败:' + error.message); toast.error('保存档案失败:' + error.message);
} finally { } finally {
setLoading(false); setLoading(false);

View File

@@ -89,7 +89,6 @@ const WuxingAnalysisPage: React.FC = () => {
throw new Error('分析结果为空'); throw new Error('分析结果为空');
} }
} catch (err: any) { } catch (err: any) {
console.error('五行分析错误:', err);
setError(err.message || '分析失败,请稍后重试'); setError(err.message || '分析失败,请稍后重试');
toast.error('分析失败,请稍后重试'); toast.error('分析失败,请稍后重试');
} finally { } finally {

File diff suppressed because it is too large Load Diff