feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -0,0 +1,845 @@
|
||||
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 }
|
||||
})()
|
||||
Reference in New Issue
Block a user