557479b848
后端3小端点(sanguo_factor/universe_pools.py+routes):/factor/universe/pools
池清单(白名单6指数+当前成分数,in_current=1池语义≠回测并集口径)/pool/{key}
成分(code+name读constituent_unified)/search?q=(代码前缀OR名称子句DISTINCT
LIMIT10);cfg=None兜底同analyzer;+6单测+2端点测(⚠️首版误覆盖batch_eval的
universe.py,git恢复后改名universe_pools,292测全绿)
前端:①New.vue终端风重写——FactorPicker(搜索+三色分组点选+已选托盘,
240因子弃下拉)+UniversePicker(三层输入:预设池一键选[跨行业30前端常量+
6指数池走后端]/搜索combobox防抖250ms带名称候选/chips池可删+复制+清空,
批量粘贴折叠兜底)+原生date input禁未来+跨度显示+校验前置(禁用+原因文案);
要素零变化(query.factor预填/hydrate回填/≥1因子+≥2标的/混合分隔)。
②Result.vue外壳——5格metric-strip(最优ICIR正红负绿/最优因子青)+IC明细
终端表格(右对齐tabular-nums,|t|≥2加粗,最优因子行青标+左青条)+tears区
tabs终端化(TearsPanel本体不动)。③factorSamples.ts常量:跨行业30只
(code+name)详情页一键直达与New页预设共用同源(LeaderboardDetail改引,
删本地重复清单)。npm run build绿(vue-tsc+rolldown)
241 lines
9.8 KiB
Vue
241 lines
9.8 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 标的池三层输入(设计稿 v2 拍板): 预设池一键选 + 搜索加自选 + chips 已选池。
|
||
* 批量粘贴收进折叠兜底入口。v-model = 代码数组;名称仅显示,组件内部管理。
|
||
*/
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import {
|
||
getUniversePools, getUniversePool, searchUniverse,
|
||
type UniversePool, type UniverseStock,
|
||
} from '@/api/factor'
|
||
import { TEARS_SAMPLE } from '@/constants/factorSamples'
|
||
|
||
const props = defineProps<{ modelValue: string[] }>()
|
||
const emit = defineEmits<{ (e: 'update:modelValue', v: string[]): void }>()
|
||
|
||
// code -> name(仅显示;批量粘贴的未知代码回退显示代码本身)
|
||
const names = ref<Record<string, string>>({})
|
||
const pool = ref<Map<string, string>>(new Map())
|
||
const codes = computed(() => [...pool.value.keys()])
|
||
|
||
function sync(): void {
|
||
for (const [c, n] of pool.value) names.value[c] = n
|
||
emit('update:modelValue', codes.value)
|
||
}
|
||
|
||
function addAll(stocks: UniverseStock[]): void {
|
||
for (const s of stocks) pool.value.set(s.code, s.name || s.code)
|
||
activeKey.value = ''
|
||
sync()
|
||
}
|
||
|
||
// —— ① 预设池 ——
|
||
const pools = ref<UniversePool[]>([])
|
||
const activeKey = ref('')
|
||
const loadingPool = ref('')
|
||
|
||
onMounted(async () => {
|
||
// 跨行业 30 只为前端常量;指数池从后端读清单
|
||
pools.value = [{ key: 'cross30', name: '跨行业 30 只', count: TEARS_SAMPLE.length }]
|
||
try {
|
||
const remote = await getUniversePools()
|
||
pools.value = [...pools.value, ...remote]
|
||
} catch {
|
||
ElMessage.warning('指数池清单加载失败(可继续用搜索/粘贴)')
|
||
}
|
||
})
|
||
|
||
async function loadPool(key: string): Promise<void> {
|
||
if (key === 'cross30') {
|
||
addAll(TEARS_SAMPLE)
|
||
activeKey.value = key
|
||
return
|
||
}
|
||
loadingPool.value = key
|
||
try {
|
||
const stocks = await getUniversePool(key)
|
||
if (!stocks.length) {
|
||
ElMessage.warning('该池无当前成分数据')
|
||
return
|
||
}
|
||
addAll(stocks)
|
||
activeKey.value = key
|
||
} catch {
|
||
ElMessage.error('池成分加载失败')
|
||
} finally {
|
||
loadingPool.value = ''
|
||
}
|
||
}
|
||
|
||
// —— ② 搜索 combobox ——
|
||
const q = ref('')
|
||
const cand = ref<UniverseStock[]>([])
|
||
const candOpen = ref(false)
|
||
let timer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
function onQuery(): void {
|
||
if (timer) clearTimeout(timer)
|
||
const v = q.value.trim()
|
||
if (!v) {
|
||
candOpen.value = false
|
||
return
|
||
}
|
||
timer = setTimeout(async () => {
|
||
try {
|
||
cand.value = await searchUniverse(v)
|
||
candOpen.value = true
|
||
} catch {
|
||
candOpen.value = false
|
||
}
|
||
}, 250)
|
||
}
|
||
|
||
function pick(s: UniverseStock): void {
|
||
if (pool.value.has(s.code)) return
|
||
pool.value.set(s.code, s.name || s.code)
|
||
activeKey.value = ''
|
||
q.value = ''
|
||
candOpen.value = false
|
||
sync()
|
||
}
|
||
|
||
// —— ③ chips 池管理 ——
|
||
function removeOne(code: string): void {
|
||
pool.value.delete(code)
|
||
activeKey.value = ''
|
||
sync()
|
||
}
|
||
|
||
function clearAll(): void {
|
||
pool.value.clear()
|
||
activeKey.value = ''
|
||
sync()
|
||
}
|
||
|
||
async function copyList(): Promise<void> {
|
||
if (!codes.value.length) return
|
||
try {
|
||
await navigator.clipboard.writeText(codes.value.join('\n'))
|
||
ElMessage.success(`已复制 ${codes.value.length} 个代码`)
|
||
} catch {
|
||
ElMessage.warning('复制失败(浏览器限制),可用批量粘贴框查看')
|
||
}
|
||
}
|
||
|
||
// —— 批量粘贴兜底 ——
|
||
const bulkOpen = ref(false)
|
||
const bulkText = ref('')
|
||
|
||
function parseBulk(): void {
|
||
const items = bulkText.value.split(/[\s,,]+/).map((s) => s.trim()).filter(Boolean)
|
||
if (!items.length) return
|
||
for (const c of items) {
|
||
if (!pool.value.has(c)) pool.value.set(c, names.value[c] || c)
|
||
}
|
||
activeKey.value = ''
|
||
bulkText.value = ''
|
||
sync()
|
||
}
|
||
|
||
// —— 外部初始值(父传入默认 codes;名称可缺省) ——
|
||
onMounted(() => {
|
||
for (const c of props.modelValue) {
|
||
if (!pool.value.has(c)) pool.value.set(c, names.value[c] || c)
|
||
}
|
||
sync()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="up">
|
||
<div class="poolrow">
|
||
<span class="plab">预设池</span>
|
||
<button
|
||
v-for="p in pools" :key="p.key" type="button" class="pool"
|
||
:class="{ on: activeKey === p.key }" :disabled="loadingPool === p.key"
|
||
@click="loadPool(p.key)"
|
||
>{{ p.name }} <span class="pc">{{ p.count }}</span></button>
|
||
</div>
|
||
|
||
<div class="symsearch">
|
||
<input
|
||
v-model="q" type="text" spellcheck="false" autocomplete="off"
|
||
placeholder="搜索代码 / 名称加入自选,如 600519 或 茅台 …"
|
||
@input="onQuery" @keydown.esc="candOpen = false" @focus="q && (candOpen = true)"
|
||
>
|
||
<div v-if="candOpen" class="cand">
|
||
<template v-if="cand.length">
|
||
<div
|
||
v-for="s in cand" :key="s.code" class="ci" :class="{ sel: pool.has(s.code) }"
|
||
@mousedown.prevent="pick(s)"
|
||
><span class="cc">{{ s.code }}</span><span class="cn">{{ s.name }}</span><span v-if="pool.has(s.code)" class="in">已选</span></div>
|
||
</template>
|
||
<div v-else class="none">无匹配标的</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="poolbox">
|
||
<span v-if="!codes.length" class="empty">用上方预设池或搜索加入标的(≥2 只)</span>
|
||
<span v-for="c in codes" :key="c" class="schip">
|
||
<span class="code">{{ c }}</span><span class="nm">{{ names[c] }}</span>
|
||
<span class="x" title="移除" @click="removeOne(c)">✕</span>
|
||
</span>
|
||
</div>
|
||
|
||
<div class="meta">
|
||
<span class="badge">{{ codes.length }} 只</span>
|
||
<span class="hint">IC 横截面需 ≥2 只;截面排名类因子(cs_rank 族)建议 300+ 只才不失真</span>
|
||
<button type="button" class="mini" @click="bulkOpen = !bulkOpen">{{ bulkOpen ? '收起粘贴' : '批量粘贴' }}</button>
|
||
<button type="button" class="mini" @click="copyList">复制清单</button>
|
||
<button type="button" class="mini warn" @click="clearAll">清空</button>
|
||
</div>
|
||
|
||
<div v-if="bulkOpen" class="bulkwrap">
|
||
<textarea v-model="bulkText" spellcheck="false" placeholder="粘贴一列股票代码(空格 / 逗号 / 换行混合均可),点「解析加入」合并进已选池" />
|
||
<div class="bulkact"><button type="button" class="mini" @click="parseBulk">解析加入</button></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.up { width: 100%; }
|
||
.poolrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
||
.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 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); }
|
||
.pool:disabled { opacity: .5; cursor: wait; }
|
||
.pool .pc { color: var(--text-3); font-size: 10px; margin-left: 2px; }
|
||
.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); }
|
||
.symsearch input:focus { outline: 1px solid rgba(0,229,255,.5); }
|
||
.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: var(--shadow); max-height: 264px; overflow-y: auto; }
|
||
.ci { display: flex; align-items: center; gap: 10px; padding: 6px 10px; cursor: pointer; border-bottom: 1px solid var(--border-2); }
|
||
.ci:last-child { border-bottom: none; }
|
||
.ci:hover { background: var(--bg-hover); }
|
||
.ci .cc { font-family: var(--mono); font-size: 12px; color: var(--brand); width: 64px; }
|
||
.ci .cn { font-size: 12px; color: var(--text); }
|
||
.ci .in { font-size: 10.5px; color: var(--text-3); margin-left: auto; }
|
||
.ci.sel { opacity: .45; cursor: default; }
|
||
.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; }
|
||
.empty { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); }
|
||
.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); }
|
||
.meta { display: flex; align-items: center; gap: 10px; margin-top: 8px; flex-wrap: wrap; }
|
||
.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; }
|
||
.hint { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); }
|
||
.mini { font-family: var(--mono); font-size: 10.5px; padding: 3px 10px; border-radius: var(--r-sm); border: 1px dashed var(--border); background: transparent; color: var(--text-2); cursor: pointer; }
|
||
.mini:hover { color: var(--brand); border-color: rgba(0,229,255,.4); }
|
||
.mini.warn:hover { color: var(--danger); border-color: rgba(255,77,94,.4); }
|
||
.bulkwrap { margin-top: 10px; }
|
||
.bulkwrap textarea { 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; }
|
||
.bulkwrap textarea:focus { outline: 1px solid rgba(0,229,255,.5); }
|
||
.bulkact { margin-top: 8px; }
|
||
</style>
|