Files
Toolbox/public/tools/yuanzhupai/yuanzhupai.js
T

845 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const yzp = (() => {
let analysts = []
let reports = {}
let activeAnalystId = null
let uiMode = 'debug'
let dialogTimer = null
let dialogCountdown = 0
let dialogHasError = false
let queryCode = ''
let hintMessage = '请通过首页九宫格进入,或使用正确的访问链接'
let currentScenarioId = 'stock_invest'
let scenarioConfig = null
let scenariosData = []
let imageBase64 = ''
let userProfile = null
const fetchHint = async () => {
try {
const resp = await fetch('/api/yuanzhupai/auth/hint')
const data = await resp.json().catch(() => null)
if (data && data.ok && data.message) hintMessage = data.message
} catch {}
}
const el = id => document.getElementById(id)
const esc = s => String(s || '').replace(/[&<>"]/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[m]))
const getUiMode = () => uiMode
const nowStr = () => {
const d = new Date()
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`
}
const clearDialogTimer = () => {
if (dialogTimer) { clearInterval(dialogTimer); dialogTimer = null }
}
const openDialog = () => {
clearDialogTimer()
dialogHasError = false
dialogCountdown = 0
const box = el('ydp-dialog-logs')
if (box) box.innerHTML = ''
const status = el('ydp-dialog-status')
if (status) { status.textContent = '运行中'; status.className = 'log-line info' }
const dlg = el('ydp-dialog')
if (dlg) dlg.classList.add('show')
}
const closeDialog = () => {
clearDialogTimer()
const dlg = el('ydp-dialog')
if (dlg) dlg.classList.remove('show')
}
const appendLog = (line, type) => {
const box = el('ydp-dialog-logs')
if (!box) return
const div = document.createElement('div')
div.className = `log-line${type ? ` ${type}` : ''}`
div.textContent = `[${nowStr()}] ${String(line || '')}`
box.appendChild(div)
box.scrollTop = box.scrollHeight
}
const finishDialog = () => {
if (dialogHasError) return
dialogCountdown = uiMode === 'debug' ? 3 : 2
const status = el('ydp-dialog-status')
const updateStatus = () => {
if (status) { status.textContent = `成功,${dialogCountdown}s 后自动关闭`; status.className = 'log-line ok' }
}
updateStatus()
clearDialogTimer()
dialogTimer = setInterval(() => {
dialogCountdown -= 1
if (dialogCountdown <= 0) { clearDialogTimer(); closeDialog(); return }
updateStatus()
}, 1000)
}
const failDialog = err => {
dialogHasError = true
const status = el('ydp-dialog-status')
if (status) { status.textContent = '发生错误,已中断'; status.className = 'log-line err' }
appendLog(String(err && err.message ? err.message : err), 'err')
}
const copyText = async text => {
if (!navigator.clipboard || !navigator.clipboard.writeText) throw new Error('当前环境不支持剪贴板写入')
await navigator.clipboard.writeText(String(text || ''))
}
const loadConfig = async (version, scenarioId) => {
await fetchHint()
const ver = String(version || 'x').trim()
const sid = String(scenarioId || 'stock_invest').trim()
currentScenarioId = sid
scenarioConfig = null
let authOk = false
try {
const resp = await fetch('/api/yuanzhupai/analysts?version=' + ver + '&scenario=' + sid)
const data = await resp.json().catch(() => null)
if (data && data.ok && Array.isArray(data.analysts)) {
analysts = data.analysts
uiMode = data.ui_mode || 'debug'
if (data.scenario) scenarioConfig = data.scenario
authOk = true
return
}
if (resp.status === 401) authOk = false
} catch {}
analysts = [
{ id:'business', name:'张志远', title:'商业模式分析师', area:'商业模式', initials:'ZZ', color:'#3b82f6', verdict:'buy', verdict_text:'买入' },
{ id:'financial', name:'陈雨薇', title:'资深财务分析师', area:'财务健康', initials:'CY', color:'#06b6d4', verdict:'buy', verdict_text:'买入' },
{ id:'moat', name:'李铭哲', title:'竞争护城河专家', area:'竞争护城河', initials:'LM', color:'#8b5cf6', verdict:'buy', verdict_text:'买入' },
{ id:'valuation', name:'王芷茜', title:'估值与定价分析师', area:'股票估值', initials:'WZ', color:'#f59e0b', verdict:'buy', verdict_text:'买入' },
{ id:'risk', name:'刘浩宇', title:'风险管理总监', area:'风险分析', initials:'LH', color:'#ef4444', verdict:'hold', verdict_text:'持有' },
{ id:'growth', name:'孙雪莹', title:'增长战略分析师', area:'增长潜力', initials:'SX', color:'#22c55e', verdict:'buy', verdict_text:'买入' },
{ id:'institutional', name:'赵启航', title:'机构投资策略师', area:'机构视角', initials:'ZQ', color:'#14b8a6', verdict:'buy', verdict_text:'买入' },
{ id:'bull', name:'周俊杰', title:'看涨首席分析师', area:'看涨论点', initials:'ZJ', color:'#4ade80', verdict:'buy', verdict_text:'买入' },
{ id:'bear', name:'吴静波', title:'看跌风险分析师', area:'看跌论点', initials:'WJ', color:'#f87171', verdict:'sell', verdict_text:'卖出' },
{ id:'chairman', name:'Yang John', title:'圆桌主席 · 首席投资官', area:'最终判决', initials:'YJ', color:'#c9a84c', verdict:'hold', verdict_text:'综合裁定' }
]
if (!authOk) {
const rpEmptyP = document.querySelector('.rp-empty-p')
if (rpEmptyP) {
rpEmptyP.innerHTML = `请通过首页九宫格进入<br>或使用正确的访问链接<br><br><small style="color:#c9a84c">${esc(hintMessage)}</small>`
}
}
}
const positionSeats = () => {
const wrap = el('tcWrap')
if (!wrap || !analysts.length) return
const cx = 258, cy = 258, r = 210
const total = analysts.length
analysts.forEach((a, i) => {
const angle = -Math.PI / 2 + (2 * Math.PI * i) / total
const left = cx + r * Math.cos(angle) - 25
const top = cy + r * Math.sin(angle) - 25
const seat = el(`seat-${a.id}`)
if (seat) { seat.style.left = `${left}px`; seat.style.top = `${top}px` }
})
}
const renderSeats = () => {
const wrap = el('tcWrap')
if (!wrap) return
wrap.querySelectorAll('.seat').forEach(s => s.remove())
analysts.forEach(a => {
const div = document.createElement('div')
div.className = 'seat'
div.id = `seat-${a.id}`
div.innerHTML = `
<div class="seat-av" style="background:${a.color}22;color:${a.color}">
${esc(a.initials)}
<span class="seat-loading"></span>
<span class="seat-badge" style="background:${(scenarioConfig && scenarioConfig.verdicts && scenarioConfig.verdicts[a.verdict] && scenarioConfig.verdicts[a.verdict].color) || '#888'}22;color:${(scenarioConfig && scenarioConfig.verdicts && scenarioConfig.verdicts[a.verdict] && scenarioConfig.verdicts[a.verdict].color) || '#888'}">${esc((a.verdictText || a.verdict_text || '').charAt(0))}</span>
</div>
<div class="seat-nm">${esc(a.name)}</div>
<div class="seat-rl">${esc(a.title)}</div>
`
div.onclick = () => selectAnalyst(a.id)
wrap.appendChild(div)
})
positionSeats()
}
const renderBottomChips = () => {
const container = el('botChips')
if (!container) return
container.innerHTML = ''
analysts.forEach(a => {
const chip = document.createElement('div')
chip.className = `chip${activeAnalystId === a.id ? ' active' : ''}`
chip.id = `chip-${a.id}`
chip.innerHTML = `<span class="chip-d" style="background:${a.color}"></span>${esc(a.name)}`
chip.onclick = () => selectAnalyst(a.id)
container.appendChild(chip)
})
}
const renderProgress = () => {
const container = el('progList')
if (!container) return
container.innerHTML = ''
const verdicts = (scenarioConfig && scenarioConfig.verdicts) || { buy: { label: '买入', color: '#4ade80' }, hold: { label: '持有', color: '#f59e0b' }, sell: { label: '卖出', color: '#f87171' } }
const verdictKeys = Object.keys(verdicts)
const total = analysts.length
const done = Object.keys(reports).filter(k => reports[k] && !reports[k].error).length
analysts.forEach(a => {
const r = reports[a.id]
const isDone = r && !r.error && !r._loading
const isLoading = r && r._loading
const div = document.createElement('div')
div.className = 'prog-item'
div.style.opacity = isDone ? '1' : isLoading ? '.7' : '.35'
div.innerHTML = `
<div class="prog-av" style="background:${a.color}22;color:${a.color}">${esc(a.initials)}</div>
<div class="prog-name">${esc(a.name)}</div>
<div class="prog-area">${isLoading ? '分析中…' : isDone ? '已完成' : '待开始'}</div>
`
div.onclick = () => r && selectAnalyst(a.id)
container.appendChild(div)
})
if (done > 0) {
const conBar = el('conBar')
const verdictCounts = {}
verdictKeys.forEach(v => { verdictCounts[v] = Object.values(reports).filter(r => r && r.verdict === v && !r.error).length })
if (conBar) {
const totalDone = Object.values(verdictCounts).reduce((s, v) => s + v, 0) || 1
conBar.innerHTML = verdictKeys.map(v => `<div class="con-seg" style="flex:${verdictCounts[v] || 1};background:${verdicts[v].color || '#888'}"></div>`).join('')
}
const conLbls = el('conLbls')
if (conLbls) {
conLbls.innerHTML = verdictKeys.map(v => `<div class="c-lbl"><div class="c-dot" style="background:${verdicts[v].color || '#888'}"></div>${verdicts[v].label} ${verdictCounts[v]}/${total}</div>`).join('')
}
}
}
const selectAnalyst = id => {
activeAnalystId = id
const r = reports[id]
renderSeats()
renderBottomChips()
const active = el(`seat-${id}`)
if (active) {
document.querySelectorAll('.seat.active').forEach(s => s.classList.remove('active'))
active.classList.add('active')
}
if (r && !r._loading) {
renderReport(id)
} else if (!r) {
showEmptyReport()
}
}
const showEmptyReport = () => {
const empty = el('rpEmpty')
const content = el('rpContent')
if (empty) empty.style.display = ''
if (content) content.classList.remove('show')
}
const showLoadingReport = () => {
const empty = el('rpEmpty')
const content = el('rpContent')
if (empty) empty.style.display = 'none'
if (content) {
content.classList.add('show')
content.innerHTML = '<div class="rp-loading">分析中…</div>'
}
}
const renderReport = id => {
const r = reports[id]
if (!r) { showEmptyReport(); return }
const a = analysts.find(x => x.id === id) || {}
const empty = el('rpEmpty')
const content = el('rpContent')
if (empty) empty.style.display = 'none'
if (!content) return
content.classList.add('show')
if (r.error) {
const vColor = (scenarioConfig && scenarioConfig.verdicts && scenarioConfig.verdicts[a.verdict] && scenarioConfig.verdicts[a.verdict].color) || '#888'
content.innerHTML = `
<div class="rp-hd">
<div class="rp-analyst-row">
<div class="rp-av" style="background:${a.color}22;color:${a.color};border-color:${a.color}44">${esc(a.initials)}</div>
<div><div class="rp-nm">${esc(a.name)}</div><div class="rp-tl">${esc(a.title)}</div></div>
</div>
<div class="rp-badges"><span class="vb" style="background:${vColor}22;color:${vColor};border-color:${vColor}44">${esc(a.verdict_text || a.verdictText || '')}</span></div>
</div>
<div class="rp-error">分析失败:${esc(r.error)}</div>
`
return
}
if (r._loading) {
const vColor2 = (scenarioConfig && scenarioConfig.verdicts && scenarioConfig.verdicts[a.verdict] && scenarioConfig.verdicts[a.verdict].color) || '#888'
content.innerHTML = `
<div class="rp-hd">
<div class="rp-analyst-row">
<div class="rp-av" style="background:${a.color}22;color:${a.color};border-color:${a.color}44">${esc(a.initials)}</div>
<div><div class="rp-nm">${esc(a.name)}</div><div class="rp-tl">${esc(a.title)}</div></div>
</div>
<div class="rp-badges"><span class="vb" style="background:${vColor2}22;color:${vColor2};border-color:${vColor2}44">${esc(a.verdict_text || a.verdictText || '')}</span></div>
</div>
<div class="rp-loading">分析报告中…</div>
`
return
}
const tokenInfo = r.tokens ? `${r.tokens.total} tokens · ${r.request_ms}ms` : ''
const vColor3 = (scenarioConfig && scenarioConfig.verdicts && scenarioConfig.verdicts[a.verdict] && scenarioConfig.verdicts[a.verdict].color) || '#888'
content.innerHTML = `
<div class="rp-hd">
<div class="rp-analyst-row">
<div class="rp-av" style="background:${a.color}22;color:${a.color};border-color:${a.color}44">${esc(a.initials)}</div>
<div><div class="rp-nm">${esc(a.name)}</div><div class="rp-tl">${esc(a.title)}</div></div>
</div>
<div class="rp-badges">
<span class="vb" style="background:${vColor3}22;color:${vColor3};border-color:${vColor3}44">${esc(a.verdict_text || a.verdictText || '')}</span>
${tokenInfo ? `<span class="sb2">${esc(tokenInfo)}</span>` : ''}
</div>
</div>
<div class="sec"><div class="sec-body"><pre>${esc(r.report)}</pre></div></div>
<div style="margin-top:12px">
<button class="qwen-btn" onclick="yzp.copyReport('${id}')">复制报告</button>
</div>
`
}
const copyReport = async id => {
const r = reports[id]
if (!r || !r.report) return
try {
await copyText(r.report)
if (uiMode === 'normal') {
showToast('已复制到剪贴板', 'ok')
}
} catch {
if (uiMode === 'normal') {
showToast('复制失败', 'err')
}
}
}
let toastTimer = null
const showToast = (msg, type) => {
if (toastTimer) clearTimeout(toastTimer)
let el = document.getElementById('yzp-toast')
if (!el) {
el = document.createElement('div')
el.id = 'yzp-toast'
el.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;padding:8px 20px;border-radius:20px;font-size:12px;font-weight:600;pointer-events:none;transition:opacity .3s;'
document.body.appendChild(el)
}
if (type === 'ok') { el.style.background = '#166534'; el.style.color = '#86efac' }
else if (type === 'err') { el.style.background = '#991b1b'; el.style.color = '#fca5a5' }
else { el.style.background = '#1e3a5f'; el.style.color = '#93c5fd' }
el.textContent = msg
el.style.opacity = '1'
toastTimer = setTimeout(() => { el.style.opacity = '0' }, 2000)
}
const updateChairmanInfo = () => {
const ceoName = el('ceo-name')
const ceoTitle = el('ceo-title')
const chairman = analysts.find(a => a.id === 'chairman' || a.id === 'cio' || a.id === 'final_verdict')
if (ceoName && chairman) ceoName.textContent = chairman.initials || '主'
if (ceoTitle && chairman) ceoTitle.textContent = `${chairman.name} · ${chairman.title}`
}
const updateDate = () => {
const d = new Date()
const pad = n => String(n).padStart(2, '0')
const el2 = document.getElementById('dateEl')
if (el2) el2.textContent = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
const setButtonLoading = (btn, loading) => {
if (!btn) return
if (loading) {
btn._origText = btn.textContent
btn.disabled = true
btn.textContent = '分析中…'
} else {
btn.disabled = false
btn.textContent = btn._origText || '开始分析'
}
}
const updateCenterTable = (code, label) => {
const codeEl = el('ts-code')
const nameEl = el('ts-name')
const priceEl = el('ts-price')
const chgEl = el('ts-chg')
if (codeEl) codeEl.textContent = code || '输入议题'
if (nameEl) nameEl.textContent = label || code || '待分析议题'
if (priceEl) priceEl.textContent = '--'
if (chgEl) { chgEl.textContent = label ? '分析完成' : '等待分析'; chgEl.className = label ? 'ts-chg' : 'ts-chg' }
}
const updateSidebar = (code, resultSummary) => {
const companyEl = el('sb-company')
const sectorEl = el('sb-sector')
const codeEl = el('sb-code')
const doneEl = el('sb-done')
const sbTitle = (scenarioConfig && scenarioConfig.sidebar && scenarioConfig.sidebar.consensus_title) || '共识评级'
const consensusLabel = el('consensus-label')
if (consensusLabel) consensusLabel.textContent = sbTitle
if (companyEl) companyEl.textContent = code || '待分析标的'
if (sectorEl) sectorEl.textContent = resultSummary ? '会议已完成' : ((scenarioConfig && scenarioConfig.input && scenarioConfig.input.placeholder) || '输入议题')
if (codeEl) codeEl.textContent = code || '--'
if (doneEl) doneEl.textContent = resultSummary ? `${resultSummary.success || 0} / ${resultSummary.total || analysts.length}` : '0'
const sbCount = el('sb-count')
if (sbCount) sbCount.textContent = analysts.length + '人'
const badge = el('live-badge')
if (badge) badge.innerHTML = resultSummary ? '<span class="live-dot" style="background:var(--green)"></span>会议已结束' : '<span class="live-dot"></span>会议待启'
}
const startAnalysis = async () => {
const input = el('hd-search-input')
if (!input) return
const code = String(input.value || '').trim()
if (!code) return
queryCode = code
const searchBtn = el('hd-search-btn')
setButtonLoading(searchBtn, true)
reports = {}
analysts.forEach(a => { reports[a.id] = { _loading: true, verdict: a.verdict, verdictText: a.verdictText || a.verdict_text || '' } })
activeAnalystId = null
renderSeats()
renderBottomChips()
renderProgress()
updateCenterTable(queryCode)
updateSidebar(queryCode, null)
showEmptyReport()
updateDate()
if (uiMode === 'debug') openDialog()
appendLog(`开始圆桌分析,标的:${code}`)
appendLog(`分析师人数:${analysts.length}`, 'info')
try {
const resp = await fetch('/api/yuanzhupai/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, version: el('hd-version') ? el('hd-version').value : 'x', scenario: currentScenarioId, imageBase64, userProfile })
})
if (resp.status === 401) throw new Error(`未登录:${hintMessage}`)
const data = await resp.json().catch(() => null)
if (!resp.ok || !data || !data.ok) throw new Error((data && data.error) || `请求失败: ${resp.status}`)
const result = data.result
const apiLogs = Array.isArray(data.logs) ? data.logs : []
apiLogs.forEach(l => appendLog(l))
reports = {}
;(result.analysts || []).forEach(r => {
reports[r.id] = {
report: r.report || '',
error: r.error || null,
tokens: r.tokens || { prompt: 0, completion: 0, total: 0 },
request_ms: r.request_ms || 0,
verdict: r.verdict,
verdictText: r.verdictText,
_loading: false
}
const seat = el(`seat-${r.id}`)
if (seat) seat.classList.remove('loading')
})
renderSeats()
renderBottomChips()
renderProgress()
updateCenterTable(queryCode, queryCode)
updateSidebar(queryCode, result.summary)
if (uiMode === 'debug') {
finishDialog()
} else {
closeDialog()
const successCount = result.summary ? result.summary.success : (result.analysts || []).filter(x => !x.error).length
showToast(`${successCount}/${analysts.length} 位分析师完成报告`, 'ok')
}
} catch (e) {
const errMsg = String(e.message || e)
const isAuth = errMsg.includes('未登录') || errMsg.includes('unauthorized')
appendLog(`分析失败:${errMsg}`, 'err')
if (uiMode === 'debug') {
failDialog(e)
} else {
showToast(`分析失败:${errMsg}`, 'err')
}
if (isAuth) {
const empty = el('rpEmpty')
const content = el('rpContent')
if (empty && content) {
empty.style.display = 'none'
content.classList.add('show')
content.innerHTML = `<div style="padding:24px;text-align:center">
<div style="font-size:40px;margin-bottom:16px">🔐</div>
<div style="font-family:'Noto Serif SC',serif;font-size:16px;color:var(--text);margin-bottom:12px">需要身份验证</div>
<div style="font-size:12px;color:var(--muted);line-height:1.8;margin-bottom:16px">
请通过首页九宫格点击 <b style="color:var(--gold)">圆桌派</b> 进入<br>
或使用 GUID 链接直接访问
</div>
<a href="/go?systemId=Tools-yuanzhupai" style="display:inline-block;background:rgba(201,168,76,.1);border:1px solid var(--border-md);border-radius:20px;padding:8px 24px;color:var(--gold);font-size:12px;text-decoration:none;font-weight:600">🏠 从首页九宫格进入</a>
</div>`
}
}
analysts.forEach(a => {
if (reports[a.id] && reports[a.id]._loading) {
reports[a.id] = { error: e.message || e, _loading: false }
const seat = el(`seat-${a.id}`)
if (seat) seat.classList.remove('loading')
}
})
renderSeats()
renderProgress()
} finally {
setButtonLoading(searchBtn, false)
updateDate()
}
}
const updateImageBar = () => {
const bar = el('imageBar')
if (!bar) return
const inputType = (scenarioConfig && scenarioConfig.input && scenarioConfig.input.type) || 'text'
if (inputType === 'image_text') {
bar.style.display = ''
} else {
bar.style.display = 'none'
resetImageUI()
}
}
const resetImageUI = () => {
imageBase64 = ''
const preview = el('imagePreview')
const wrap = el('imagePreviewWrap')
if (preview) preview.src = ''
if (wrap) wrap.style.display = 'none'
const btn = el('imageUploadBtn')
if (btn) btn.textContent = '📷 上传穿搭照片'
const fileInput = el('imageFileInput')
if (fileInput) fileInput.value = ''
}
const loadDefaultProfile = () => {
if (scenarioConfig && scenarioConfig.input && scenarioConfig.input.user_profile) {
userProfile = JSON.parse(JSON.stringify(scenarioConfig.input.user_profile))
} else {
userProfile = null
}
updateProfileSummary()
}
const updateProfileSummary = () => {
const el = document.getElementById('profileSummary')
if (!el) return
if (!userProfile) { el.textContent = ''; return }
const p = userProfile
const parts = []
parts.push(`年龄:${p.age || '--'}岁`)
if (p.industry) parts.push(p.industry)
parts.push(`${p.height || '--'}cm/${p.weight || '--'}kg`)
el.textContent = parts.join(' · ')
}
const openProfileModal = () => {
const panel = el('yp-profile-panel')
if (!panel) return
const p = userProfile || {}
const goal = Array.isArray(p.goal) ? p.goal.join(', ') : (p.goal || '')
const style = Array.isArray(p.style_preference) ? p.style_preference.join(', ') : (p.style_preference || '')
const setVal = (id, val) => { const e = document.getElementById(id); if (e) e.value = val || '' }
setVal('pf-age', p.age || '')
setVal('pf-industry', p.industry || '')
setVal('pf-height', p.height || '')
setVal('pf-weight', p.weight || '')
setVal('pf-goal', goal)
setVal('pf-style', style)
panel.classList.add('show')
}
const closeProfileModal = () => {
const panel = el('yp-profile-panel')
if (panel) panel.classList.remove('show')
}
const saveProfile = () => {
const getVal = id => { const e = document.getElementById(id); return e ? String(e.value || '').trim() : '' }
userProfile = {
age: parseInt(getVal('pf-age'), 10) || 0,
industry: getVal('pf-industry'),
height: parseInt(getVal('pf-height'), 10) || 0,
weight: parseInt(getVal('pf-weight'), 10) || 0,
goal: getVal('pf-goal').split(/[,,]/).map(s => s.trim()).filter(Boolean),
style_preference: getVal('pf-style').split(/[,,]/).map(s => s.trim()).filter(Boolean)
}
updateProfileSummary()
closeProfileModal()
}
const populateVersionSelect = sid => {
const sel = el('hd-version')
if (!sel) return
const scenario = scenariosData.find(s => s.id === sid) || scenariosData[0]
const variants = (scenario && scenario.variants) || {}
const keys = Object.keys(variants)
if (!keys.length) return
const currentVal = sel.value
sel.innerHTML = keys.map(k => `<option value="${esc(k)}">${esc(variants[k])}</option>`).join('')
if (keys.includes(currentVal)) {
sel.value = currentVal
}
}
const copyAllLogs = async () => {
const box = el('ydp-dialog-logs')
if (!box) return
const text = Array.from(box.querySelectorAll('.log-line')).map(d => d.textContent).join('\n')
try { await copyText(text) } catch {}
}
const bindEvents = () => {
const searchBtn = el('hd-search-btn')
if (searchBtn) searchBtn.onclick = startAnalysis
const searchInput = el('hd-search-input')
if (searchInput) {
searchInput.onkeydown = e => { if (e.key === 'Enter') startAnalysis() }
}
const scenarioSelect = el('hd-scenario')
if (scenarioSelect) {
scenarioSelect.onchange = async () => {
reports = {}
activeAnalystId = null
resetImageUI()
const sid = scenarioSelect.value
populateVersionSelect(sid)
const ver = el('hd-version') ? el('hd-version').value : 'x'
await loadConfig(ver, sid)
loadDefaultProfile()
updateImageBar()
renderSeats()
renderBottomChips()
renderProgress()
updateChairmanInfo()
updateSidebar(queryCode || '', null)
showEmptyReport()
const input = el('hd-search-input')
if (input && scenarioConfig && scenarioConfig.input) {
input.placeholder = scenarioConfig.input.placeholder || '请输入…'
}
}
}
const historyBtn = el('hd-history-btn')
if (historyBtn) historyBtn.onclick = openHistory
const historyClose = el('yp-history-close')
if (historyClose) historyClose.onclick = closeHistory
const historyBack = el('yp-history-back')
if (historyBack) {
historyBack.style.display = 'none'
historyBack.onclick = openHistory
}
const historyCloseBottom = el('yp-history-close-bottom')
if (historyCloseBottom) historyCloseBottom.onclick = closeHistory
const versionSelect = el('hd-version')
if (versionSelect) {
versionSelect.onchange = () => {
reports = {}
activeAnalystId = null
const sid = el('hd-scenario') ? el('hd-scenario').value : 'stock_invest'
loadConfig(versionSelect.value, sid).then(() => {
renderSeats()
renderBottomChips()
renderProgress()
updateChairmanInfo()
updateImageBar()
showEmptyReport()
})
}
}
const dialogClose = el('ydp-dialog-close')
if (dialogClose) dialogClose.onclick = closeDialog
const dialogCopy = el('ydp-dialog-copy')
if (dialogCopy) dialogCopy.onclick = copyAllLogs
const dialogCloseBtn = el('ydp-dialog-close-bottom')
if (dialogCloseBtn) dialogCloseBtn.onclick = closeDialog
const imageUploadBtn = el('imageUploadBtn')
const imageFileInput = el('imageFileInput')
if (imageUploadBtn && imageFileInput) {
imageUploadBtn.onclick = () => imageFileInput.click()
imageFileInput.onchange = () => {
const file = imageFileInput.files && imageFileInput.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
imageBase64 = reader.result
const preview = el('imagePreview')
const wrap = el('imagePreviewWrap')
if (preview) preview.src = imageBase64
if (wrap) wrap.style.display = ''
if (imageUploadBtn) imageUploadBtn.textContent = '📷 已上传照片'
}
reader.readAsDataURL(file)
}
}
const imageRemoveBtn = el('imageRemoveBtn')
if (imageRemoveBtn) {
imageRemoveBtn.onclick = () => {
imageBase64 = ''
const preview = el('imagePreview')
const wrap = el('imagePreviewWrap')
if (preview) preview.src = ''
if (wrap) wrap.style.display = 'none'
if (imageUploadBtn) imageUploadBtn.textContent = '📷 上传穿搭照片'
const fileInput = el('imageFileInput')
if (fileInput) fileInput.value = ''
}
}
const profileEditBtn = el('profileEditBtn')
if (profileEditBtn) profileEditBtn.onclick = openProfileModal
const profileClose = el('yp-profile-close')
if (profileClose) profileClose.onclick = closeProfileModal
const profileSave = el('yp-profile-save')
if (profileSave) profileSave.onclick = saveProfile
}
const init = async () => {
await fetchHint()
const scenarioSelect = el('hd-scenario')
const versionSelect = el('hd-version')
const sid = scenarioSelect ? scenarioSelect.value : 'stock_invest'
if (scenarioSelect) {
try {
const resp = await fetch('/api/yuanzhupai/scenarios')
const data = await resp.json().catch(() => null)
if (data && data.ok && Array.isArray(data.scenarios) && data.scenarios.length) {
scenariosData = data.scenarios
scenarioSelect.innerHTML = data.scenarios.map(s => `<option value="${esc(s.id)}"${s.id === sid ? ' selected' : ''}>${esc(s.icon || '')} ${esc(s.name)}</option>`).join('')
populateVersionSelect(sid)
}
} catch {}
}
const ver = versionSelect ? versionSelect.value : 'x'
await loadConfig(ver, sid)
loadDefaultProfile()
updateImageBar()
if (scenarioConfig && scenarioConfig.input) {
const input = el('hd-search-input')
if (input) input.placeholder = scenarioConfig.input.placeholder || '请输入…'
}
renderSeats()
renderBottomChips()
renderProgress()
updateChairmanInfo()
updateDate()
bindEvents()
setInterval(updateDate, 60000)
}
document.addEventListener('DOMContentLoaded', init)
const openHistory = async () => {
const panel = el('yp-history-panel')
const list = el('yp-history-list')
const backBtn = el('yp-history-back')
if (backBtn) backBtn.style.display = 'none'
if (panel) panel.classList.add('show')
if (list) list.innerHTML = '<div style="padding:24px;text-align:center;color:var(--muted)">加载中…</div>'
try {
const resp = await fetch('/api/yuanzhupai/sessions?limit=20')
const data = await resp.json().catch(() => null)
if (!data || !data.ok || !Array.isArray(data.sessions)) throw new Error('获取失败')
if (!data.sessions.length) { if (list) list.innerHTML = '<div style="padding:32px;text-align:center;color:var(--muted)">暂无历史会议记录</div>'; return }
if (list) {
list.innerHTML = data.sessions.map(s => {
const d = new Date(s.created_at)
const ts = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
return `<div class="yp-session-row" style="padding:12px 16px;border-bottom:1px solid var(--border-sub);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:background .15s" onmouseenter="this.style.background='var(--surface-2)'" onmouseleave="this.style.background='transparent'" onclick="yzp.loadHistorySession(${s.id})">
<div>
<div style="font-weight:600;color:var(--text)">${esc(s.query_code)}</div>
<div style="font-size:11px;color:var(--muted);margin-top:2px">${ts} · ${esc(s.version || 'x')}版本</div>
</div>
<div style="font-size:18px;color:var(--gold);opacity:.6">→</div>
</div>`
}).join('')
}
} catch (e) {
if (list) list.innerHTML = `<div style="padding:24px;text-align:center;color:var(--red)">加载失败:${esc(String(e.message || e))}</div>`
}
}
const loadHistorySession = async sessionId => {
const list = el('yp-history-list')
const backBtn = el('yp-history-back')
if (!list) return
list.innerHTML = '<div style="padding:24px;text-align:center;color:var(--muted)">加载会议记录…</div>'
try {
const resp = await fetch('/api/yuanzhupai/sessions/' + sessionId)
const data = await resp.json().catch(() => null)
if (!data || !data.ok || !data.session) throw new Error('session_not_found')
const s = data.session
const reports = Array.isArray(s.reports) ? s.reports : []
if (backBtn) backBtn.style.display = ''
list.innerHTML = `<div style="padding:16px;border-bottom:1px solid var(--border-md)">
<div style="font-weight:700;font-size:14px;color:var(--gold)">${esc(s.query_code)}</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px">${esc(s.created_at)} · ${esc(s.version || 'x')}版本 · ${reports.length}位分析师</div>
</div>`
reports.forEach(r => {
const div = document.createElement('div')
div.style.cssText = 'padding:12px 16px;border-bottom:1px solid var(--border-sub)'
div.innerHTML = `<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="font-size:13px;font-weight:600;color:var(--text)">${esc(r.analyst_name)}</span>
<span style="font-size:11px;color:var(--muted)">${esc(r.analyst_title)}</span>
<span style="margin-left:auto;font-size:10px;color:var(--muted)">${r.tokens_used || 0}t · ${r.request_ms || 0}ms</span>
</div>
<pre style="margin:0;font-size:12px;line-height:1.5;color:var(--text);white-space:pre-wrap;max-height:200px;overflow-y:auto;background:var(--surface-0);padding:8px;border-radius:8px">${esc(r.report_text)}</pre>`
list.appendChild(div)
})
} catch (e) {
list.innerHTML = `<div style="padding:24px;text-align:center;color:var(--red)">加载失败:${esc(String(e.message || e))}</div>`
}
}
const closeHistory = () => {
const panel = el('yp-history-panel')
if (panel) panel.classList.remove('show')
}
return { copyReport, openHistory, loadHistorySession, closeHistory }
})()