页面顶部展示最佳评分推荐品种。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dongzp 2026-09-07 16:27:49 +08:00
parent 0fae54e093
commit 2adb5707c0
4 changed files with 389 additions and 8 deletions

View File

@ -10,6 +10,11 @@
</template>
<section class="quote-section">
<AiRecommendBar
:items="aiRecommendVarieties"
:loading="aiVarietyScoreLoading"
:error="aiVarietyScoreError"
/>
<QuoteStats v-if="quote" :quote="quote" />
<div class="quote-grid">
<div class="chart-col">
@ -81,7 +86,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import AppLayout from './layout/AppLayout.vue'
import AppHeader from './components/header/AppHeader.vue'
@ -96,12 +101,14 @@ import QuickAccessFab from './components/common/QuickAccessFab.vue'
import NewsList from './components/news/NewsList.vue'
import PositionPanel from './components/position/PositionPanel.vue'
import AiAdviceDrawer from './components/ai/AiAdviceDrawer.vue'
import AiRecommendBar from './components/ai/AiRecommendBar.vue'
import { useQuote } from './composables/useQuote'
import { useNews } from './composables/useNews'
import { usePositions } from './composables/usePositions'
import { useAiAdvice } from './composables/useAiAdvice'
import { useQuotaStore } from './stores/quota'
const quota = useQuotaStore()
const { data: quote, klineLoading, loadKline } = useQuote()
const {
wsStatus,
@ -113,13 +120,20 @@ const {
positionAnomalyLoading,
positionAnomalyError,
aiVarietyScore,
} = storeToRefs(useQuotaStore())
aiRecommendVarieties,
aiVarietyScoreLoading,
aiVarietyScoreError,
} = storeToRefs(quota)
const { data: news, loading: newsLoading, error: newsError, refresh: refreshNews } = useNews()
const { data: positions } = usePositions()
const { data: advice, loading: adviceLoading, refresh: refreshAdvice } = useAiAdvice()
const newsOpen = ref(false)
const positionOpen = ref(false)
onMounted(() => {
void quota.fetchAiVarietyScore()
})
</script>
<style scoped>

View File

