mirror of
https://github.com/patdelphi/suanming.git
synced 2026-02-27 21:23:12 +08:00
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:
@@ -6,8 +6,8 @@ services:
|
||||
type: web
|
||||
git:
|
||||
branch: master
|
||||
build_command: npm install -g pnpm@9.0.0 && pnpm install --frozen-lockfile && pnpm run build
|
||||
run_command: pnpm start
|
||||
build_command: npm ci && npm run build
|
||||
run_command: npm start
|
||||
instance_type: nano
|
||||
ports:
|
||||
- port: 8000
|
||||
|
||||
1
dist/assets/index-6fDJrSnT.css
vendored
Normal file
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
718
dist/assets/index-B6tpII-u.js
vendored
Normal file
File diff suppressed because one or more lines are too long
622
dist/assets/index-CGu5zB0q.js
vendored
622
dist/assets/index-CGu5zB0q.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-CW1NqJRG.css
vendored
1
dist/assets/index-CW1NqJRG.css
vendored
File diff suppressed because one or more lines are too long
18
dist/assets/index.es-DFUQDuH3.js
vendored
18
dist/assets/index.es-DFUQDuH3.js
vendored
File diff suppressed because one or more lines are too long
18
dist/assets/index.es-DlHrZ7Is.js
vendored
Normal file
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
4
dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script type="module" crossorigin src="/assets/index-CGu5zB0q.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CW1NqJRG.css">
|
||||
<script type="module" crossorigin src="/assets/index-B6tpII-u.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-6fDJrSnT.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
1869
package-lock.json
generated
1869
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
"version": "3.0.0",
|
||||
"description": "神机阁 - AI驱动的中华传统命理分析平台,提供八字、紫微斗数、易经占卜等专业分析服务",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"packageManager": "npm@10.0.0",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run server\" \"vite\"",
|
||||
"server": "nodemon server/index.cjs",
|
||||
@@ -69,9 +69,11 @@
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^6",
|
||||
"recharts": "^2.12.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^1.7.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
7105
pnpm-lock.yaml
generated
7105
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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_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字段
|
||||
CREATE TRIGGER IF NOT EXISTS update_users_timestamp
|
||||
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;
|
||||
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
|
||||
AFTER INSERT ON user_sessions
|
||||
|
||||
@@ -10,6 +10,7 @@ const analysisRoutes = require('./routes/analysis.cjs');
|
||||
const historyRoutes = require('./routes/history.cjs');
|
||||
const profileRoutes = require('./routes/profile.cjs');
|
||||
const downloadRoutes = require('./routes/download.cjs');
|
||||
const aiInterpretationRoutes = require('./routes/aiInterpretation.cjs');
|
||||
|
||||
// 导入中间件
|
||||
const { errorHandler } = require('./middleware/errorHandler.cjs');
|
||||
@@ -94,6 +95,7 @@ app.use('/api/analysis', analysisRoutes);
|
||||
app.use('/api/history', historyRoutes);
|
||||
app.use('/api/profile', profileRoutes);
|
||||
app.use('/api/download', downloadRoutes);
|
||||
app.use('/api/ai-interpretation', aiInterpretationRoutes);
|
||||
|
||||
// 静态文件服务 (用于生产环境)
|
||||
// 强制在 Koyeb 部署时启用静态文件服务
|
||||
|
||||
219
server/routes/aiInterpretation.cjs
Normal file
219
server/routes/aiInterpretation.cjs
Normal 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;
|
||||
@@ -110,7 +110,7 @@ router.post('/', authenticate, async (req, res) => {
|
||||
|
||||
// 记录下载历史(可选)
|
||||
try {
|
||||
const db = dbManager.getDb();
|
||||
const db = dbManager.getDatabase();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO download_history (user_id, analysis_type, format, filename, created_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
|
||||
@@ -234,7 +234,6 @@ const BaziAnalysisDisplay: React.FC<BaziAnalysisDisplayProps> = ({ birthDate })
|
||||
modern_applications: analysisResult.modern_applications || {}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('获取分析数据出错:', err);
|
||||
setError(err instanceof Error ? err.message : '分析数据获取失败,请稍后重试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -90,7 +90,6 @@ const CompleteBaziAnalysis: React.FC<CompleteBaziAnalysisProps> = ({ birthDate,
|
||||
|
||||
setAnalysisData(analysisResult);
|
||||
} catch (err) {
|
||||
console.error('获取分析数据出错:', err);
|
||||
setError(err instanceof Error ? err.message : '分析数据获取失败,请稍后重试');
|
||||
} finally {
|
||||
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="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-1">
|
||||
<AIInterpretationButton
|
||||
analysisData={analysisData}
|
||||
analysisType="bazi"
|
||||
analysisId={`bazi-${birthDate.date}-${birthDate.time}`}
|
||||
onConfigClick={() => setShowAIConfig(true)}
|
||||
className="sticky top-4 z-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<DownloadButton
|
||||
analysisData={analysisData}
|
||||
analysisType="bazi"
|
||||
userName={birthDate.name}
|
||||
targetElementId="bazi-analysis-content"
|
||||
className="sticky top-4 z-10"
|
||||
/>
|
||||
</div>
|
||||
{/* 下载按钮 */}
|
||||
<div className="flex justify-end no-export" data-no-export>
|
||||
<DownloadButton
|
||||
analysisData={analysisData}
|
||||
analysisType="bazi"
|
||||
userName={birthDate.name}
|
||||
targetElementId="bazi-analysis-content"
|
||||
className="sticky top-4 z-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI解读按钮 - 独立占用全宽 */}
|
||||
<div className="w-full no-export" data-no-export>
|
||||
<AIInterpretationButton
|
||||
analysisData={analysisData}
|
||||
analysisType="bazi"
|
||||
onConfigClick={() => setShowAIConfig(true)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标题和基本信息 */}
|
||||
|
||||
@@ -87,7 +87,6 @@ const CompleteYijingAnalysis: React.FC<CompleteYijingAnalysisProps> = ({
|
||||
|
||||
setAnalysisData(analysisResult);
|
||||
} catch (err) {
|
||||
console.error('获取分析数据出错:', err);
|
||||
setError(err instanceof Error ? err.message : '分析数据获取失败,请稍后重试');
|
||||
} finally {
|
||||
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="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-1">
|
||||
<AIInterpretationButton
|
||||
analysisData={analysisData}
|
||||
analysisType="yijing"
|
||||
analysisId={`yijing-${question || 'general'}-${Date.now()}`}
|
||||
onConfigClick={() => setShowAIConfig(true)}
|
||||
className="sticky top-4 z-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<DownloadButton
|
||||
analysisData={analysisData}
|
||||
analysisType="yijing"
|
||||
userName={question ? `占卜_${question.substring(0, 10)}` : 'user'}
|
||||
targetElementId="yijing-analysis-content"
|
||||
className="sticky top-4 z-10"
|
||||
/>
|
||||
</div>
|
||||
{/* 下载按钮 */}
|
||||
<div className="flex justify-end no-export" data-no-export>
|
||||
<DownloadButton
|
||||
analysisData={analysisData}
|
||||
analysisType="yijing"
|
||||
userName={question ? `占卜_${question.substring(0, 10)}` : 'user'}
|
||||
targetElementId="yijing-analysis-content"
|
||||
className="sticky top-4 z-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI解读按钮 - 独立占用全宽 */}
|
||||
<div className="w-full no-export" data-no-export>
|
||||
<AIInterpretationButton
|
||||
analysisData={analysisData}
|
||||
analysisType="yijing"
|
||||
onConfigClick={() => setShowAIConfig(true)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标题和基本信息 */}
|
||||
|
||||
@@ -292,7 +292,6 @@ const CompleteZiweiAnalysis: React.FC<CompleteZiweiAnalysisProps> = ({ birthDate
|
||||
|
||||
setAnalysisData(analysisResult);
|
||||
} catch (err) {
|
||||
console.error('获取分析数据出错:', err);
|
||||
setError(err instanceof Error ? err.message : '分析数据获取失败,请稍后重试');
|
||||
} finally {
|
||||
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="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-1">
|
||||
<AIInterpretationButton
|
||||
analysisData={analysisData}
|
||||
analysisType="ziwei"
|
||||
analysisId={`ziwei-${birthDate.date}-${birthDate.time}`}
|
||||
onConfigClick={() => setShowAIConfig(true)}
|
||||
className="sticky top-4 z-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<DownloadButton
|
||||
analysisData={analysisData}
|
||||
analysisType="ziwei"
|
||||
userName={birthDate.name}
|
||||
targetElementId="ziwei-analysis-content"
|
||||
className="sticky top-4 z-10"
|
||||
/>
|
||||
</div>
|
||||
{/* 下载按钮 */}
|
||||
<div className="flex justify-end no-export" data-no-export>
|
||||
<DownloadButton
|
||||
analysisData={analysisData}
|
||||
analysisType="ziwei"
|
||||
userName={birthDate.name}
|
||||
targetElementId="ziwei-analysis-content"
|
||||
className="sticky top-4 z-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI解读按钮 - 独立占用全宽 */}
|
||||
<div className="w-full no-export" data-no-export>
|
||||
<AIInterpretationButton
|
||||
analysisData={analysisData}
|
||||
analysisType="ziwei"
|
||||
onConfigClick={() => setShowAIConfig(true)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标题和基本信息 */}
|
||||
|
||||
@@ -33,7 +33,6 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
];
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
console.log('Toggle mobile menu:', !isMobileMenuOpen);
|
||||
setIsMobileMenuOpen(!isMobileMenuOpen);
|
||||
};
|
||||
|
||||
|
||||
@@ -21,9 +21,10 @@ const AIConfigModal: React.FC<AIConfigModalProps> = ({
|
||||
apiKey: '',
|
||||
apiUrl: '',
|
||||
modelName: '',
|
||||
maxTokens: 2000,
|
||||
maxTokens: 4000,
|
||||
temperature: 0.7,
|
||||
timeout: 30000
|
||||
timeout: 30000,
|
||||
stream: true
|
||||
});
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -40,7 +41,7 @@ const AIConfigModal: React.FC<AIConfigModalProps> = ({
|
||||
}, [isOpen]);
|
||||
|
||||
// 处理输入变化
|
||||
const handleInputChange = (field: keyof AIConfig, value: string | number) => {
|
||||
const handleInputChange = (field: keyof AIConfig, value: string | number | boolean) => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState, useEffect } from '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 { ChineseCard, ChineseCardContent, ChineseCardHeader, ChineseCardTitle } from './ChineseCard';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -8,13 +10,15 @@ import {
|
||||
saveAIInterpretation,
|
||||
getAIInterpretation,
|
||||
AIInterpretationResult,
|
||||
AIInterpretationRequest
|
||||
AIInterpretationRequest,
|
||||
convertAnalysisToMarkdown
|
||||
} from '../../services/aiInterpretationService';
|
||||
import { getAIConfig, validateAIConfig, getPromptTemplate } from '../../config/aiConfig';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface AIInterpretationButtonProps {
|
||||
analysisData: any;
|
||||
analysisData?: any; // 分析数据对象(可选)
|
||||
analysisMarkdown?: string; // 直接传递的MD内容(可选)
|
||||
analysisType: 'bazi' | 'ziwei' | 'yijing';
|
||||
analysisId?: string; // 用于缓存解读结果
|
||||
className?: string;
|
||||
@@ -22,23 +26,26 @@ interface AIInterpretationButtonProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showConfigButton?: boolean; // 是否显示配置按钮
|
||||
onConfigClick?: () => void; // 配置按钮点击回调
|
||||
onAIInterpretationClick?: () => void; // AI解读按钮点击回调(可选,用于自定义行为)
|
||||
}
|
||||
|
||||
const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
|
||||
analysisData,
|
||||
analysisMarkdown,
|
||||
analysisType,
|
||||
analysisId,
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
showConfigButton = true,
|
||||
onConfigClick
|
||||
onConfigClick,
|
||||
onAIInterpretationClick
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [interpretation, setInterpretation] = useState<AIInterpretationResult | null>(null);
|
||||
const [showResult, setShowResult] = useState(false);
|
||||
const [isConfigValid, setIsConfigValid] = useState(false);
|
||||
const [debugInfo, setDebugInfo] = useState<any>(null);
|
||||
|
||||
const [requestStartTime, setRequestStartTime] = useState<number | null>(null);
|
||||
const [streamingContent, setStreamingContent] = useState<string>(''); // 流式内容
|
||||
|
||||
@@ -48,15 +55,64 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
|
||||
setIsConfigValid(validateAIConfig(config));
|
||||
}, []);
|
||||
|
||||
// 加载已保存的解读结果
|
||||
useEffect(() => {
|
||||
// 生成唯一的分析ID,包含分析数据的时间戳
|
||||
const generateAnalysisId = () => {
|
||||
if (analysisId) {
|
||||
const savedInterpretation = getAIInterpretation(analysisId);
|
||||
if (savedInterpretation) {
|
||||
setInterpretation(savedInterpretation);
|
||||
return analysisId;
|
||||
}
|
||||
|
||||
// 尝试从分析数据中提取时间戳
|
||||
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解读请求
|
||||
const handleAIInterpretation = async () => {
|
||||
@@ -68,7 +124,7 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!analysisData) {
|
||||
if (!analysisData && !analysisMarkdown) {
|
||||
toast.error('没有可解读的分析数据');
|
||||
return;
|
||||
}
|
||||
@@ -79,147 +135,47 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
|
||||
// 获取用户配置的AI设置
|
||||
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 {
|
||||
const request: AIInterpretationRequest = {
|
||||
analysisType,
|
||||
analysisContent: analysisData,
|
||||
analysisContent: analysisMarkdown || analysisData, // 优先使用MD字符串
|
||||
onStreamUpdate: currentConfig.stream ? (content: string) => {
|
||||
setStreamingContent(content);
|
||||
setShowResult(true); // 开始流式输出时就显示结果区域
|
||||
} : 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 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) {
|
||||
console.log('AI解读成功,结果:', result);
|
||||
setInterpretation(result);
|
||||
setShowResult(true);
|
||||
setStreamingContent(''); // 清空流式内容,使用最终结果
|
||||
|
||||
// 保存解读结果
|
||||
if (analysisId) {
|
||||
saveAIInterpretation(analysisId, result);
|
||||
if (uniqueAnalysisId) {
|
||||
try {
|
||||
await saveAIInterpretation(uniqueAnalysisId, result, analysisType);
|
||||
} catch (saveError) {
|
||||
// 保存失败不影响用户体验,静默处理
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`AI解读完成,耗时${duration}ms`);
|
||||
toast.success('AI解读完成');
|
||||
} else {
|
||||
console.error('AI解读失败:', result.error);
|
||||
toast.error(`AI解读失败: ${result.error}`);
|
||||
setStreamingContent(''); // 清空流式内容
|
||||
}
|
||||
} 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 || '未知错误'}`);
|
||||
setStreamingContent(''); // 清空流式内容
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
// 不要立即清除requestStartTime,保留用于调试
|
||||
// setRequestStartTime(null);
|
||||
setRequestStartTime(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -241,16 +197,25 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<div className={cn('w-full space-y-4', className)}>
|
||||
{/* AI解读按钮区域 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 flex-wrap gap-2">
|
||||
<ChineseButton
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={interpretation ? () => setShowResult(!showResult) : handleAIInterpretation}
|
||||
onClick={() => {
|
||||
if (onAIInterpretationClick) {
|
||||
onAIInterpretationClick();
|
||||
}
|
||||
if (interpretation) {
|
||||
setShowResult(!showResult);
|
||||
} else if (!onAIInterpretationClick) {
|
||||
handleAIInterpretation();
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || (!isConfigValid && !interpretation)}
|
||||
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'
|
||||
)}
|
||||
>
|
||||
@@ -259,7 +224,7 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
|
||||
) : (
|
||||
<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
|
||||
? 'AI解读中...'
|
||||
: interpretation
|
||||
@@ -273,13 +238,13 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
|
||||
{interpretation && (
|
||||
<ChineseButton
|
||||
variant="outline"
|
||||
size={size}
|
||||
size="md"
|
||||
onClick={handleReinterpret}
|
||||
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')} />
|
||||
<span className="text-xs">重新解读</span>
|
||||
<RefreshCw className={cn('h-3 w-3 sm:h-4 sm:w-4', isLoading && 'animate-spin')} />
|
||||
<span className="text-xs sm:text-sm">重新解读</span>
|
||||
</ChineseButton>
|
||||
)}
|
||||
|
||||
@@ -287,12 +252,12 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
|
||||
{showConfigButton && onConfigClick && (
|
||||
<ChineseButton
|
||||
variant="ghost"
|
||||
size={size}
|
||||
size="md"
|
||||
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" />
|
||||
<span className="text-xs">配置</span>
|
||||
<Settings className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<span className="text-xs sm:text-sm">配置</span>
|
||||
</ChineseButton>
|
||||
)}
|
||||
</div>
|
||||
@@ -308,123 +273,11 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
|
||||
</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解读结果显示 */}
|
||||
{(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>
|
||||
<ChineseCardTitle className="flex items-center space-x-2 text-purple-800">
|
||||
{isLoading ? (
|
||||
@@ -455,13 +308,70 @@ const AIInterpretationButton: React.FC<AIInterpretationButtonProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<div className="whitespace-pre-wrap text-gray-800 leading-relaxed">
|
||||
{streamingContent || interpretation?.content}
|
||||
{isLoading && streamingContent && (
|
||||
<span className="inline-block w-2 h-5 bg-purple-600 animate-pulse ml-1"></span>
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// 自定义表格样式
|
||||
table: ({node, ...props}) => (
|
||||
<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>
|
||||
)}
|
||||
</ChineseCardContent>
|
||||
|
||||
@@ -173,6 +173,7 @@ const ChineseCardContent = React.forwardRef<HTMLDivElement, ChineseCardContentPr
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full',
|
||||
'text-ink-900',
|
||||
'leading-relaxed',
|
||||
className
|
||||
|
||||
@@ -82,12 +82,7 @@ const DownloadButton: React.FC<DownloadButtonProps> = ({
|
||||
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') => {
|
||||
if (disabled || isDownloading) return;
|
||||
@@ -107,7 +102,6 @@ const DownloadButton: React.FC<DownloadButtonProps> = ({
|
||||
await defaultDownload(format);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error);
|
||||
// 显示错误提示
|
||||
if (typeof window !== 'undefined' && (window as any).toast) {
|
||||
(window as any).toast.error(`下载失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
@@ -120,30 +114,18 @@ const DownloadButton: React.FC<DownloadButtonProps> = ({
|
||||
|
||||
// 前端导出功能
|
||||
const frontendExport = async (format: DownloadFormat) => {
|
||||
console.log('开始前端导出,格式:', format, '目标元素ID:', targetElementId);
|
||||
|
||||
if (!targetElementId) {
|
||||
const error = '未指定导出目标元素ID,无法使用前端导出功能';
|
||||
console.error(error);
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
const element = document.getElementById(targetElementId);
|
||||
console.log('查找目标元素:', targetElementId, '找到元素:', element);
|
||||
|
||||
if (!element) {
|
||||
const error = `未找到ID为"${targetElementId}"的元素,请确认页面已完全加载`;
|
||||
console.error(error);
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
console.log('目标元素尺寸:', {
|
||||
width: element.offsetWidth,
|
||||
height: element.offsetHeight,
|
||||
scrollWidth: element.scrollWidth,
|
||||
scrollHeight: element.scrollHeight
|
||||
});
|
||||
|
||||
if (format === 'png') {
|
||||
await exportToPNG(element);
|
||||
} else if (format === 'pdf') {
|
||||
@@ -372,8 +354,6 @@ const DownloadButton: React.FC<DownloadButtonProps> = ({
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error);
|
||||
|
||||
// 显示错误提示
|
||||
if (typeof window !== 'undefined' && (window as any).toast) {
|
||||
(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" />
|
||||
)}
|
||||
<span className="font-medium hidden sm:inline">
|
||||
<span className="font-medium text-xs sm:text-sm">
|
||||
{isDownloading ? `正在生成${getFormatLabel(downloadingFormat!)}...` : '下载'}
|
||||
</span>
|
||||
<ChevronDown className={cn(
|
||||
|
||||
@@ -80,7 +80,7 @@ export const getAIConfig = (): AIConfig => {
|
||||
const parsedConfig = JSON.parse(savedConfig);
|
||||
return { ...defaultAIConfig, ...parsedConfig };
|
||||
} catch (error) {
|
||||
console.warn('解析AI配置失败,使用默认配置:', error);
|
||||
// 解析失败,使用默认配置
|
||||
}
|
||||
}
|
||||
return defaultAIConfig;
|
||||
@@ -93,7 +93,7 @@ export const saveAIConfig = (config: Partial<AIConfig>): void => {
|
||||
const newConfig = { ...currentConfig, ...config };
|
||||
localStorage.setItem('ai-config', JSON.stringify(newConfig));
|
||||
} catch (error) {
|
||||
console.error('保存AI配置失败:', error);
|
||||
// 静默处理保存错误
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
setUser(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载用户信息失败:', error);
|
||||
// 静默处理用户信息加载错误
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -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`));
|
||||
|
||||
// 调试信息
|
||||
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> {
|
||||
data?: T;
|
||||
@@ -114,7 +112,7 @@ class LocalApiClient {
|
||||
|
||||
return { data: data.data || data };
|
||||
} catch (error) {
|
||||
console.error('API请求错误:', error);
|
||||
// API请求失败
|
||||
return {
|
||||
error: {
|
||||
code: 'NETWORK_ERROR',
|
||||
|
||||
@@ -60,8 +60,8 @@ const AnalysisPage: React.FC = () => {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载档案失败:', error);
|
||||
}
|
||||
// 静默处理加载错误
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -167,15 +167,13 @@ const AnalysisPage: React.FC = () => {
|
||||
};
|
||||
|
||||
await localApi.analysis.saveHistory(analysisType, analysisData, inputData);
|
||||
console.log('历史记录保存成功');
|
||||
// 历史记录保存成功
|
||||
} catch (historyError: any) {
|
||||
console.error('保存历史记录失败:', historyError);
|
||||
// 历史记录保存失败不影响分析结果显示
|
||||
// 静默处理历史记录保存错误
|
||||
}
|
||||
|
||||
toast.success('分析完成!');
|
||||
} catch (error: any) {
|
||||
console.error('分析失败:', error);
|
||||
toast.error('分析失败:' + (error.message || '未知错误'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ChineseLoading } from '../components/ui/ChineseLoading';
|
||||
import AnalysisResultDisplay from '../components/AnalysisResultDisplay';
|
||||
import DownloadButton from '../components/ui/DownloadButton';
|
||||
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 { cn } from '../lib/utils';
|
||||
|
||||
@@ -18,6 +18,10 @@ const HistoryPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedReading, setSelectedReading] = useState<NumerologyReading | null>(null);
|
||||
const [viewingResult, setViewingResult] = useState(false);
|
||||
|
||||
// 分页相关状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
// 安全地从input_data中获取值的辅助函数
|
||||
const getInputDataValue = (inputData: string | any, key: string, defaultValue: any = null) => {
|
||||
@@ -37,7 +41,7 @@ const HistoryPage: React.FC = () => {
|
||||
|
||||
return defaultValue;
|
||||
} catch (error) {
|
||||
console.warn('解析input_data失败:', error);
|
||||
// 解析input_data失败
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
@@ -47,7 +51,7 @@ const HistoryPage: React.FC = () => {
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await localApi.history.getAll();
|
||||
const response = await localApi.history.getAll({ limit: 1000 });
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
@@ -85,7 +89,6 @@ const HistoryPage: React.FC = () => {
|
||||
|
||||
setReadings(processedData);
|
||||
} catch (error: any) {
|
||||
console.error('加载历史记录失败:', error);
|
||||
toast.error('加载历史记录失败:' + (error.message || '未知错误'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -115,7 +118,6 @@ const HistoryPage: React.FC = () => {
|
||||
}
|
||||
toast.success('删除成功');
|
||||
} catch (error: any) {
|
||||
console.error('删除失败:', error);
|
||||
toast.error('删除失败:' + (error.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
@@ -127,6 +129,8 @@ const HistoryPage: React.FC = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
|
||||
|
||||
const getAnalysisTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
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) {
|
||||
return (
|
||||
<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="text-center">
|
||||
<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>
|
||||
|
||||
<ChineseCard variant="elevated">
|
||||
@@ -231,7 +266,7 @@ const HistoryPage: React.FC = () => {
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{readings.map((reading) => {
|
||||
{currentReadings.map((reading) => {
|
||||
const Icon = getAnalysisTypeIcon(reading.reading_type);
|
||||
const colorClass = getAnalysisTypeColor(reading.reading_type);
|
||||
|
||||
@@ -264,15 +299,15 @@ const HistoryPage: React.FC = () => {
|
||||
</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
|
||||
variant="outline"
|
||||
size="md"
|
||||
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" />
|
||||
<span className="hidden sm:inline">查看</span>
|
||||
<span className="text-xs sm:text-sm">查看</span>
|
||||
</ChineseButton>
|
||||
|
||||
<DownloadButton
|
||||
@@ -282,15 +317,17 @@ const HistoryPage: React.FC = () => {
|
||||
}}
|
||||
analysisType={reading.reading_type as 'bazi' | 'ziwei' | 'yijing'}
|
||||
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
|
||||
variant="ghost"
|
||||
size="md"
|
||||
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" />
|
||||
<span className="text-xs sm:text-sm ml-1">删除</span>
|
||||
</ChineseButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,6 +337,69 @@ const HistoryPage: React.FC = () => {
|
||||
})}
|
||||
</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>
|
||||
</ChineseCard>
|
||||
|
||||
|
||||
@@ -47,8 +47,7 @@ const ProfilePage: React.FC = () => {
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载档案失败:', error);
|
||||
toast.error('加载档案失败');
|
||||
// 静默处理加载错误
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
@@ -83,7 +82,6 @@ const ProfilePage: React.FC = () => {
|
||||
navigate('/analysis');
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
console.error('保存档案失败:', error);
|
||||
toast.error('保存档案失败:' + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -89,7 +89,6 @@ const WuxingAnalysisPage: React.FC = () => {
|
||||
throw new Error('分析结果为空');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('五行分析错误:', err);
|
||||
setError(err.message || '分析失败,请稍后重试');
|
||||
toast.error('分析失败,请稍后重试');
|
||||
} finally {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user