页面顶部展示最佳评分推荐品种。
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>
|
</template>
|
||||||
|
|
||||||
<section class="quote-section">
|
<section class="quote-section">
|
||||||
|
<AiRecommendBar
|
||||||
|
:items="aiRecommendVarieties"
|
||||||
|
:loading="aiVarietyScoreLoading"
|
||||||
|
:error="aiVarietyScoreError"
|
||||||
|
/>
|
||||||
<QuoteStats v-if="quote" :quote="quote" />
|
<QuoteStats v-if="quote" :quote="quote" />
|
||||||
<div class="quote-grid">
|
<div class="quote-grid">
|
||||||
<div class="chart-col">
|
<div class="chart-col">
|
||||||
@ -81,7 +86,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import AppLayout from './layout/AppLayout.vue'
|
import AppLayout from './layout/AppLayout.vue'
|
||||||
import AppHeader from './components/header/AppHeader.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 NewsList from './components/news/NewsList.vue'
|
||||||
import PositionPanel from './components/position/PositionPanel.vue'
|
import PositionPanel from './components/position/PositionPanel.vue'
|
||||||
import AiAdviceDrawer from './components/ai/AiAdviceDrawer.vue'
|
import AiAdviceDrawer from './components/ai/AiAdviceDrawer.vue'
|
||||||
|
import AiRecommendBar from './components/ai/AiRecommendBar.vue'
|
||||||
import { useQuote } from './composables/useQuote'
|
import { useQuote } from './composables/useQuote'
|
||||||
import { useNews } from './composables/useNews'
|
import { useNews } from './composables/useNews'
|
||||||
import { usePositions } from './composables/usePositions'
|
import { usePositions } from './composables/usePositions'
|
||||||
import { useAiAdvice } from './composables/useAiAdvice'
|
import { useAiAdvice } from './composables/useAiAdvice'
|
||||||
import { useQuotaStore } from './stores/quota'
|
import { useQuotaStore } from './stores/quota'
|
||||||
|
|
||||||
|
const quota = useQuotaStore()
|
||||||
const { data: quote, klineLoading, loadKline } = useQuote()
|
const { data: quote, klineLoading, loadKline } = useQuote()
|
||||||
const {
|
const {
|
||||||
wsStatus,
|
wsStatus,
|
||||||
@ -113,13 +120,20 @@ const {
|
|||||||
positionAnomalyLoading,
|
positionAnomalyLoading,
|
||||||
positionAnomalyError,
|
positionAnomalyError,
|
||||||
aiVarietyScore,
|
aiVarietyScore,
|
||||||
} = storeToRefs(useQuotaStore())
|
aiRecommendVarieties,
|
||||||
|
aiVarietyScoreLoading,
|
||||||
|
aiVarietyScoreError,
|
||||||
|
} = storeToRefs(quota)
|
||||||
const { data: news, loading: newsLoading, error: newsError, refresh: refreshNews } = useNews()
|
const { data: news, loading: newsLoading, error: newsError, refresh: refreshNews } = useNews()
|
||||||
const { data: positions } = usePositions()
|
const { data: positions } = usePositions()
|
||||||
const { data: advice, loading: adviceLoading, refresh: refreshAdvice } = useAiAdvice()
|
const { data: advice, loading: adviceLoading, refresh: refreshAdvice } = useAiAdvice()
|
||||||
|
|
||||||
const newsOpen = ref(false)
|
const newsOpen = ref(false)
|
||||||
const positionOpen = ref(false)
|
const positionOpen = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void quota.fetchAiVarietyScore()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -65,10 +65,16 @@ export function findVarietyScore(
|
|||||||
return list.find((item) => item.variety === key) ?? null
|
return list.find((item) => item.variety === key) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AiVarietyScoresResult {
|
||||||
|
all: AiVarietyScore[]
|
||||||
|
/** 最佳推荐品种(通常 3 个),保持接口返回顺序 */
|
||||||
|
recommend: AiVarietyScore[]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拉取全品种评分 + 推荐列表,合并 isBest 标记。
|
* 拉取全品种评分 + 推荐列表,合并 isBest 标记。
|
||||||
*/
|
*/
|
||||||
export async function fetchAiVarietyScores(): Promise<AiVarietyScore[]> {
|
export async function fetchAiVarietyScores(): Promise<AiVarietyScoresResult> {
|
||||||
const [all, recommend] = await Promise.all([
|
const [all, recommend] = await Promise.all([
|
||||||
getAiVarietyList(),
|
getAiVarietyList(),
|
||||||
getAiRecommendVarietyList(),
|
getAiRecommendVarietyList(),
|
||||||
@ -78,7 +84,13 @@ export async function fetchAiVarietyScores(): Promise<AiVarietyScore[]> {
|
|||||||
recommend.map((r) => extractVariety(r.main_contract).toUpperCase()),
|
recommend.map((r) => extractVariety(r.main_contract).toUpperCase()),
|
||||||
)
|
)
|
||||||
|
|
||||||
return all.map((raw) =>
|
return {
|
||||||
mapVariety(raw, bestKeys.has(extractVariety(raw.main_contract).toUpperCase())),
|
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 品种评分(全品种列表,按当前合约匹配)─────
|
// ─── 同花顺 AI 品种评分(全品种列表,按当前合约匹配)─────
|
||||||
const aiVarietyScores = ref<AiVarietyScore[]>([])
|
const aiVarietyScores = ref<AiVarietyScore[]>([])
|
||||||
|
/** 最佳推荐品种(来自 recommend_variety_list,通常 3 个) */
|
||||||
|
const aiRecommendVarieties = ref<AiVarietyScore[]>([])
|
||||||
const aiVarietyScore = ref<AiVarietyScoreView | null>(null)
|
const aiVarietyScore = ref<AiVarietyScoreView | null>(null)
|
||||||
const aiVarietyScoreLoading = ref(false)
|
const aiVarietyScoreLoading = ref(false)
|
||||||
const aiVarietyScoreError = ref<unknown>(null)
|
const aiVarietyScoreError = ref<unknown>(null)
|
||||||
@ -356,8 +358,9 @@ export const useQuotaStore = defineStore('quota', () => {
|
|||||||
aiVarietyScoreLoading.value = true
|
aiVarietyScoreLoading.value = true
|
||||||
aiVarietyScoreError.value = null
|
aiVarietyScoreError.value = null
|
||||||
try {
|
try {
|
||||||
const list = await fetchAiVarietyScores()
|
const { all, recommend } = await fetchAiVarietyScores()
|
||||||
aiVarietyScores.value = list
|
aiVarietyScores.value = all
|
||||||
|
aiRecommendVarieties.value = recommend
|
||||||
aiVarietyScoresLoaded.value = true
|
aiVarietyScoresLoaded.value = true
|
||||||
bindCurrentAiScore()
|
bindCurrentAiScore()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -642,6 +645,7 @@ export const useQuotaStore = defineStore('quota', () => {
|
|||||||
positionAnomalyLoading,
|
positionAnomalyLoading,
|
||||||
positionAnomalyError,
|
positionAnomalyError,
|
||||||
aiVarietyScore,
|
aiVarietyScore,
|
||||||
|
aiRecommendVarieties,
|
||||||
aiVarietyScoreLoading,
|
aiVarietyScoreLoading,
|
||||||
aiVarietyScoreError,
|
aiVarietyScoreError,
|
||||||
wsStatus,
|
wsStatus,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user