@ -65,10 +65,16 @@ export function findVarietyScore(
return list.find((item) => item.variety === key) ?? null
}
export interface AiVarietyScoresResult {
all: AiVarietyScore[]
/** 最佳推荐品种(通常 3 个),保持接口返回顺序 */
recommend: AiVarietyScore[]
}
/**
* 拉取全品种评分 + 推荐列表,合并 isBest 标记。
*/
export async function fetchAiVarietyScores(): Promise<AiVarietyScore[]> {
export async function fetchAiVarietyScores(): Promise<AiVarietyScoresResult> {
const [all, recommend] = await Promise.all([
getAiVarietyList(),
getAiRecommendVarietyList(),
@ -78,7 +84,13 @@ export async function fetchAiVarietyScores(): Promise<AiVarietyScore[]> {
recommend.map((r) => extractVariety(r.main_contract).toUpperCase()),
)
return all.map((raw) =>
mapVariety(raw, bestKeys.has(extractVariety(raw.main_contract).toUpperCase())),
)
return {
all: all.map((raw) =>
mapVariety(
raw,
bestKeys.has(extractVariety(raw.main_contract).toUpperCase()),
),
),
recommend: recommend.map((raw) => mapVariety(raw, true)),
}
}

View File

@ -0,0 +1,351 @@
<template>
<section v-if="items.length || loading || error" class="recommend-bar">
<div class="head">
<span class="title">最佳评分</span>
<span class="sub">同花顺 AI 推荐</span>
</div>
<div v-if="loading && !items.length" class="loading">加载中…</div>
<div v-else-if="error && !items.length" class="loading error">评分推荐加载失败</div>
<div v-else class="cards">
<button
v-for="(item, idx) in items"
:key="item.variety"
type="button"
class="card"
@click="openDetail(item)"
>
<span class="rank">{{ idx + 1 }}</span>
<div class="meta">
<div class="name-row">
<span class="name">{{ item.varietyName }}</span>
<span class="code">{{ item.mainContract }}</span>
</div>
<div class="nums">
<span class="score" :class="scoreTone(item.score)">
{{ formatSigned(item.score) }}
</span>
<span class="pct" :class="scoreTone(item.percent)">
{{ formatSigned(item.percent) }}%
</span>
</div>
</div>
</button>
</div>
<el-dialog
v-model="dialogOpen"
:title="detail ? `${detail.varietyName} · AI 评分详情` : 'AI 评分详情'"
width="520px"
top="12vh"
destroy-on-close
class="recommend-dialog"
>
<template v-if="detail">
<div class="detail-head">
<div class="detail-score" :class="scoreTone(detail.score)">
{{ formatSigned(detail.score) }}
</div>
<div class="detail-meta">
<div>主力合约 {{ detail.mainContract }}</div>
<div>
涨跌幅
<span :class="scoreTone(detail.percent)">
{{ formatSigned(detail.percent) }}%
</span>
</div>
<div v-if="detail.market">市场 {{ detail.market }}</div>
</div>
</div>
<ul v-if="detail.factors.length" class="factors">
<li v-for="(f, i) in detail.factors" :key="i">
<span class="judge" :class="judgeClass(f.judge)">{{ f.judge }}</span>
<span class="factor-text">{{ f.factor }}</span>
<span class="factor-score" :class="scoreTone(f.score)">
{{ formatSigned(f.score) }}
</span>
</li>
</ul>
<el-empty v-else description="暂无影响因素" :image-size="64" />
</template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { AiVarietyScore } from '../../api/jqka/aiExplainTypes'
withDefaults(
defineProps<{
items: AiVarietyScore[]
loading?: boolean
error?: unknown
}>(),
{ loading: false, error: null },
)
const dialogOpen = ref(false)
const detail = ref<AiVarietyScore | null>(null)
function openDetail(item: AiVarietyScore) {
detail.value = item
dialogOpen.value = true
}
function formatSigned(n: number): string {
if (!Number.isFinite(n)) return '—'
return `${n > 0 ? '+' : ''}${n}`
}
function scoreTone(n: number): string {
if (n > 0) return 'bull'
if (n < 0) return 'bear'
return 'flat'
}
function judgeClass(judge: string): string {
if (judge.includes('多')) return 'bull'
if (judge.includes('空')) return 'bear'
return 'flat'
}
</script>
<style scoped>
.recommend-bar {
display: flex;
align-items: stretch;
gap: 12px;
padding: 10px 14px;
margin-bottom: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.head {
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
min-width: 72px;
flex-shrink: 0;
}
.title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.sub {
font-size: 11px;
color: var(--text-secondary);
}
.loading {
flex: 1;
display: flex;
align-items: center;
color: var(--text-secondary);
font-size: 13px;
}
.loading.error {
color: var(--up);
}
.cards {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
min-width: 0;
}
.card {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-elevated, #f7f8fa);
cursor: pointer;
text-align: left;
transition: border-color 0.15s, box-shadow 0.15s;
}
.card:hover {
border-color: #1677ff;
box-shadow: 0 0 0 2px color-mix(in srgb, #1677ff 12%, transparent);
}
.rank {
width: 22px;
height: 22px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: #fff;
background: #1677ff;
}
.card:nth-child(1) .rank {
background: #cf1322;
}
.card:nth-child(2) .rank {
background: #d48806;
}
.card:nth-child(3) .rank {
background: #1677ff;
}
.meta {
min-width: 0;
flex: 1;
}
.name-row {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.code {
font-size: 12px;
color: var(--text-secondary);
flex-shrink: 0;
}
.nums {
margin-top: 2px;
display: flex;
align-items: baseline;
gap: 10px;
font-variant-numeric: tabular-nums;
}
.score {
font-size: 16px;
font-weight: 700;
}
.pct {
font-size: 12px;
font-weight: 600;
}
.bull {
color: var(--up);
}
.bear {
color: var(--down);
}
.flat {
color: var(--text-secondary);
}
.detail-head {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px dashed var(--border);
}
.detail-score {
font-size: 36px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.detail-meta {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: var(--text-secondary);
}
.factors {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.factors li {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
line-height: 1.5;
}
.judge {
flex-shrink: 0;
padding: 0 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
line-height: 20px;
background: #f0f2f5;
}
.judge.bull {
color: var(--up);
background: color-mix(in srgb, var(--up) 12%, #fff);
}
.judge.bear {
color: var(--down);
background: color-mix(in srgb, var(--down) 12%, #fff);
}
.factor-text {
flex: 1;
min-width: 0;
color: var(--text-primary);
}
.factor-score {
flex-shrink: 0;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 960px) {
.recommend-bar {
flex-direction: column;
}
.cards {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -288,6 +288,8 @@ export const useQuotaStore = defineStore('quota', () => {
// ─── 同花顺 AI 品种评分(全品种列表,按当前合约匹配)─────
const aiVarietyScores = ref<AiVarietyScore[]>([])
/** 最佳推荐品种(来自 recommend_variety_list,通常 3 个) */
const aiRecommendVarieties = ref<AiVarietyScore[]>([])
const aiVarietyScore = ref<AiVarietyScoreView | null>(null)
const aiVarietyScoreLoading = ref(false)
const aiVarietyScoreError = ref<unknown>(null)
@ -356,8 +358,9 @@ export const useQuotaStore = defineStore('quota', () => {
aiVarietyScoreLoading.value = true
aiVarietyScoreError.value = null
try {
const list = await fetchAiVarietyScores()
aiVarietyScores.value = list
const { all, recommend } = await fetchAiVarietyScores()
aiVarietyScores.value = all
aiRecommendVarieties.value = recommend
aiVarietyScoresLoaded.value = true
bindCurrentAiScore()
} catch (e) {
@ -642,6 +645,7 @@ export const useQuotaStore = defineStore('quota', () => {
positionAnomalyLoading,
positionAnomalyError,
aiVarietyScore,
aiRecommendVarieties,
aiVarietyScoreLoading,
aiVarietyScoreError,
wsStatus,