feat(web-ui): 股票代码输入支持拼音声母搜索(zjxc→中际旭创)

所有股票代码输入框(回测/寻优/组合页)支持代码/中文名/拼音声母三路
匹配的下拉联想。复用项目里早已有但从未被前端消费的 security_list_all
数据(沪深 A 股 5206 只完整中文名表)。

后端:
- 新增 GET /api/v1/security/search-index 端点,返回 [{code,name,initials}]
- 声母用 pypinyin FIRST_LETTER 预计算,进程内缓存,首次算一次常驻
- web extra 新增 pypinyin>=0.50 依赖

前端:
- 新增 useStockSearch composable(模块级缓存 + 三路过滤 + 防抖)
- 新增 StockSearchInput.vue(下拉 + 键盘导航 + 市场标签)
- SymbolPicker / StocksPicker 接入新组件,保留直接输 6 位代码的快路径

实测 zjxc→中际旭创、gzmt→贵州茅台、cjxc→楚江新材,5000 条本地过滤 <5ms
This commit is contained in:
Justin Gu
2026-07-06 01:43:57 +08:00
parent 044e21064c
commit e2bf29e931
9 changed files with 427 additions and 45 deletions
+9
View File
@@ -14,6 +14,7 @@ import type {
SavedStrategy,
SavedStrategyCreate,
SavedStrategyListResponse,
StockSearchIndex,
StrategiesResponse,
TaskListResponse,
TaskState,
@@ -102,6 +103,14 @@ export async function fetchBars(
return bars
}
/** 拉取股票搜索索引(code/name/initials,约 5000 条,~150KB)。
* 前端 useStockSearch 会模块级缓存,整个会话只拉一次。 */
export async function fetchSearchIndex(): Promise<StockSearchIndex> {
const resp = await fetch(`${BASE}/security/search-index`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as StockSearchIndex
}
/** 把后端 bars 的单条记录归一化为统一 Bardatetime 字段)。 */
function normalizeBar(row: Record<string, unknown>): Bar {
const raw = (row.datetime ?? row.date) as string | undefined
+241
View File
@@ -0,0 +1,241 @@
<script setup lang="ts">
// 股票搜索输入框:支持 6 位代码 / 中文名 / 拼音声母(如 zjxc→中际旭创)。
// 下拉建议 + 键盘导航(↑↓/Enter/Esc)。v-model 绑定 6 位代码。
// 数据源:useStockSearch composable(模块级缓存索引,整会话拉一次)。
import { computed, nextTick, ref, watch } from 'vue'
import { useStockSearch } from '../composables/useStockSearch'
import { detectMarket, marketLabel } from '../market'
import type { StockSearchEntry } from '../types'
const code = defineModel<string>({ default: '' })
const props = withDefaults(defineProps<{ placeholder?: string }>(), {
placeholder: '代码 / 拼音 / 名字',
})
const emit = defineEmits<{
/** 选中某只股票(code + name)时触发,供父组件做额外处理(如回填名称) */
select: [entry: StockSearchEntry]
/** 无下拉时按 Enter 触发(输入满 6 位代码的"确认"场景,供组合页接"添加" */
confirm: [code: string]
}>()
const { ready, loadError, search } = useStockSearch()
// 输入框文本(可能是代码片段、拼音、中文)。与 code 解耦:
// code 是最终选定的 6 位代码,inputText 是用户正在敲的内容
const inputText = ref(code.value)
const suggestions = ref<StockSearchEntry[]>([])
const showDropdown = ref(false)
const activeIndex = ref(-1) // 键盘高亮项,-1 表示不高亮
const inputRef = ref<HTMLInputElement | null>(null)
// 输入满 6 位纯数字 → 直接当成选定代码(保留"直接敲代码"的老习惯)
const isFullCode = computed(() => /^\d{6}$/.test(inputText.value.trim()))
// 智能识别的市场(用于提示展示)
const detectedMarket = computed(() =>
code.value && /^\d{6}$/.test(code.value) ? marketLabel(detectMarket(code.value)) : '',
)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
async function refreshSuggestions() {
const q = inputText.value.trim().toLowerCase()
// 满 6 位纯数字:清空下拉(已经是有效代码,无需搜索)
if (/^\d{6}$/.test(q)) {
suggestions.value = []
showDropdown.value = false
activeIndex.value = -1
return
}
if (!q || q.length < 1) {
suggestions.value = []
showDropdown.value = false
activeIndex.value = -1
return
}
if (!ready.value) return // 索引未就绪,等加载完再过滤
suggestions.value = await search(q, 30)
showDropdown.value = suggestions.value.length > 0
activeIndex.value = suggestions.value.length > 0 ? 0 : -1
}
watch(inputText, () => {
// 同步纯数字输入到 code(边敲代码边更新市场标签)
if (/^\d{6}$/.test(inputText.value.trim())) {
code.value = inputText.value.trim()
}
// 防抖 120ms
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(refreshSuggestions, 120)
})
function selectEntry(entry: StockSearchEntry) {
inputText.value = entry.code
code.value = entry.code
suggestions.value = []
showDropdown.value = false
activeIndex.value = -1
emit('select', entry)
inputRef.value?.focus()
}
function onKeydown(e: KeyboardEvent) {
if (!showDropdown.value || suggestions.value.length === 0) {
// 无下拉时,Enter 且输入是有效代码 → 通知父组件"确认"(如组合页添加标的)
if (e.key === 'Enter' && isFullCode.value) {
emit('confirm', code.value)
}
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = (activeIndex.value + 1) % suggestions.value.length
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value =
(activeIndex.value - 1 + suggestions.value.length) % suggestions.value.length
} else if (e.key === 'Enter') {
if (activeIndex.value >= 0 && activeIndex.value < suggestions.value.length) {
e.preventDefault()
selectEntry(suggestions.value[activeIndex.value])
}
} else if (e.key === 'Escape') {
showDropdown.value = false
activeIndex.value = -1
}
}
function onBlur() {
// 延迟关闭,给 click 事件时间触发(mousedown 在 blur 前,但 click 在后)
setTimeout(() => {
showDropdown.value = false
}, 150)
}
function onFocus() {
// 聚焦时若已有输入且非完整代码,重新展示建议
nextTick(() => {
if (inputText.value.trim() && !isFullCode.value && suggestions.value.length > 0) {
showDropdown.value = true
}
})
}
// 父组件外部更新 code 时(如 URL 回填),同步到输入框
watch(code, (newCode) => {
if (newCode !== inputText.value) {
inputText.value = newCode
}
})
</script>
<template>
<div class="stock-search-input">
<input
ref="inputRef"
v-model="inputText"
type="text"
autocomplete="off"
:placeholder="props.placeholder"
@keydown="onKeydown"
@blur="onBlur"
@focus="onFocus"
/>
<span v-if="detectedMarket" class="market-tag">{{ detectedMarket }}</span>
<span v-if="loadError" class="load-err" :title="loadError"></span>
<ul v-if="showDropdown" class="suggestions">
<li
v-for="(s, i) in suggestions"
:key="s.code"
:class="{ active: i === activeIndex }"
@mousedown.prevent="selectEntry(s)"
@mouseenter="activeIndex = i"
>
<span class="code">{{ s.code }}</span>
<span class="name">{{ s.name }}</span>
<span v-if="s.initials" class="initials">{{ s.initials }}</span>
</li>
</ul>
</div>
</template>
<style scoped>
.stock-search-input {
position: relative;
width: 100%;
}
.stock-search-input input {
width: 100%;
padding-right: 70px;
}
.market-tag {
position: absolute;
right: 8px;
bottom: 8px;
font-size: 11px;
color: var(--text-dim);
background: var(--bg-elevated);
border: 1px solid var(--border);
padding: 1px 6px;
border-radius: 3px;
}
.load-err {
position: absolute;
right: 8px;
top: 8px;
color: var(--up);
font-size: 14px;
}
.suggestions {
position: absolute;
z-index: 100;
top: calc(100% + 2px);
left: 0;
right: 0;
max-height: 320px;
overflow-y: auto;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
list-style: none;
margin: 0;
padding: 0;
}
.suggestions li {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
cursor: pointer;
font-size: 13px;
}
.suggestions li:hover,
.suggestions li.active {
background: var(--bg-elevated);
}
.suggestions .code {
font-family: var(--font-mono);
color: var(--text-dim);
width: 64px;
flex-shrink: 0;
}
.suggestions .name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.suggestions .initials {
font-size: 11px;
color: var(--text-dim);
opacity: 0.7;
text-transform: lowercase;
}
</style>
+20 -16
View File
@@ -2,9 +2,11 @@
// 多标的输入(组合回测用)。逐个添加 6 位代码,市场自动识别。
// 删除手动市场选择(沪市/深市/北交所),由 detectMarket 智能匹配。
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { detectMarket, marketLabel } from '../market'
import { detectMarket } from '../market'
import StockSearchInput from './StockSearchInput.vue'
import type { StockSearchEntry } from '../types'
const props = defineProps<{
modelValue: string[]
@@ -12,9 +14,6 @@ const props = defineProps<{
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
const code = ref('')
const detectedMarket = computed(() => (code.value && /^\d{6}$/.test(code.value)
? marketLabel(detectMarket(code.value))
: ''))
function add() {
if (!/^\d{6}$/.test(code.value)) return
@@ -25,6 +24,15 @@ function add() {
code.value = ''
}
/** 选中下拉建议时,直接添加并清空输入框(组合页"选中即添加"的快捷流) */
function onSelectEntry(entry: StockSearchEntry) {
const sym = `${detectMarket(entry.code)}:${entry.code}`
if (!props.modelValue.includes(sym)) {
emit('update:modelValue', [...props.modelValue, sym])
}
code.value = ''
}
function remove(sym: string) {
emit('update:modelValue', props.modelValue.filter((s) => s !== sym))
}
@@ -33,15 +41,14 @@ function remove(sym: string) {
<template>
<div class="stocks-picker">
<div class="row add-row">
<input
<StockSearchInput
v-model="code"
maxlength="6"
placeholder="6位代码(市场自动识别)"
@keyup.enter="add"
placeholder="6位代码 / 拼音 / 名字"
@select="onSelectEntry"
@confirm="add"
/>
<button @click="add">添加</button>
</div>
<p v-if="detectedMarket" class="market-hint">将识别为{{ detectedMarket }}</p>
<div v-if="modelValue.length" class="stock-list">
<span v-for="s in modelValue" :key="s" class="stock-tag">
@@ -57,15 +64,12 @@ function remove(sym: string) {
.add-row {
display: flex;
gap: 6px;
align-items: center;
}
.add-row input {
/* StockSearchInput 根元素填满剩余宽度 */
.add-row :deep(.stock-search-input) {
flex: 1;
}
.market-hint {
color: var(--text-dim);
font-size: 11px;
margin-top: 4px;
}
.stock-list {
display: flex;
flex-wrap: wrap;
+4 -27
View File
@@ -4,10 +4,11 @@
// 后端 /bars 仅支持 count(上限 800,约 3.2 年),固定拉满后前端按日期过滤。
// 默认:结束日=今天(最近交易日),开始日=2020-01-06。
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { fetchBars, formatError } from '../api'
import { detectMarket, marketLabel } from '../market'
import { detectMarket } from '../market'
import StockSearchInput from './StockSearchInput.vue'
import { useBacktestStore } from '../stores/backtest'
import type { Category } from '../types'
@@ -36,11 +37,6 @@ const loading = ref(false)
const CATEGORIES: Category[] = ['DAY', 'WEEK', 'MONTH', 'MIN_5', 'MIN_15', 'MIN_30', 'MIN_60']
// 智能识别的市场(用于提示展示)
const detectedMarket = computed(() => (code.value && /^\d{6}$/.test(code.value)
? marketLabel(detectMarket(code.value))
: ''))
/** 取行情(由父组件在点击「开始回测/开始寻优」时调用)。
* 成功返回 true,失败返回 false(并把错误写入 store.error 供父组件感知)。 */
async function loadBars(): Promise<boolean> {
@@ -93,12 +89,7 @@ defineExpose({ loadBars, loading })
<div class="symbol-picker">
<div class="field code-field">
<label>代码</label>
<input
v-model="code"
maxlength="6"
placeholder="6位代码(市场自动识别)"
/>
<span v-if="detectedMarket" class="market-tag">{{ detectedMarket }}</span>
<StockSearchInput v-model="code" placeholder="6位代码 / 拼音 / 名字" />
</div>
<div class="field">
@@ -130,20 +121,6 @@ defineExpose({ loadBars, loading })
.code-field {
position: relative;
}
.code-field input {
padding-right: 70px;
}
.market-tag {
position: absolute;
right: 8px;
bottom: 8px;
font-size: 11px;
color: var(--text-dim);
background: var(--bg-elevated);
border: 1px solid var(--border);
padding: 1px 6px;
border-radius: 3px;
}
.err {
color: var(--up);
font-size: 12px;
+78
View File
@@ -0,0 +1,78 @@
// 股票搜索 composable:模块级缓存搜索索引 + 按代码/名字/声母三路过滤。
// 索引整会话只拉一次(~150KB / 5000 条),后续过滤纯本地计算(<5ms)。
import { ref } from 'vue'
import { fetchSearchIndex, formatError } from '../api'
import type { StockSearchEntry } from '../types'
// ── 模块级缓存(所有组件实例共享一次拉取) ─────────────────────────────────
let cachedIndex: StockSearchEntry[] | null = null
let loadPromise: Promise<StockSearchEntry[]> | null = null
/** 三路匹配:代码前缀 / 名字包含 / 声母包含。
* query 纯数字时优先按代码前缀(照顾"直接输 6 位代码"的老习惯);
* 含字母时按声母;任何情况都叠加名字包含(输"旭创"也能命中)。 */
function matchEntry(entry: StockSearchEntry, q: string): boolean {
if (entry.code.startsWith(q)) return true
if (entry.name.includes(q)) return true
if (entry.initials.includes(q)) return true
return false
}
/** 拉索引(去重并发请求;成功后常驻模块级缓存)。失败抛错,调用方处理。 */
async function ensureIndex(): Promise<StockSearchEntry[]> {
if (cachedIndex) return cachedIndex
if (!loadPromise) {
loadPromise = (async () => {
const { data } = await fetchSearchIndex()
cachedIndex = data
return data
})().catch((e) => {
// 失败清空 promise,允许下次重试
loadPromise = null
throw e
})
}
return loadPromise
}
export interface UseStockSearch {
/** 索引是否已加载就绪 */
ready: ReturnType<typeof ref<boolean>>
/** 加载错误信息(空串表示无错) */
loadError: ReturnType<typeof ref<string>>
/** 按输入过滤,返回最多 limit 条(默认 30) */
search: (query: string, limit?: number) => Promise<StockSearchEntry[]>
}
/** 股票搜索:懒加载索引 + 本地三路过滤。 */
export function useStockSearch(): UseStockSearch {
const ready = ref(false)
const loadError = ref('')
// 首次调用即触发后台拉取(不阻塞,失败记错)
ensureIndex()
.then(() => {
ready.value = true
})
.catch((e) => {
loadError.value = formatError(e)
})
async function search(query: string, limit = 30): Promise<StockSearchEntry[]> {
const q = query.trim().toLowerCase()
if (!q) return []
const index = await ensureIndex()
const out: StockSearchEntry[] = []
for (const entry of index) {
if (matchEntry(entry, q)) {
out.push(entry)
if (out.length >= limit) break
}
}
return out
}
return { ready, loadError, search }
}
+15
View File
@@ -330,3 +330,18 @@ export interface MultiStrategyBacktestRequest {
slippage?: number
execution?: ExecutionMode
}
// ── 股票搜索索引(GET /api/v1/security/search-index ────────────────────────
/** 搜索索引单条:code/name/initials(声母,如 中际旭创→zjxc)。 */
export interface StockSearchEntry {
code: string
name: string
initials: string
}
/** 搜索索引响应。 */
export interface StockSearchIndex {
count: number
data: StockSearchEntry[]
}