docs(factor): 设计稿v2——标的池textarea改三层输入(用户反馈'太原始') [nas]
CI/CD / test (push) Successful in 1s
CI/CD / nas-deploy (push) Successful in 6s
CI/CD / nas-verify (push) Successful in 8s

参考聚宽/万得选股器+收件人式tags input,把'手打代码'变'点选':
①预设池一键选:跨行业30/沪深300/中证500/1000/2000/全A主板+创业板(cs_rank族
因子300+只才不失真,手贴不现实)
②搜索加自选:代码/名称模糊→候选下拉(代码·名称·行业·已选标记)点选加入
③chips已选池:600519贵州茅台✕可删标签+计数徽标+复制清单+清空;
批量粘贴收进折叠兜底入口(能力保留不再是主入口)
配套说明:后端3小端点(池清单/池成分读constituent_unified/标的搜索),
New.vue拆UniversePicker.vue组件,预估1天
Chrome实测:池载入54只+按钮点亮/搜茅台候选带已选标/chips渲染/校验联动全通
This commit is contained in:
2026-08-30 09:48:09 +08:00
parent d1d48896a9
commit 271adedbb5
@@ -105,7 +105,31 @@ input:focus-visible,textarea:focus-visible{outline:1px solid rgba(0,229,255,.5);
.picked .clear:hover{color:var(--danger)}
/* ===== 标的池 / 日期 ===== */
.sym-area{width:100%;min-height:128px;background:var(--panel);border:1px solid var(--border);border-radius:var(--r-sm);color:var(--text);font-family:var(--mono);font-size:12px;line-height:1.8;padding:8px 10px;resize:vertical}
.poolrow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px}
.poolrow .plab{font-family:var(--mono);font-size:10.5px;color:var(--text-3);letter-spacing:.1em}
.pool{font-family:var(--mono);font-size:11px;padding:3px 12px;border-radius:var(--r-sm);border:1px solid var(--border);background:var(--panel);color:var(--text-2);cursor:pointer;transition:all .12s var(--ease)}
.pool:hover{color:var(--brand);border-color:rgba(0,229,255,.4)}
.pool.on{color:var(--brand);border-color:rgba(0,229,255,.45);background:var(--cyan-soft)}
.symsearch{position:relative;margin-bottom:10px}
.symsearch input{width:100%;background:var(--panel);border:1px solid var(--border);border-radius:var(--r-sm);color:var(--text);font-family:var(--mono);font-size:12px;padding:6px 10px}
.symsearch input::placeholder{color:var(--text-3)}
.cand{position:absolute;top:calc(100% + 3px);left:0;right:0;z-index:30;background:var(--bg-overlay);border:1px solid var(--border);border-radius:var(--r-sm);box-shadow:0 8px 32px rgba(0,0,0,.6);max-height:264px;overflow-y:auto}
.cand .ci{display:flex;align-items:center;gap:10px;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--border-2)}
.cand .ci:last-child{border-bottom:none}
.cand .ci:hover{background:var(--bg-hover)}
.cand .ci .cc{font-family:var(--mono);font-size:12px;color:var(--brand);width:64px}
.cand .ci .cn{font-size:12px;color:var(--text)}
.cand .ci .in{font-size:10.5px;color:var(--text-3);margin-left:auto}
.cand .ci.sel{opacity:.45;cursor:default}
.cand .none{padding:8px 10px;color:var(--text-3);font-size:11.5px}
.poolbox{display:flex;flex-wrap:wrap;gap:5px;border:1px dashed var(--border);border-radius:var(--r-md);background:var(--panel);padding:9px 10px;min-height:44px;max-height:180px;overflow-y:auto}
.schip{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:11px;border:1px solid var(--border);border-radius:var(--r-sm);padding:2px 8px;background:var(--bg-card)}
.schip .code{color:var(--brand)}
.schip .nm{color:var(--text-2);font-family:var(--sans);font-size:11px}
.schip .x{cursor:pointer;color:var(--text-3)}
.schip .x:hover{color:var(--danger)}
.bulkwrap{margin-top:10px}
.sym-area{width:100%;min-height:96px;background:var(--panel);border:1px solid var(--border);border-radius:var(--r-sm);color:var(--text);font-family:var(--mono);font-size:12px;line-height:1.8;padding:8px 10px;resize:vertical}
.sym-area::placeholder{color:var(--text-3)}
.sym-meta{display:flex;align-items:center;gap:10px;margin-top:8px;flex-wrap:wrap}
.sym-badge{font-family:var(--mono);font-size:10.5px;color:var(--brand);border:1px solid rgba(0,229,255,.3);background:var(--cyan-soft);border-radius:var(--r-sm);padding:2px 8px}
@@ -179,7 +203,7 @@ input:focus-visible,textarea:focus-visible{outline:1px solid rgba(0,229,255,.5);
<!-- ================= ① 提交页 ================= -->
<section id="tab-new" class="tab on">
<div class="dcap"><span class="no">N1</span><span class="txt"><b>提交页</b>:自选因子 × 自选标的池 × 自选区间的自由实验台。终端风替换 Element 表单——因子改为「搜索 + 分组点选」面板(240 个因子下拉翻页不便)。</span></div>
<div class="dcap"><span class="no">N1</span><span class="txt"><b>提交页</b>:自选因子 × 自选标的池 × 自选区间的自由实验台。终端风替换 Element 表单——因子改为「搜索 + 分组点选」面板(240 个因子下拉翻页不便)<b>标的池改三层输入:预设池一键选 + 搜索加自选 + chips 已选池</b>(批量粘贴降级为折叠兜底)</span></div>
<div class="mock flash">
<span class="backlink">← 投研 / 因子分析</span>
<div class="page-head">
@@ -214,18 +238,34 @@ input:focus-visible,textarea:focus-visible{outline:1px solid rgba(0,229,255,.5);
<div class="formrow">
<div class="flab">标的池</div>
<div class="fbody">
<textarea class="sym-area" id="symarea" spellcheck="false" placeholder="每行一个标的代码(≥2,IC 横截面需多标的);支持空格 / 逗号 / 换行混合分隔">600519
000858
600036
601318
000001
600000
300750
002594</textarea>
<!-- ① 预设池一键选 -->
<div class="poolrow">
<span class="plab">预设池</span>
<button class="pool" data-pool="cross30">跨行业 30 只</button>
<button class="pool" data-pool="hs300">沪深300</button>
<button class="pool" data-pool="zz500">中证500</button>
<button class="pool" data-pool="zz1000">中证1000</button>
<button class="pool" data-pool="zz2000">中证2000</button>
<button class="pool" data-pool="allmain">全A主板+创业板</button>
</div>
<!-- ② 搜索加自选 -->
<div class="symsearch">
<input id="symq" type="text" spellcheck="false" autocomplete="off" placeholder="搜索代码 / 名称加入自选,如 600519 或 茅台 …">
<div class="cand" id="cand" hidden></div>
</div>
<!-- ③ 已选池 chips -->
<div class="poolbox" id="poolbox"><span class="hint-inline" id="poolempty">用上方预设池或搜索加入标的(≥2 只)</span></div>
<div class="sym-meta">
<span class="sym-badge" id="symbadge">8</span>
<span class="hint-inline">IC 横截面统计需要 ≥2 只;截面排名类因子(cs_rank 族)建议 300+ 只才不失真</span>
<button class="preset" id="sympool">载入:跨行业 30 只样本</button>
<span class="sym-badge" id="symbadge">0</span>
<span class="hint-inline">IC 横截面 ≥2 只;截面排名类因子(cs_rank 族)建议 300+ 只才不失真</span>
<button class="preset" id="bulktoggle">批量粘贴</button>
<button class="preset" id="copybtn">复制清单</button>
<span class="preset" id="poolclear" style="cursor:pointer">清空</span>
</div>
<!-- 批量粘贴(折叠兜底入口) -->
<div class="bulkwrap" id="bulkwrap" hidden>
<textarea class="sym-area" id="symarea" spellcheck="false" placeholder="粘贴一列股票代码(空格 / 逗号 / 换行混合均可),点「解析加入」合并进已选池"></textarea>
<div style="margin-top:8px"><button class="preset" id="bulkadd">解析加入</button></div>
</div>
</div>
</div>
@@ -304,14 +344,18 @@ input:focus-visible,textarea:focus-visible{outline:1px solid rgba(0,229,255,.5);
<h3 class="nh">范围</h3>
<ul class="ask">
<li><b>New.vue(提交页)</b> + <b>Result.vue 外壳(指标卡 + IC 明细表)</b> 两处终端风重构;<b>TearsPanel 不动</b>(已是新版终端风)。</li>
<li>要素零变化:因子多选按类分组 / 标的池 textarea 混合分隔 / 起止日期禁选未来 / 提交校验(≥1 因子 + ≥2 标的)/ query.factor 预填。</li>
<li>前端改造,零后端改动;沿用 tokens.css,与排行榜 / 详情 / 对比 / tears 同一套视觉语言</li>
<li>要素零变化:因子多选按类分组 / 标的池支持混合分隔批量粘贴 / 起止日期禁选未来 / 提交校验(≥1 因子 + ≥2 标的)/ query.factor 预填。</li>
<li>前端为主,沿用 tokens.css 与全站视觉语言;标的池三层输入需 3 个后端小端点(见下)</li>
</ul>
<h3 class="nh">提交页交互变化</h3>
<ul class="ask">
<li><b>因子选择从「多选下拉」改为「搜索 + 分组点选」面板</b>:240 个因子在下拉里翻页困难;点选即加入已选托盘,托盘 chips 可单删 / 清空,与排行榜「加入对比」托盘交互一致。</li>
<li>保留类别三色:alpha101 青 / alpha158 紫 / builtin 琥珀(同排行榜类别 chip)。</li>
<li>标的池:mono 字体 textarea + 实时计数徽标 + 快捷预设(跨行业 30 只,与详情页一键直达同一样本,方便复现)。</li>
<li><b>标的池从 textarea 改为三层输入</b>(参考聚宽/万得选股器 + 收件人式 tags input):<br>
<b>预设池一键选</b>——跨行业30 / 沪深300 / 中证500 / 中证1000 / 中证2000 / 全A主板+创业板,大样本一键载入(cs_rank 族因子要 300+ 只才不失真,手贴不现实);<br>
<b>搜索加自选</b>——输代码或名称,候选下拉带「代码 · 名称 · 行业」,点选加入;<br>
<b>chips 已选池</b>——可删标签 + 计数 + 复制清单 + 清空;批量粘贴收进折叠兜底入口(能力保留,不再是主入口)。</li>
<li>后端配套 3 个小端点:池清单(指数名+数量)/ 池成分(代码+名称,读 constituent_unified/ 标的搜索(代码前缀+名称子串);跨行业 30 只与详情页一键直达同一样本。</li>
<li>提交按钮在未满足校验时禁用并给出原因(替代提交后弹 warning)。</li>
</ul>
<h3 class="nh">结果页外壳变化</h3>
@@ -321,7 +365,7 @@ input:focus-visible,textarea:focus-visible{outline:1px solid rgba(0,229,255,.5);
</ul>
<h3 class="nh">实现拆分(预估)</h3>
<ul class="ask">
<li><b>New.vue</b>:因子面板组件 FactorPicker.vue(搜索 + 分组网格 + 已选托盘)+ 表单区终端化,约半天。</li>
<li><b>New.vue</b>:因子面板组件 FactorPicker.vue + 标的池组件 UniversePicker.vue(预设池 + 搜索 combobox + chips+ 后端 3 小端点,约 1 天。</li>
<li><b>Result.vue</b>:指标条 + 表格模板替换(数据逻辑不动),约 1–2 小时。</li>
<li>npm run build + push NAS 验收。</li>
</ul>
@@ -401,21 +445,118 @@ function refreshCounts(){
el.classList.toggle('sel-builtin',sel&&c==='builtin');
});
}
const symArea=document.getElementById('symarea');
function symCount(){return symArea.value.split(/[\s,]+/).map(s=>s.trim()).filter(Boolean).length;}
// —— 标的池三层:预设池 + 搜索 + chips ——
const STOCKS=[
['600519','贵州茅台','白酒'],['000858','五粮液','白酒'],['600036','招商银行','银行'],
['601318','中国平安','保险'],['000001','平安银行','银行'],['600000','浦发银行','银行'],
['300750','宁德时代','电池'],['002594','比亚迪','汽车'],['600276','恒瑞医药','医药'],
['000538','云南白药','医药'],['600887','伊利股份','食品'],['000651','格力电器','家电'],
['000333','美的集团','家电'],['600690','海尔智家','家电'],['000725','京东方A','面板'],
['002415','海康威视','安防'],['600030','中信证券','券商'],['601166','兴业银行','银行'],
['600028','中国石化','石化'],['601857','中国石油','石化'],['600585','海螺水泥','建材'],
['000625','长安汽车','汽车'],['601633','长城汽车','汽车'],['002352','顺丰控股','物流'],
['600104','上汽集团','汽车'],['601398','工商银行','银行'],['600941','中国移动','通信'],
['300059','东方财富','券商'],['002230','科大讯飞','AI'],['601888','中国中免','免税']
];
// 种子合成扩充候选池(形态示意)
(function(){
const pre=['华','中','新','东方','金','天','大','恒','瑞','联'];
const mid=['创','科','安','能','信','光','泰','海','正','宏'];
const suf=['电子','机械','能源','软件','生物','材料','环保','通信','电气','化工'];
const seen=new Set(STOCKS.map(s=>s[0]));
for(let i=0;STOCKS.length<96;i++){
let code=String(100000+Math.floor(rnd()*899999));
if(seen.has(code))continue;seen.add(code);
STOCKS.push([code,pre[Math.floor(rnd()*10)]+mid[Math.floor(rnd()*10)]+suf[Math.floor(rnd()*10)],suf[Math.floor(rnd()*10)]]);
}
})();
const POOLS={
cross30:{label:'跨行业 30 只',list:STOCKS.slice(0,30)},
hs300:{label:'沪深300',list:STOCKS.slice(4,54)},
zz500:{label:'中证500',list:STOCKS.slice(20,80)},
zz1000:{label:'中证1000',list:STOCKS.slice(30,96)},
zz2000:{label:'中证2000',list:STOCKS.slice(40,96)},
allmain:{label:'全A主板+创业板',list:STOCKS.slice()}
};
const symPool=new Map(); // code -> name
function renderPool(){
const box=document.getElementById('poolbox');
document.getElementById('symbadge').textContent=`${symPool.size}`;
if(!symPool.size){box.innerHTML='<span class="hint-inline" id="poolempty">用上方预设池或搜索加入标的(≥2 只)</span>';}
else{
box.innerHTML='';
for(const [c,n] of symPool){
const s=document.createElement('span');s.className='schip';
s.innerHTML=`<span class="code">${c}</span><span class="nm">${n}</span><span class="x" title="移除">✕</span>`;
s.querySelector('.x').onclick=()=>{symPool.delete(c);clearPoolOn();renderPool();};
box.appendChild(s);
}
}
validate();
}
function clearPoolOn(){document.querySelectorAll('.pool').forEach(b=>b.classList.remove('on'));}
document.querySelectorAll('.pool').forEach(b=>{
b.onclick=()=>{
const p=POOLS[b.dataset.pool];
p.list.forEach(s=>symPool.set(s[0],s[1]));
clearPoolOn();b.classList.add('on');renderPool();
};
});
document.getElementById('poolclear').onclick=()=>{symPool.clear();clearPoolOn();renderPool();};
// 搜索 combobox
const symq=document.getElementById('symq'),cand=document.getElementById('cand');
symq.addEventListener('input',()=>{
const v=symq.value.trim();
if(!v){cand.hidden=true;return;}
const hits=STOCKS.filter(s=>s[0].includes(v)||s[1].includes(v)).slice(0,10);
cand.hidden=false;
if(!hits.length){cand.innerHTML='<div class="none">无匹配标的</div>';return;}
cand.innerHTML='';
hits.forEach(s=>{
const d=document.createElement('div');d.className='ci'+(symPool.has(s[0])?' sel':'');
d.innerHTML=`<span class="cc">${s[0]}</span><span class="cn">${s[1]}</span><span class="in">${s[2]}</span>${symPool.has(s[0])?'<span class="in">已选</span>':''}`;
d.onclick=()=>{
if(symPool.has(s[0]))return;
symPool.set(s[0],s[1]);clearPoolOn();renderPool();
symq.value='';cand.hidden=true;symq.focus();
};
cand.appendChild(d);
});
});
symq.addEventListener('keydown',e=>{if(e.key==='Escape'){cand.hidden=true;}});
document.addEventListener('click',e=>{if(!e.target.closest('.symsearch'))cand.hidden=true;});
// 批量粘贴兜底 + 复制
document.getElementById('bulktoggle').onclick=()=>{
const w=document.getElementById('bulkwrap');w.hidden=!w.hidden;
document.getElementById('bulktoggle').textContent=w.hidden?'批量粘贴':'收起粘贴';
};
document.getElementById('bulkadd').onclick=()=>{
const codes=document.getElementById('symarea').value.split(/[\s,]+/).map(s=>s.trim()).filter(Boolean);
let added=0;
codes.forEach(c=>{
if(symPool.has(c))return;
const hit=STOCKS.find(s=>s[0]===c);
symPool.set(c,hit?hit[1]:c);added++;
});
clearPoolOn();renderPool();
};
document.getElementById('copybtn').onclick=()=>{
const txt=[...symPool.keys()].join('\n');
if(navigator.clipboard)navigator.clipboard.writeText(txt).then(()=>alert('已复制 '+symPool.size+' 个代码到剪贴板'));
else alert(txt);
};
function validate(){
const sc=symCount();
document.getElementById('symbadge').textContent=`${sc}`;
const sc=symPool.size;
const btn=document.getElementById('submitbtn'),note=document.getElementById('validnote');
if(!picked.size){btn.disabled=true;note.textContent='待办:至少选 1 个因子';}
else if(sc<2){btn.disabled=true;note.textContent='待办:标的池 ≥2 只';}
else{btn.disabled=false;note.textContent='';}
}
symArea.addEventListener('input',validate);
document.getElementById('sympool').onclick=()=>{
symArea.value=['600519','000858','600036','601318','000001','600000','300750','002594','600276','000538','600887','000651','000333','600690','000725','002415','600030','601166','600028','601857','600585','000625','601633','002352','600104','601398','600941','300059','002230','601888'].join('\n');
validate();
};
// 区间跨度
function span(){try{const a=new Date(dstart.value),b=new Date(dend.value);const d=Math.round((b-a)/864e5);document.getElementById('spannote').textContent=isFinite(d)?`跨度 ${d} 天 ≈ ${(d/365).toFixed(1)}`:'';}catch(e){}}
const dstart=document.getElementById('dstart'),dend=document.getElementById('dend');
@@ -454,7 +595,8 @@ document.querySelectorAll('.tears-tabs .tt').forEach(b=>{
});
// init
renderPicked();refreshCounts();validate();span();
STOCKS.slice(0,8).forEach(s=>symPool.set(s[0],s[1]));
renderPicked();refreshCounts();renderPool();span();
</script>
</body>
</html>