页面顶部展示最佳评分推荐品种。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0fae54e093
commit
2adb5707c0
18
src/App.vue
18
src/App.vue
@ -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>
|
||||
|
||||
@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
351
src/components/ai/AiRecommendBar.vue
Normal file
351
src/components/ai/AiRecommendBar.vue
Normal 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>
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user