Files
sanguo_vnpy_v2/frontend/src/views/factor/UniversePicker.vue
T
claude_dev 557479b848
CI/CD / test (push) Successful in 3s
CI/CD / nas-deploy (push) Successful in 6s
CI/CD / nas-verify (push) Successful in 9s
feat(factor): New/Result两页终端风重构落地(方案B设计稿v2用户拍板'开工') [nas]
后端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)
2026-08-30 09:59:38 +08:00

241 lines
9.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>