feat: enhance reflection frontend with manual pairing, AI versions, and experience pagination

- Added manual pairing UI in unpaired trades section with checkbox selection
- Added validation for direction/symbol matching before calling POST /trade-pairs
- Added AI analysis version dropdown in AI result modal
- Fetched analysis history from GET /reanalyze/{id}/history and switchable versions
- Updated save-experience to use currently selected analysis_id
- Added server-side pagination controls to experience library
- Added experience detail modal with title/type/content/tags/source/date
- Verified JS syntax with node --check and tests pass
refactor3.0
Lxy 2 weeks ago
parent 69f2c004ba
commit f6cde33909

@ -495,6 +495,7 @@
.tr-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
.tr-toolbar-left, .tr-toolbar-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.tr-btn { padding: 10px 20px; border-radius: 12px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; display: flex; align-items: center; gap: 8px; transition: all 0.2s; font-family: inherit; }
.tr-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.tr-btn-primary { background: var(--color-brand); color: #fff; }
.tr-btn-primary:hover { background: #0066d6; box-shadow: 0 4px 10px rgba(0,122,255,0.3); }
.tr-btn-secondary { background: #fff; color: var(--text-secondary); box-shadow: var(--shadow-sm); border: 1px solid rgba(0,0,0,0.05); }
@ -647,9 +648,12 @@
/* 未配对交易 */
.tr-refl-unpaired { background: var(--bg-card); border-radius: var(--radius-card); padding: 20px; box-shadow: var(--shadow-sm); }
.tr-refl-unpaired-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.tr-refl-unpaired-header .tr-refl-section-title { margin-bottom: 0; }
.tr-refl-unpaired-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.tr-refl-unpaired-table th { background: #F5F5F7; padding: 10px 8px; text-align: left; font-weight: 600; color: var(--text-tertiary); font-size: 12px; }
.tr-refl-unpaired-table td { padding: 10px 8px; border-bottom: 1px solid rgba(0,0,0,0.04); color: var(--text-primary); }
.tr-refl-unpaired-table input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--color-brand); cursor: pointer; }
/* 经验库 */
.tr-exp-toolbar { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
@ -667,6 +671,13 @@
.tr-exp-card-meta { font-size: 12px; color: var(--text-tertiary); margin-bottom: 12px; }
.tr-exp-card-content { font-size: 13px; color: var(--text-secondary); line-height: 1.7; margin-bottom: 14px; }
.tr-exp-card-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.tr-exp-pagination { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 24px; }
.tr-exp-pageinfo { font-size: 13px; color: var(--text-secondary); font-weight: 500; }
.tr-exp-detail-meta { font-size: 12px; color: var(--text-tertiary); margin-bottom: 16px; }
.tr-exp-detail-content { font-size: 14px; color: var(--text-primary); line-height: 1.8; background: #F5F5F7; border-radius: 12px; padding: 16px; margin-bottom: 16px; white-space: pre-wrap; }
.tr-exp-detail-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
.tr-exp-detail-source { font-size: 13px; color: var(--text-secondary); }
.tr-exp-detail-source .link { cursor: pointer; }
/* Modal 表单 */
.tr-form-group { margin-bottom: 18px; }
@ -691,6 +702,9 @@
.tr-tag-custom .tr-form-input { flex: 1; }
/* AI 分析结果 */
.tr-ai-version-row { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; padding: 12px 16px; background: #F5F5F7; border-radius: 12px; }
.tr-ai-version-label { font-size: 13px; font-weight: 600; color: var(--text-tertiary); }
.tr-ai-version-row .tr-form-select { flex: 1; min-width: 200px; margin: 0; }
.tr-ai-result-section { margin-bottom: 18px; }
.tr-ai-result-title { font-size: 14px; font-weight: 700; color: var(--color-brand); margin-bottom: 8px; }
.tr-ai-result-content { font-size: 14px; color: var(--text-primary); line-height: 1.8; background: #F5F5F7; border-radius: 12px; padding: 14px; }
@ -1244,10 +1258,15 @@
<div id="tr-refl-pairs" class="tr-refl-pairs"></div>
<div class="tr-refl-unpaired">
<div class="tr-refl-unpaired-header">
<div class="tr-refl-section-title">未配对交易</div>
<button class="tr-btn tr-btn-primary" id="tr-refl-manual-pair-btn">
<span>🔗</span><span>手动配对</span>
</button>
</div>
<table class="tr-refl-unpaired-table">
<thead>
<tr><th>时间</th><th>品种</th><th>方向</th><th>价格</th><th>手数</th><th>盈亏</th></tr>
<tr><th><input type="checkbox" id="tr-refl-unpaired-select-all" title="全选"></th><th>时间</th><th>品种</th><th>开平</th><th>方向</th><th>价格</th><th>手数</th><th>盈亏</th></tr>
</thead>
<tbody id="tr-refl-unpaired-body"></tbody>
</table>
@ -1282,6 +1301,11 @@
<div class="tr-empty-icon">📚</div>
<div class="tr-empty-text">暂无经验记录</div>
</div>
<div class="tr-exp-pagination" id="tr-exp-pagination" style="display:none;">
<button class="tr-btn tr-btn-secondary" id="tr-exp-prev">上一页</button>
<span class="tr-exp-pageinfo" id="tr-exp-pageinfo"></span>
<button class="tr-btn tr-btn-secondary" id="tr-exp-next">下一页</button>
</div>
</div>
<!-- 反思编辑 Modal -->
@ -1455,6 +1479,25 @@
</div>
</div>
<!-- 经验详情 Modal -->
<div id="tr-exp-detail-modal" class="tr-modal-overlay" onclick="if(event.target===this)trExpCloseDetailModal()">
<div class="tr-modal" style="max-width:600px;">
<div class="tr-modal-header">
<span class="tr-modal-title" id="tr-exp-detail-title">经验详情</span>
<button class="tr-modal-close" onclick="trExpCloseDetailModal()"></button>
</div>
<div class="tr-modal-body">
<div class="tr-exp-detail-meta" id="tr-exp-detail-meta"></div>
<div class="tr-exp-detail-content" id="tr-exp-detail-content"></div>
<div class="tr-exp-detail-tags" id="tr-exp-detail-tags"></div>
<div class="tr-exp-detail-source" id="tr-exp-detail-source"></div>
<div class="tr-form-actions">
<button type="button" class="tr-btn tr-btn-secondary" onclick="trExpCloseDetailModal()">关闭</button>
</div>
</div>
</div>
</div>
<!-- AI 分析结果 Modal -->
<div id="tr-refl-ai-modal" class="tr-modal-overlay" onclick="if(event.target===this)trReflCloseAIModal()">
<div class="tr-modal" style="max-width:680px;">
@ -1464,6 +1507,10 @@
</div>
<div class="tr-modal-body">
<input type="hidden" id="tr-refl-ai-pair-id">
<div class="tr-ai-version-row">
<label class="tr-ai-version-label" for="tr-refl-ai-version">版本</label>
<select class="tr-form-select" id="tr-refl-ai-version"></select>
</div>
<div class="tr-ai-result-section">
<div class="tr-ai-result-title">综合评价</div>
<div class="tr-ai-result-content" id="tr-refl-ai-overall">-</div>

@ -634,9 +634,14 @@ let trReflData = null;
let trReflTags = [];
let trReflExperiences = [];
let trReflTagState = new Set();
let trExpPage = 1;
let trExpPageSize = 12;
let trExpTotal = 0;
let trReflCurrentPairId = null;
let trReflCurrentReflectionId = null;
let trReflCurrentAIResult = null;
let trReflAIHistory = [];
let trReflCurrentAnalysis = null;
function trSwitchSubtab(tab) {
document.querySelectorAll('#tr-subtabs .tr-subtab').forEach(btn => {
@ -682,6 +687,16 @@ function trReflInit() {
});
}
const manualPairBtn = document.getElementById('tr-refl-manual-pair-btn');
if (manualPairBtn) manualPairBtn.addEventListener('click', trReflManualPair);
const selectAll = document.getElementById('tr-refl-unpaired-select-all');
if (selectAll) {
selectAll.addEventListener('change', function() {
document.querySelectorAll('.tr-refl-unpaired-check').forEach(cb => cb.checked = this.checked);
});
}
document.querySelectorAll('#tr-refl-discipline-score .tr-form-star, #tr-refl-daily-discipline-score .tr-form-star').forEach(star => {
star.addEventListener('click', () => {
const container = star.parentElement;
@ -691,9 +706,11 @@ function trReflInit() {
});
});
document.getElementById('tr-exp-search').addEventListener('input', trExpRender);
document.getElementById('tr-exp-tag-filter').addEventListener('change', trExpRender);
document.getElementById('tr-exp-type-filter').addEventListener('change', trExpRender);
document.getElementById('tr-exp-search').addEventListener('input', () => { trExpPage = 1; trExpLoad(); });
document.getElementById('tr-exp-tag-filter').addEventListener('change', () => { trExpPage = 1; trExpLoad(); });
document.getElementById('tr-exp-type-filter').addEventListener('change', () => { trExpPage = 1; trExpLoad(); });
document.getElementById('tr-exp-prev').addEventListener('click', () => { if (trExpPage > 1) { trExpPage--; trExpLoad(); } });
document.getElementById('tr-exp-next').addEventListener('click', () => { if (trExpPage * trExpPageSize < trExpTotal) { trExpPage++; trExpLoad(); } });
document.querySelectorAll('.tr-exp-viewtab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tr-exp-viewtab').forEach(b => b.classList.remove('active'));
@ -775,18 +792,22 @@ function trReflRender() {
const unpairedBody = document.getElementById('tr-refl-unpaired-body');
const unpairedEmpty = document.getElementById('tr-refl-unpaired-empty');
const selectAll = document.getElementById('tr-refl-unpaired-select-all');
const unpaired = data.unpaired || [];
if (!unpaired.length) {
unpairedBody.innerHTML = '';
unpairedEmpty.style.display = 'flex';
if (selectAll) selectAll.checked = false;
} else {
unpairedEmpty.style.display = 'none';
unpairedBody.innerHTML = unpaired.map(u => {
const pnl = u.net_pnl || u.close_pnl || 0;
const pnlCls = pnl > 0 ? 'tr-pnl-positive' : (pnl < 0 ? 'tr-pnl-negative' : '');
return `<tr>
<td><input type="checkbox" class="tr-refl-unpaired-check" data-id="${u.id}" data-offset="${u.offset}" data-direction="${u.direction}" data-symbol="${u.symbol || u.variety || ''}"></td>
<td>${u.trade_time || '-'}</td>
<td>${u.variety}${u.symbol_name ? '(' + u.symbol_name + ')' : ''}</td>
<td>${u.offset || '-'}</td>
<td>${u.direction || '-'}</td>
<td>${(u.price || 0).toFixed(2)}</td>
<td>${u.volume || 0}</td>
@ -796,6 +817,63 @@ function trReflRender() {
}
}
async function trReflManualPair() {
const checked = Array.from(document.querySelectorAll('.tr-refl-unpaired-check:checked'));
if (!checked.length) {
showToast('warning', '请选择记录', '请至少选择一条未配对交易记录');
return;
}
const openRecords = checked.filter(cb => cb.dataset.offset === '开');
const closeRecords = checked.filter(cb => cb.dataset.offset === '平');
if (!openRecords.length) {
showToast('warning', '缺少开仓记录', '手动配对需要至少一条开仓记录(开)');
return;
}
if (!closeRecords.length) {
showToast('warning', '缺少平仓记录', '手动配对需要至少一条平仓记录(平)');
return;
}
const symbols = new Set(checked.map(cb => cb.dataset.symbol));
if (symbols.size > 1) {
showToast('warning', '品种不一致', '请选择同一品种的开仓和平仓记录');
return;
}
const openDirection = openRecords[0].dataset.direction;
const closeDirection = closeRecords[0].dataset.direction;
// 多头:开=买,平=卖;空头:开=卖,平=买
const isLong = openDirection === '买' && closeDirection === '卖';
const isShort = openDirection === '卖' && closeDirection === '买';
if (!isLong && !isShort) {
showToast('warning', '方向不匹配', '多头配对:开仓为买、平仓为卖;空头配对:开仓为卖、平仓为买');
return;
}
const openRecordIds = openRecords.map(cb => parseInt(cb.dataset.id));
const closeRecordIds = closeRecords.map(cb => parseInt(cb.dataset.id));
try {
const res = await fetch(`${TR_API_BASE}/trade-pairs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ open_record_ids: openRecordIds, close_record_ids: closeRecordIds }),
});
const json = await res.json();
if (json.success) {
showToast('success', '配对成功', '交易配对已创建');
trReflLoadData();
} else {
showToast('error', '配对失败', json.message || '请重试');
}
} catch (e) {
showToast('error', '配对失败', e.message);
}
}
function trReflRenderPairCard(p) {
const pnl = p.net_pnl || 0;
const pnlCls = pnl >= 0 ? 'profit' : 'loss';
@ -1190,6 +1268,17 @@ async function trReflOpenAIModal(pairId, fetchLatest = true) {
const pair = data.pairs.find(p => p.id === pairId);
if (!pair) return;
trReflCurrentPairId = pairId;
trReflAIHistory = [];
trReflCurrentAnalysis = null;
// 加载历史版本列表
try {
const historyRes = await fetch(`${TR_API_BASE}/reanalyze/${pairId}/history`);
const historyJson = await historyRes.json();
if (historyJson.success && historyJson.data) {
trReflAIHistory = Array.isArray(historyJson.data) ? historyJson.data : (historyJson.data.items || []);
}
} catch (e) { console.error('获取AI分析历史失败', e); }
if (fetchLatest) {
trReflCurrentAIResult = null;
@ -1221,25 +1310,71 @@ async function trReflOpenAIModal(pairId, fetchLatest = true) {
}
} catch (e) { console.error('AI重新分析失败', e); }
}
// 如果历史列表为空但拿到最新结果,补充进历史列表
if (!trReflAIHistory.length && trReflCurrentAIResult) {
trReflAIHistory = [trReflCurrentAIResult];
}
}
// 优先从历史列表中选中与当前结果匹配的版本,否则默认第一个
let selected = trReflAIHistory[0] || trReflCurrentAIResult || null;
if (trReflCurrentAIResult && trReflCurrentAIResult.id && trReflAIHistory.length) {
const matched = trReflAIHistory.find(a => a.id === trReflCurrentAIResult.id);
if (matched) selected = matched;
}
trReflCurrentAnalysis = selected;
trReflRenderAIVersionSelect();
trReflDisplayAIAnalysis(trReflCurrentAnalysis);
const result = trReflCurrentAIResult || {};
document.getElementById('tr-refl-ai-pair-id').value = pairId;
document.getElementById('tr-refl-ai-modal').classList.add('show');
}
function trReflRenderAIVersionSelect() {
const select = document.getElementById('tr-refl-ai-version');
if (!select) return;
if (!trReflAIHistory.length) {
select.innerHTML = '<option value="">暂无版本</option>';
select.disabled = true;
return;
}
select.disabled = false;
select.innerHTML = trReflAIHistory.map((a, idx) => {
const version = a.version || (trReflAIHistory.length - idx);
const created = a.created_at ? a.created_at.slice(0, 19).replace('T', ' ') : '';
const selected = (trReflCurrentAnalysis && trReflCurrentAnalysis.id === a.id) ? 'selected' : '';
return `<option value="${a.id || idx}" ${selected}>版本 ${version}${created ? ' · ' + created : ''}</option>`;
}).join('');
select.onchange = trReflSwitchAIVersion;
}
function trReflSwitchAIVersion() {
const select = document.getElementById('tr-refl-ai-version');
if (!select) return;
const value = select.value;
const analysis = trReflAIHistory.find(a => String(a.id) === value);
if (!analysis) return;
trReflCurrentAnalysis = analysis;
trReflDisplayAIAnalysis(analysis);
}
function trReflDisplayAIAnalysis(analysis) {
const result = analysis || {};
document.getElementById('tr-refl-ai-overall').textContent = result.overall_evaluation || result.analysis || '暂无 AI 分析结果(请先保存反思)';
document.getElementById('tr-refl-ai-strengths').innerHTML = (result.strengths || []).map(s => `<li>${s}</li>`).join('') || '<li>-</li>';
document.getElementById('tr-refl-ai-weaknesses').innerHTML = (result.weaknesses || []).map(s => `<li>${s}</li>`).join('') || '<li>-</li>';
document.getElementById('tr-refl-ai-suggestion').textContent = result.experience_suggestion || result.suggestion || '暂无建议';
document.getElementById('tr-refl-ai-modal').classList.add('show');
}
async function trReflSaveExperience() {
const pairId = parseInt(document.getElementById('tr-refl-ai-pair-id').value);
const data = trReflData || { pairs: [] };
const pair = data.pairs.find(p => p.id === pairId);
if (!pair || !trReflCurrentAIResult) return;
const result = trReflCurrentAnalysis || trReflCurrentAIResult;
if (!pair || !result) return;
const result = trReflCurrentAIResult;
const analysisId = result.id;
if (!analysisId) {
showToast('error', '保存失败', '缺少分析记录ID');
@ -1274,19 +1409,38 @@ function trReflCloseAIModal() {
document.getElementById('tr-refl-ai-modal').classList.remove('show');
trReflCurrentPairId = null;
trReflCurrentAIResult = null;
trReflCurrentAnalysis = null;
trReflAIHistory = [];
}
// ===== 经验库 =====
async function trExpLoad() {
try {
const res = await fetch(`${TR_API_BASE}/experiences`);
const params = new URLSearchParams();
params.set('page', trExpPage);
params.set('page_size', trExpPageSize);
const keyword = document.getElementById('tr-exp-search').value.trim();
const tag = document.getElementById('tr-exp-tag-filter').value;
const type = document.getElementById('tr-exp-type-filter').value;
if (keyword) params.set('keyword', keyword);
if (tag) params.set('tag', tag);
if (type) params.set('type', type);
const res = await fetch(`${TR_API_BASE}/experiences?${params.toString()}`);
const json = await res.json();
trReflExperiences = json.success ? (json.data || []) : [];
if (json.success && json.data) {
trReflExperiences = json.data.items || json.data || [];
trExpTotal = json.data.total || trReflExperiences.length;
} else {
trReflExperiences = [];
trExpTotal = 0;
}
trExpPopulateTagFilter();
trExpRender();
} catch (e) {
console.error('加载经验库失败', e);
trReflExperiences = [];
trExpTotal = 0;
trExpRender();
}
}
@ -1303,30 +1457,33 @@ function trExpPopulateTagFilter() {
function trExpRender() {
const container = document.getElementById('tr-exp-container');
const empty = document.getElementById('tr-exp-empty');
const keyword = document.getElementById('tr-exp-search').value.toLowerCase();
const tag = document.getElementById('tr-exp-tag-filter').value;
const type = document.getElementById('tr-exp-type-filter').value;
const pagination = document.getElementById('tr-exp-pagination');
const pageInfo = document.getElementById('tr-exp-pageinfo');
const view = document.querySelector('.tr-exp-viewtab.active').dataset.view;
let list = trReflExperiences.filter(e => {
const matchKeyword = !keyword || (e.title || '').toLowerCase().includes(keyword) || (e.content || '').toLowerCase().includes(keyword);
const matchTag = !tag || (e.tags || []).some(t => (typeof t === 'string' ? t : t.name) === tag);
const matchType = !type || e.type === type;
return matchKeyword && matchTag && matchType;
});
const list = trReflExperiences;
if (!list.length) {
container.innerHTML = '';
container.className = 'tr-exp-grid';
empty.style.display = 'flex';
if (pagination) pagination.style.display = 'none';
return;
}
empty.style.display = 'none';
const totalPages = Math.max(1, Math.ceil(trExpTotal / trExpPageSize));
if (pagination) {
pagination.style.display = 'flex';
pageInfo.textContent = `${trExpPage} / ${totalPages} 页,共 ${trExpTotal}`;
document.getElementById('tr-exp-prev').disabled = trExpPage <= 1;
document.getElementById('tr-exp-next').disabled = trExpPage >= totalPages;
}
if (view === 'timeline') {
container.className = '';
list = [...list].sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
container.innerHTML = `<div style="display:flex;flex-direction:column;gap:16px;">${list.map(e => trExpRenderCard(e, true)).join('')}</div>`;
const sorted = [...list].sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
container.innerHTML = `<div style="display:flex;flex-direction:column;gap:16px;">${sorted.map(e => trExpRenderCard(e, true)).join('')}</div>`;
} else if (view === 'category') {
container.className = '';
const grouped = {};
@ -1352,7 +1509,7 @@ function trExpRenderCard(e, timeline = false) {
const typeNames = { lesson: '教训', insight: '心得', strategy: '策略', discipline: '纪律' };
const tags = (e.tags || []).map(t => `<span class="tr-refl-tag">${typeof t === 'string' ? t : t.name}</span>`).join('');
const date = e.created_at ? e.created_at.slice(0, 10) : '';
return `<div class="tr-exp-card">
return `<div class="tr-exp-card" onclick="trExpOpenDetailModal(${e.id})" style="cursor:pointer;">
<div class="tr-exp-card-title">${e.title || '未命名经验'}</div>
<div class="tr-exp-card-meta">${typeNames[e.type] || e.type || '其他'} ${date ? '· ' + date : ''}</div>
<div class="tr-exp-card-content">${(e.content || '').slice(0, 120)}${(e.content || '').length > 120 ? '...' : ''}</div>
@ -1360,6 +1517,39 @@ function trExpRenderCard(e, timeline = false) {
</div>`;
}
function trExpOpenDetailModal(id) {
const e = trReflExperiences.find(item => item.id === id);
if (!e) return;
const typeNames = { lesson: '教训', insight: '心得', strategy: '策略', discipline: '纪律' };
const tags = (e.tags || []).map(t => `<span class="tr-refl-tag">${typeof t === 'string' ? t : t.name}</span>`).join('');
const created = e.created_at ? e.created_at.slice(0, 19).replace('T', ' ') : '-';
const sourceDate = e.source_trade_date || (e.source_pair ? e.source_pair.trade_date : '');
document.getElementById('tr-exp-detail-title').textContent = e.title || '经验详情';
document.getElementById('tr-exp-detail-meta').textContent = `${typeNames[e.type] || e.type || '其他'} · 创建于 ${created}${sourceDate ? ' · 来源交易日期 ' + sourceDate : ''}`;
document.getElementById('tr-exp-detail-content').textContent = e.content || '';
document.getElementById('tr-exp-detail-tags').innerHTML = tags || '<span style="color:var(--text-tertiary);font-size:13px;">无标签</span>';
const sourceEl = document.getElementById('tr-exp-detail-source');
if (e.source_pair_id) {
sourceEl.innerHTML = `<span class="link" onclick="trExpViewSourcePair(${e.source_pair_id})">查看原交易</span>`;
} else {
sourceEl.innerHTML = '';
}
document.getElementById('tr-exp-detail-modal').classList.add('show');
}
function trExpCloseDetailModal() {
document.getElementById('tr-exp-detail-modal').classList.remove('show');
}
function trExpViewSourcePair(pairId) {
console.log('查看原交易配对:', pairId);
alert(`查看原交易配对:${pairId}`);
}
// ===== 星级评分辅助 =====
function trReflUpdateStars(container, score) {
container.querySelectorAll('.tr-form-star').forEach(s => {

Loading…
Cancel
Save