chore: 初始提交 - 移除所有硬编码凭据,统一从环境变量读取

This commit is contained in:
yangxiangyuan
2026-06-11 17:10:58 +08:00
commit c223c79b36
1699 changed files with 313940 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"_comment": "圆桌派工具的鉴权配置",
"enable_auth_comment": "true:启用三级鉴权(需要从导航登录、token握手或GUID访问);false:完全放开访问",
"enable_auth": true,
"max_age_days_comment": "鉴权 cookie 的最大有效天数(1~365)",
"max_age_days": 30,
"cookieName_comment": "鉴权 Cookie 名称,建议保持默认",
"cookieName": "yuanzhupai_gate",
"requireGuid_comment": "true:入口必须带 guid 参数;false:允许仅凭 token 或会话访问",
"requireGuid": true,
"guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid",
"guidParam": "guid",
"guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换",
"guidValue": "YUAN-ZHUOPAI-GUID-2025-SECURE-KEY-V8X9M2K4"
}
+179
View File
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>圆桌派 · 股票研究圆桌会议</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700&family=Noto+Sans+SC:wght@300;400;500;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/tools/yuanzhupai/yuanzhupai.css?v=1">
</head>
<body>
<header class="hd">
<div class="hd-brand">
<div class="hd-emblem">圆</div>
<div>
<div class="hd-title">圆桌派</div>
<div class="hd-sub">机构级股票研究圆桌会议系统 · Round Table Research</div>
</div>
</div>
<div class="hd-search">
<select id="hd-scenario" class="hd-version" style="margin-right:8px">
<option value="stock_invest">📈 股票投研</option>
</select>
<input id="hd-search-input" class="hd-search-input" type="text" placeholder="如 600028、159915、513100" autocomplete="off" />
<select id="hd-version" class="hd-version"></select>
<button id="hd-search-btn" class="hd-search-btn">开始分析</button>
</div>
<div class="hd-right">
<button id="hd-history-btn" class="hd-search-btn" style="margin-right:12px;background:transparent;border-color:var(--border-md);color:var(--text);padding:7px 16px">📋 历史</button>
<div class="live-badge"><span class="live-dot"></span>圆桌会议待启</div>
<div class="hd-date" id="dateEl"></div>
</div>
</header>
<div class="image-bar" id="imageBar" style="display:none">
<div class="image-bar-inner">
<div class="image-upload-area">
<input type="file" id="imageFileInput" accept="image/*" style="display:none" />
<button id="imageUploadBtn" class="image-upload-btn">📷 上传穿搭照片</button>
<div class="image-preview-wrap" id="imagePreviewWrap" style="display:none">
<img id="imagePreview" src="" alt="预览" />
<button id="imageRemoveBtn" class="image-remove-btn">✕</button>
</div>
</div>
<div class="profile-edit-area">
<button id="profileEditBtn" class="profile-edit-btn">👤 人物画像</button>
<span class="profile-summary" id="profileSummary">年龄:50岁 · IT · 176cm/76kg</span>
</div>
</div>
</div>
<div class="layout">
<aside class="sb">
<div class="card">
<div class="co-name" id="sb-company">待分析标的</div>
<div class="co-sector" id="sb-sector">输入股票代码或公司名称</div>
<div class="s-row"><span class="s-lbl">查询代码</span><span class="s-val gd" id="sb-code">--</span></div>
<div class="s-row"><span class="s-lbl">分析师人数</span><span class="s-val" id="sb-count">10人</span></div>
<div class="s-row"><span class="s-lbl">已完成</span><span class="s-val gn" id="sb-done">0</span></div>
</div>
<div class="sb-h">分析师共识评级</div>
<div class="card">
<div style="display:flex;justify-content:space-between;margin-bottom:7px;">
<span style="font-size:10px;color:var(--muted)">10位分析师综合</span>
<span style="font-size:13px;font-weight:700;color:var(--green)" id="consensus-label">待分析</span>
</div>
<div class="con-bar" id="conBar">
<div class="con-seg b" style="flex:1"></div>
<div class="con-seg h" style="flex:1"></div>
<div class="con-seg s" style="flex:1"></div>
</div>
<div class="con-lbls" id="conLbls">
<div class="c-lbl"><div class="c-dot b"></div>买入 --</div>
<div class="c-lbl"><div class="c-dot h"></div>持有 --</div>
<div class="c-lbl"><div class="c-dot s"></div>卖出 --</div>
</div>
</div>
<div class="sb-h">会议出席进度</div>
<div id="progList" style="display:flex;flex-direction:column;gap:3px;"></div>
</aside>
<main class="ta">
<div class="ta-hint">↑ 点击圆桌席位查看深度研究报告</div>
<div class="tc-wrap" id="tcWrap">
<div class="ring-outer"></div>
<div class="ring-mid"></div>
<div class="ts">
<div class="ts-code" id="ts-code">输入代码</div>
<div class="ts-name" id="ts-name">待分析标的</div>
<div class="ts-price" id="ts-price">--</div>
<div class="ts-chg" id="ts-chg">等待分析</div>
</div>
</div>
<div class="ceo-arr">↑</div>
<div class="ceo-bar">
<div class="ceo-av" id="ceo-name">主</div>
<div class="ceo-lbl" id="ceo-title">圆桌主席 · 首席投资官</div>
<div class="ceo-sub">主持人席位 · 统筹十位分析师研究意见</div>
</div>
</main>
<aside class="rp" id="rp">
<div class="rp-empty" id="rpEmpty">
<div class="rp-empty-glyph">⬡</div>
<div class="rp-empty-h">选择圆桌上的<br>任意分析师席位</div>
<div class="rp-empty-p">十位专家正等待呈现<br>深度研究报告<br><br>输入股票代码或公司名称<br>点击"开始分析"启动圆桌会议</div>
</div>
<div class="rp-content" id="rpContent"></div>
</aside>
</div>
<div class="bot">
<span class="bot-lbl">快速切换</span>
<div class="bot-chips" id="botChips"></div>
<div class="qwen-status"><div class="qs-dot" style="width:5px;height:5px;border-radius:50%;background:var(--gold-dk)"></div>Qwen AI · qwen-plus-latest</div>
</div>
<div class="modal" id="ydp-dialog">
<div class="modal-body">
<div class="ydp-dialog-head">
<h3>圆桌会议 · 交互详情</h3>
<button class="ydp-dialog-close" id="ydp-dialog-close">✕</button>
</div>
<div class="ydp-dialog-body">
<div class="log-line info" id="ydp-dialog-status">运行中</div>
<div id="ydp-dialog-logs"></div>
</div>
<div class="ydp-dialog-footer">
<button id="ydp-dialog-copy">复制全部日志</button>
<button class="primary" id="ydp-dialog-close-bottom">关闭</button>
</div>
</div>
</div>
<div class="modal" id="yp-history-panel">
<div class="modal-body" style="max-width:640px">
<div class="ydp-dialog-head">
<h3>📋 历史会议记录</h3>
<button class="ydp-dialog-close" id="yp-history-close">✕</button>
</div>
<div style="max-height:420px;overflow-y:auto" id="yp-history-list">
<div style="padding:24px;text-align:center;color:var(--muted)">加载中…</div>
</div>
<div class="ydp-dialog-footer">
<button id="yp-history-back">返回列表</button>
<button class="primary" id="yp-history-close-bottom">关闭</button>
</div>
</div>
</div>
<div class="modal" id="yp-profile-panel">
<div class="modal-body" style="max-width:480px">
<div class="ydp-dialog-head">
<h3>👤 编辑人物画像</h3>
<button class="ydp-dialog-close" id="yp-profile-close">✕</button>
</div>
<div style="padding:6px 0 12px">
<div class="pf-row"><label>年龄</label><input id="pf-age" type="number" min="18" max="99" placeholder="50" /></div>
<div class="pf-row"><label>行业</label><input id="pf-industry" type="text" placeholder="IT" /></div>
<div class="pf-row2">
<div class="pf-row"><label>身高(cm)</label><input id="pf-height" type="number" min="150" max="210" placeholder="176" /></div>
<div class="pf-row"><label>体重(kg)</label><input id="pf-weight" type="number" min="40" max="150" placeholder="76" /></div>
</div>
<div class="pf-row"><label>穿搭目标(逗号分隔)</label><input id="pf-goal" type="text" placeholder="显年轻, 带一点时尚感, 不能装嫩, 参考40岁状态" /></div>
<div class="pf-row"><label>风格偏好(逗号分隔)</label><input id="pf-style" type="text" placeholder="工业风, 极简, 低logo, 成熟克制, 偏质感" /></div>
</div>
<div class="ydp-dialog-footer">
<button class="primary" id="yp-profile-save">保存</button>
</div>
</div>
</div>
<script src="/tools/yuanzhupai/yuanzhupai.js?v=3"></script>
</body>
</html>
+201
View File
@@ -0,0 +1,201 @@
:root {
--bg: #060b18;
--surface: #0d1526;
--surface-2: #121e36;
--surface-3: #192845;
--gold: #c9a84c;
--gold-lt: #e8c96a;
--gold-dk: #7a6330;
--text: #ddd8cc;
--muted: #6b7a99;
--dim: #3a4560;
--green: #22c55e;
--red: #ef4444;
--amber: #f59e0b;
--blue: #3b82f6;
--border: rgba(201,168,76,.14);
--border-md: rgba(201,168,76,.32);
--r: 12px;
--r-sm: 6px;
}
*{margin:0;padding:0;box-sizing:border-box;}
body{background:var(--bg);color:var(--text);font-family:'Noto Sans SC',sans-serif;min-height:100vh;overflow-x:hidden;}
.hd{display:flex;align-items:center;justify-content:space-between;padding:14px 28px;border-bottom:1px solid var(--border);background:var(--surface);position:sticky;top:0;z-index:200;}
.hd-brand{display:flex;align-items:center;gap:11px;}
.hd-emblem{width:40px;height:40px;border-radius:10px;background:linear-gradient(135deg,var(--gold),var(--gold-dk));display:flex;align-items:center;justify-content:center;font-family:'Noto Serif SC',serif;font-size:20px;font-weight:700;color:#060b18;letter-spacing:-.5px;}
.hd-title{font-family:'Noto Serif SC',serif;font-size:24px;font-weight:700;color:var(--gold-lt);letter-spacing:.08em;}
.hd-sub{font-size:13px;color:var(--muted);letter-spacing:.06em;margin-top:1px;}
.hd-search{display:flex;align-items:center;gap:8px;}
.hd-search-input{background:var(--surface-2);border:1px solid var(--border-md);border-radius:20px;padding:8px 18px;font-size:15px;color:var(--text);font-family:'JetBrains Mono',monospace;width:220px;outline:none;transition:border-color .2s;}
.hd-search-input:focus{border-color:var(--gold);}
.hd-version{background:var(--surface-2);border:1px solid var(--border-md);border-radius:20px;padding:7px 14px;font-size:13px;color:var(--gold);font-family:'Noto Sans SC',sans-serif;outline:none;cursor:pointer;transition:border-color .2s;appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='%23c9a84c'%3E%3Cpath d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:32px;}
.hd-version:hover{border-color:var(--gold);}
.hd-version:focus{border-color:var(--gold-lt);}
.hd-search-btn{background:rgba(201,168,76,.1);border:1px solid var(--border-md);border-radius:20px;padding:8px 18px;color:var(--gold);font-size:14px;cursor:pointer;font-weight:600;transition:all .15s;white-space:nowrap;}
.hd-search-btn:hover{background:rgba(201,168,76,.2);}
.hd-search-btn:disabled{opacity:.5;cursor:default;}
.hd-ticker{display:flex;flex-direction:column;align-items:center;}
.hd-code{font-family:'JetBrains Mono',monospace;font-size:16px;color:var(--gold);font-weight:500;}
.hd-fname{font-size:13px;color:var(--muted);}
.hd-price-block{text-align:right;}
.hd-price{font-family:'JetBrains Mono',monospace;font-size:30px;font-weight:400;color:var(--text);}
.hd-chg{font-family:'JetBrains Mono',monospace;font-size:14px;}
.hd-chg.up{color:var(--green);}
.hd-chg.dn{color:var(--red);}
.hd-right{display:flex;flex-direction:column;align-items:flex-end;gap:5px;}
.live-badge{background:rgba(201,168,76,.1);border:1px solid var(--border-md);border-radius:20px;padding:4px 14px;font-size:13px;color:var(--gold);letter-spacing:.04em;display:flex;align-items:center;gap:6px;}
.live-dot{width:6px;height:6px;border-radius:50%;background:var(--gold);animation:blink 1.4s infinite;}
@keyframes blink{0%,100%{opacity:1;}50%{opacity:.25;}}
.hd-date{font-family:'JetBrains Mono',monospace;font-size:13px;color:var(--muted);}
.layout{display:grid;grid-template-columns:236px 560px 1fr;min-height:calc(100vh - 67px);padding-bottom:56px;}
.sb{padding:20px 14px;border-right:1px solid var(--border);overflow-y:auto;}
.card{background:var(--surface-2);border:1px solid var(--border);border-radius:var(--r);padding:15px;margin-bottom:14px;}
.co-name{font-family:'Noto Serif SC',serif;font-size:20px;font-weight:600;color:var(--text);}
.co-sector{font-size:13px;color:var(--muted);margin-bottom:11px;}
.s-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.03);}
.s-row:last-child{border-bottom:none;}
.s-lbl{font-size:13px;color:var(--muted);}
.s-val{font-family:'JetBrains Mono',monospace;font-size:14px;color:var(--text);font-weight:500;}
.s-val.gn{color:var(--green);}
.s-val.gd{color:var(--gold);}
.sb-h{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:14px 0 7px;padding-left:2px;}
.con-bar{display:flex;gap:3px;border-radius:3px;overflow:hidden;margin-bottom:7px;}
.con-seg{height:7px;border-radius:1px;}
.con-seg.b{background:var(--green);}
.con-seg.h{background:var(--amber);}
.con-seg.s{background:var(--red);}
.con-lbls{display:flex;justify-content:space-between;}
.c-lbl{display:flex;align-items:center;gap:3px;font-size:12px;color:var(--muted);}
.c-dot{width:5px;height:5px;border-radius:50%;}
.c-dot.b{background:var(--green);}
.c-dot.h{background:var(--amber);}
.c-dot.s{background:var(--red);}
.tp-row{display:flex;justify-content:space-between;align-items:center;padding:4px 0;}
.prog-item{display:flex;align-items:center;gap:7px;padding:4px 0;cursor:pointer;transition:opacity .2s;}
.prog-item:hover{opacity:1!important;}
.prog-av{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;flex-shrink:0;}
.prog-name{font-size:13px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.prog-area{font-size:12px;color:var(--dim);}
.prog-vrd{font-size:12px;font-weight:600;white-space:nowrap;}
.ta{display:flex;flex-direction:column;align-items:center;justify-content:flex-start;padding:24px 16px 16px;}
.ta-hint{font-family:'Noto Serif SC',serif;font-size:15px;color:var(--muted);letter-spacing:.1em;margin-bottom:18px;}
.tc-wrap{position:relative;width:516px;height:516px;flex-shrink:0;}
.ring-outer{position:absolute;inset:0;border-radius:50%;border:1px dashed rgba(201,168,76,.08);}
.ring-mid{position:absolute;inset:80px;border-radius:50%;border:1px dashed rgba(201,168,76,.06);}
.ts{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:232px;height:232px;border-radius:50%;background:radial-gradient(circle at 38% 35%,#192845,#090f1e);border:1.5px solid var(--border-md);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:2;box-shadow:0 0 80px rgba(201,168,76,.04),inset 0 0 60px rgba(0,0,0,.6);}
@keyframes ts-pulse{0%{box-shadow:0 0 0 0 rgba(201,168,76,.12),inset 0 0 60px rgba(0,0,0,.6);}70%{box-shadow:0 0 0 14px rgba(201,168,76,0),inset 0 0 60px rgba(0,0,0,.6);}100%{box-shadow:0 0 0 0 rgba(201,168,76,0),inset 0 0 60px rgba(0,0,0,.6);}}
.ts{animation:ts-pulse 3.2s ease-out infinite;}
.ts-code{font-family:'JetBrains Mono',monospace;font-size:14px;color:var(--gold);letter-spacing:.1em;}
.ts-name{font-family:'Noto Serif SC',serif;font-size:19px;font-weight:600;color:var(--text);margin:2px 0 6px;}
.ts-price{font-family:'JetBrains Mono',monospace;font-size:31px;font-weight:300;color:var(--gold-lt);}
.ts-chg{font-family:'JetBrains Mono',monospace;font-size:14px;color:var(--green);margin-top:2px;}
.seat{position:absolute;display:flex;flex-direction:column;align-items:center;gap:5px;cursor:pointer;z-index:10;transition:transform .18s ease;}
.seat:hover{transform:scale(1.1)!important;}
.seat:hover .seat-av{border-color:var(--gold);box-shadow:0 0 18px rgba(201,168,76,.45);}
.seat.active .seat-av{border-color:var(--gold-lt);box-shadow:0 0 22px rgba(201,168,76,.6);}
.seat-av{width:50px;height:50px;border-radius:50%;border:2px solid var(--border-md);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;font-family:'Noto Sans SC',sans-serif;transition:all .18s;position:relative;}
.seat-loading{position:absolute;inset:-3px;border-radius:50%;border:2px solid transparent;border-top-color:var(--gold);animation:spin .8s linear infinite;display:none;}
.seat.loading .seat-loading{display:block;}
.seat.loading .seat-badge{display:none;}
@keyframes spin{to{transform:rotate(360deg);}}
.seat-badge{position:absolute;bottom:-3px;right:-3px;width:16px;height:16px;border-radius:50%;font-size:10px;display:flex;align-items:center;justify-content:center;border:1.5px solid var(--bg);}
.sb-buy{background:var(--green);color:#fff;}
.sb-hold{background:var(--amber);color:#fff;}
.sb-sell{background:var(--red);color:#fff;}
.seat-nm{font-size:13px;font-weight:600;color:var(--text);white-space:nowrap;}
.seat-rl{font-size:12px;color:var(--muted);white-space:nowrap;max-width:80px;overflow:hidden;text-overflow:ellipsis;text-align:center;}
@keyframes seat-in{from{opacity:0;transform:scale(.4);}to{opacity:1;transform:scale(1);}}
.ceo-bar{display:flex;flex-direction:column;align-items:center;gap:5px;margin-top:14px;}
.ceo-av{width:56px;height:56px;border-radius:50%;background:linear-gradient(135deg,var(--gold),var(--gold-dk));display:flex;align-items:center;justify-content:center;font-weight:700;font-size:17px;color:#060b18;border:2px solid var(--gold);box-shadow:0 0 28px rgba(201,168,76,.3);font-family:'Noto Serif SC',serif;}
.ceo-lbl{font-size:14px;font-weight:600;color:var(--gold);letter-spacing:.04em;}
.ceo-sub{font-size:12px;color:var(--muted);}
.ceo-arr{font-size:18px;color:var(--border-md);margin-bottom:6px;line-height:1;}
.rp{border-left:1px solid var(--border);overflow-y:auto;background:var(--surface);}
::-webkit-scrollbar{width:3px;}
::-webkit-scrollbar-track{background:transparent;}
::-webkit-scrollbar-thumb{background:var(--border-md);border-radius:3px;}
.rp-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:10px;padding:48px 32px;text-align:center;}
.rp-empty-glyph{font-size:56px;opacity:.12;font-family:'Noto Serif SC',serif;}
.rp-empty-h{font-family:'Noto Serif SC',serif;font-size:19px;color:var(--text);font-weight:600;line-height:1.5;}
.rp-empty-p{font-size:15px;color:var(--muted);line-height:1.7;margin-top:6px;}
.rp-content{padding:22px;display:none;}
.rp-content.show{display:block;}
.rp-hd{margin-bottom:18px;padding-bottom:15px;border-bottom:1px solid var(--border);}
.rp-analyst-row{display:flex;align-items:center;gap:10px;margin-bottom:10px;}
.rp-av{width:42px;height:42px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:15px;border:2px solid var(--border-md);}
.rp-nm{font-weight:600;font-size:17px;color:var(--text);}
.rp-tl{font-size:13px;color:var(--muted);}
.rp-title{font-family:'Noto Serif SC',serif;font-size:21px;font-weight:600;color:var(--text);line-height:1.45;margin-bottom:8px;}
.rp-badges{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
.vb{padding:4px 14px;border-radius:20px;font-size:13px;font-weight:700;letter-spacing:.04em;}
.vb.buy{background:rgba(34,197,94,.12);color:var(--green);border:1px solid rgba(34,197,94,.28);}
.vb.hold{background:rgba(245,158,11,.12);color:var(--amber);border:1px solid rgba(245,158,11,.28);}
.vb.sell{background:rgba(239,68,68,.12);color:var(--red);border:1px solid rgba(239,68,68,.28);}
.sb2{font-family:'JetBrains Mono',monospace;font-size:13px;color:var(--gold);background:rgba(201,168,76,.09);padding:4px 10px;border-radius:20px;border:1px solid var(--border-md);}
.exec-sum{background:var(--surface-2);border-radius:8px;padding:14px 16px;margin-bottom:16px;border-left:3px solid;}
.sec-h{font-size:13px;text-transform:uppercase;letter-spacing:.1em;color:var(--gold);font-weight:600;margin-bottom:7px;}
.sec-body{font-size:15px;line-height:1.85;color:var(--text);}
.sec-body pre{white-space:pre-wrap;font-family:inherit;margin:0;}
.sec{margin-bottom:18px;}
.rp-loading{display:flex;align-items:center;justify-content:center;gap:8px;padding:24px;color:var(--muted);font-size:15px;}
.rp-error{color:var(--red);font-size:14px;padding:14px;background:rgba(239,68,68,.08);border-radius:8px;margin-bottom:12px;}
.bot{position:fixed;bottom:0;left:0;right:0;background:var(--surface);border-top:1px solid var(--border);padding:9px 24px;display:flex;align-items:center;gap:20px;z-index:200;}
.bot-lbl{font-size:13px;color:var(--muted);white-space:nowrap;}
.bot-chips{display:flex;gap:5px;overflow-x:auto;flex:1;scrollbar-width:none;}
.bot-chips::-webkit-scrollbar{display:none;}
.chip{display:flex;align-items:center;gap:4px;padding:4px 12px;border-radius:20px;border:1px solid var(--border);font-size:13px;background:var(--surface-2);cursor:pointer;white-space:nowrap;transition:all .14s;color:var(--text);}
.chip:hover{border-color:var(--border-md);}
.chip.active{border-color:var(--gold);background:rgba(201,168,76,.08);color:var(--gold);}
.chip-d{width:5px;height:5px;border-radius:50%;}
.chip.loading{opacity:.5;pointer-events:none;}
.qwen-status{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;color:var(--gold);}
.modal{position:fixed;inset:0;background:rgba(0,0,0,.65);display:none;align-items:center;justify-content:center;z-index:999;}
.modal.show{display:flex;}
.modal-body{background:var(--surface);border:1px solid var(--border-md);border-radius:var(--r);width:min(700px,94vw);max-height:80vh;overflow-y:auto;}
.ydp-dialog-head{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--border);}
.ydp-dialog-head h3{font-family:'Noto Serif SC',serif;font-size:18px;color:var(--gold-lt);}
.ydp-dialog-close{background:none;border:none;color:var(--muted);font-size:20px;cursor:pointer;padding:2px 6px;}
.ydp-dialog-close:hover{color:var(--text);}
.ydp-dialog-body{padding:16px 18px;max-height:60vh;overflow-y:auto;}
.ydp-dialog-body .log-line{font-family:'JetBrains Mono',monospace;font-size:13px;padding:3px 0;color:var(--muted);border-bottom:1px solid rgba(255,255,255,.02);}
.ydp-dialog-body .log-line.ok{color:var(--green);}
.ydp-dialog-body .log-line.err{color:var(--red);}
.ydp-dialog-body .log-line.info{color:var(--gold);}
.ydp-dialog-footer{padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;}
.ydp-dialog-footer button{background:var(--surface-2);border:1px solid var(--border);border-radius:var(--r-sm);padding:8px 18px;color:var(--text);font-size:13px;cursor:pointer;}
.ydp-dialog-footer button:hover{background:var(--surface-3);}
.ydp-dialog-footer button.primary{background:rgba(201,168,76,.1);border-color:var(--border-md);color:var(--gold);}
.stock-empty{text-align:center;padding:48px;color:var(--muted);}
.stock-empty-h{font-family:'Noto Serif SC',serif;font-size:21px;color:var(--text);margin-bottom:8px;}
.stock-empty-p{font-size:15px;}
.image-bar{padding:8px 28px;background:var(--surface);border-bottom:1px solid var(--border);}
.image-bar-inner{display:flex;align-items:center;gap:24px;}
.image-upload-area{display:flex;align-items:center;gap:10px;}
.image-upload-btn{background:var(--surface-2);border:1px solid var(--border-md);border-radius:20px;padding:6px 16px;font-size:13px;color:var(--gold);cursor:pointer;transition:all .15s;}
.image-upload-btn:hover{background:rgba(201,168,76,.15);}
.image-preview-wrap{position:relative;display:inline-flex;}
.image-preview-wrap img{width:40px;height:40px;border-radius:6px;border:1px solid var(--border-md);object-fit:cover;}
.image-remove-btn{position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;background:var(--red);color:#fff;border:none;font-size:10px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1;}
.profile-edit-area{display:flex;align-items:center;gap:10px;}
.profile-edit-btn{background:var(--surface-2);border:1px solid var(--border-md);border-radius:20px;padding:6px 16px;font-size:13px;color:var(--gold);cursor:pointer;transition:all .15s;}
.profile-edit-btn:hover{background:rgba(201,168,76,.15);}
.profile-summary{font-size:12px;color:var(--muted);}
.pf-row{padding:4px 18px;display:flex;align-items:center;gap:10px;}
.pf-row label{width:140px;font-size:13px;color:var(--muted);flex-shrink:0;}
.pf-row input{flex:1;background:var(--surface-2);border:1px solid var(--border);border-radius:6px;padding:6px 10px;font-size:13px;color:var(--text);outline:none;}
.pf-row input:focus{border-color:var(--gold);}
.pf-row2{display:flex;gap:12px;padding:4px 18px;}
.pf-row2 .pf-row{padding:4px 0;}
.pf-row2 .pf-row label{width:auto;min-width:60px;}
.pf-row2 .pf-row input{width:120px;flex:none;}
+845
View File
@@ -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 => ({ '&': '&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 }
})()