|
| 1 | +import { Spinner, Text, makeStyles, mergeClasses } from '@fluentui/react-components' |
| 2 | +import { ArrowClockwiseRegular } from '@fluentui/react-icons' |
| 3 | +import OpptrixButton from '../components/opptrix/OpptrixButton' |
| 4 | +import type { TrendStrip, TrendStripTone } from '../types/schemas' |
| 5 | +import { opptrixTokens } from '../theme/tokens' |
| 6 | +import { useStockTrendBrief } from './useStockTrendBrief' |
| 7 | +import { shouldPollTrendBrief } from './chartLiveRefresh' |
| 8 | + |
| 9 | +const GROUP_LABELS: Record<TrendStrip['group'], string> = { |
| 10 | + trend: '趋势结构', |
| 11 | + volume: '量价行为', |
| 12 | + risk: '风险收益', |
| 13 | + holding: '持仓参考', |
| 14 | + aux: '辅助参考', |
| 15 | +} |
| 16 | + |
| 17 | +const GROUP_ORDER: TrendStrip['group'][] = ['trend', 'volume', 'risk', 'holding', 'aux'] |
| 18 | + |
| 19 | +const useStyles = makeStyles({ |
| 20 | + root: { |
| 21 | + display: 'flex', |
| 22 | + flexDirection: 'column', |
| 23 | + gap: '12px', |
| 24 | + padding: '10px 0 16px', |
| 25 | + }, |
| 26 | + head: { |
| 27 | + display: 'flex', |
| 28 | + alignItems: 'center', |
| 29 | + justifyContent: 'space-between', |
| 30 | + gap: '8px', |
| 31 | + padding: '0 2px', |
| 32 | + }, |
| 33 | + meta: { |
| 34 | + fontSize: '10px', |
| 35 | + color: opptrixTokens.textTertiary, |
| 36 | + lineHeight: 1.45, |
| 37 | + }, |
| 38 | + section: { |
| 39 | + display: 'flex', |
| 40 | + flexDirection: 'column', |
| 41 | + gap: '6px', |
| 42 | + }, |
| 43 | + sectionTitle: { |
| 44 | + fontSize: '10px', |
| 45 | + fontWeight: 650, |
| 46 | + color: opptrixTokens.textTertiary, |
| 47 | + letterSpacing: '0.06em', |
| 48 | + textTransform: 'uppercase', |
| 49 | + padding: '0 2px', |
| 50 | + }, |
| 51 | + strip: { |
| 52 | + display: 'flex', |
| 53 | + flexDirection: 'column', |
| 54 | + gap: '4px', |
| 55 | + padding: '8px 10px', |
| 56 | + borderRadius: opptrixTokens.radiusMd, |
| 57 | + backgroundColor: opptrixTokens.canvas, |
| 58 | + border: `1px solid ${opptrixTokens.separator}`, |
| 59 | + }, |
| 60 | + stripHead: { |
| 61 | + display: 'flex', |
| 62 | + alignItems: 'baseline', |
| 63 | + justifyContent: 'space-between', |
| 64 | + gap: '8px', |
| 65 | + }, |
| 66 | + stripTitle: { |
| 67 | + fontSize: '11px', |
| 68 | + fontWeight: 600, |
| 69 | + color: opptrixTokens.textPrimary, |
| 70 | + flexShrink: 0, |
| 71 | + }, |
| 72 | + stripStatus: { |
| 73 | + fontSize: '11px', |
| 74 | + fontWeight: 650, |
| 75 | + textAlign: 'right', |
| 76 | + lineHeight: 1.35, |
| 77 | + }, |
| 78 | + stripDetail: { |
| 79 | + fontSize: '11px', |
| 80 | + lineHeight: 1.55, |
| 81 | + color: opptrixTokens.textSecondary, |
| 82 | + }, |
| 83 | + toneBullish: { color: '#FF3B30' }, |
| 84 | + toneBearish: { color: '#34C759' }, |
| 85 | + toneNeutral: { color: opptrixTokens.textPrimary }, |
| 86 | + toneCaution: { color: opptrixTokens.warning }, |
| 87 | + toneMuted: { color: opptrixTokens.textTertiary }, |
| 88 | + center: { |
| 89 | + display: 'flex', |
| 90 | + flexDirection: 'column', |
| 91 | + alignItems: 'center', |
| 92 | + justifyContent: 'center', |
| 93 | + gap: '8px', |
| 94 | + padding: '32px 16px', |
| 95 | + color: opptrixTokens.textTertiary, |
| 96 | + fontSize: '12px', |
| 97 | + textAlign: 'center', |
| 98 | + }, |
| 99 | + disclaimer: { |
| 100 | + fontSize: '10px', |
| 101 | + lineHeight: 1.5, |
| 102 | + color: opptrixTokens.textTertiary, |
| 103 | + padding: '4px 2px 0', |
| 104 | + }, |
| 105 | +}) |
| 106 | + |
| 107 | +function toneClass(s: ReturnType<typeof useStyles>, tone: TrendStripTone) { |
| 108 | + switch (tone) { |
| 109 | + case 'bullish': return s.toneBullish |
| 110 | + case 'bearish': return s.toneBearish |
| 111 | + case 'caution': return s.toneCaution |
| 112 | + case 'muted': return s.toneMuted |
| 113 | + default: return s.toneNeutral |
| 114 | + } |
| 115 | +} |
| 116 | + |
| 117 | +function groupStrips(strips: TrendStrip[]) { |
| 118 | + const map = new Map<TrendStrip['group'], TrendStrip[]>() |
| 119 | + for (const strip of strips) { |
| 120 | + const list = map.get(strip.group) ?? [] |
| 121 | + list.push(strip) |
| 122 | + map.set(strip.group, list) |
| 123 | + } |
| 124 | + return GROUP_ORDER |
| 125 | + .filter(g => map.has(g)) |
| 126 | + .map(g => ({ group: g, label: GROUP_LABELS[g], items: map.get(g)! })) |
| 127 | +} |
| 128 | + |
| 129 | +interface StockTrendTabProps { |
| 130 | + code: string |
| 131 | + active: boolean |
| 132 | + holdingCost?: number | null |
| 133 | +} |
| 134 | + |
| 135 | +export default function StockTrendTab({ code, active, holdingCost }: StockTrendTabProps) { |
| 136 | + const s = useStyles() |
| 137 | + const { data, loading, error, updatedAt, refresh } = useStockTrendBrief(code, active, holdingCost) |
| 138 | + |
| 139 | + if (loading && !data) { |
| 140 | + return ( |
| 141 | + <div className={s.center}> |
| 142 | + <Spinner size="small" label="正在整理趋势研判…" /> |
| 143 | + </div> |
| 144 | + ) |
| 145 | + } |
| 146 | + |
| 147 | + if (error && !data) { |
| 148 | + return ( |
| 149 | + <div className={s.center}> |
| 150 | + <Text block>{error}</Text> |
| 151 | + <OpptrixButton size="small" variant="secondary" onClick={refresh}> |
| 152 | + 重试 |
| 153 | + </OpptrixButton> |
| 154 | + </div> |
| 155 | + ) |
| 156 | + } |
| 157 | + |
| 158 | + if (!data) { |
| 159 | + return <div className={s.center}>暂无趋势研判数据</div> |
| 160 | + } |
| 161 | + |
| 162 | + const groups = groupStrips(data.strips) |
| 163 | + const livePolling = shouldPollTrendBrief() |
| 164 | + const updatedLabel = updatedAt |
| 165 | + ? updatedAt.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) |
| 166 | + : '—' |
| 167 | + |
| 168 | + return ( |
| 169 | + <div className={s.root}> |
| 170 | + <div className={s.head}> |
| 171 | + <Text className={s.meta} block> |
| 172 | + 数据截至 {data.as_of} |
| 173 | + {livePolling ? ( |
| 174 | + <> |
| 175 | + {' · '} |
| 176 | + 盘中约每分钟自动刷新(更新于 {updatedLabel}) |
| 177 | + </> |
| 178 | + ) : ( |
| 179 | + <> |
| 180 | + {' · '} |
| 181 | + 非盘中数据不变,进入本页时加载 |
| 182 | + </> |
| 183 | + )} |
| 184 | + </Text> |
| 185 | + <OpptrixButton |
| 186 | + variant="icon" |
| 187 | + icon={<ArrowClockwiseRegular fontSize={14} />} |
| 188 | + aria-label="立即刷新" |
| 189 | + onClick={refresh} |
| 190 | + /> |
| 191 | + </div> |
| 192 | + |
| 193 | + {groups.map(section => ( |
| 194 | + <div key={section.group} className={s.section}> |
| 195 | + <Text className={s.sectionTitle}>{section.label}</Text> |
| 196 | + {section.items.map(strip => ( |
| 197 | + <div key={strip.id} className={s.strip}> |
| 198 | + <div className={s.stripHead}> |
| 199 | + <Text className={s.stripTitle}>{strip.title}</Text> |
| 200 | + <Text className={mergeClasses(s.stripStatus, toneClass(s, strip.tone))}> |
| 201 | + {strip.status} |
| 202 | + </Text> |
| 203 | + </div> |
| 204 | + <Text className={s.stripDetail} block>{strip.detail}</Text> |
| 205 | + </div> |
| 206 | + ))} |
| 207 | + </div> |
| 208 | + ))} |
| 209 | + |
| 210 | + <Text className={s.disclaimer} block> |
| 211 | + 以上基于历史行情与常用技术统计,帮助理解当前走势结构,不构成买卖建议。 |
| 212 | + </Text> |
| 213 | + </div> |
| 214 | + ) |
| 215 | +} |
0 commit comments