rebuild(stage-6): deliver emotion and market pools
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
import { marketApi, type MarketWorkspaceData } from "../../shared/api/market";
|
||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import EmotionPage from "./emotion/EmotionPage.vue";
|
||||
import PerformancePage from "./pools/PerformancePage.vue";
|
||||
import PoolPage from "./pools/PoolPage.vue";
|
||||
|
||||
const props = defineProps<{ workspaceKey: string }>();
|
||||
const market = useMarketStore();
|
||||
const data = ref<MarketWorkspaceData | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
let sequence = 0;
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const current = ++sequence;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await marketApi.workspace(props.workspaceKey, market.selectedDate);
|
||||
if (current === sequence) data.value = result;
|
||||
} catch (reason) {
|
||||
if (current === sequence) error.value = reason instanceof Error ? reason.message : "页面数据读取失败";
|
||||
} finally {
|
||||
if (current === sequence) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => props.workspaceKey, () => market.selectedDate], () => void load(), { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page-frame market-workspace-page">
|
||||
<div v-if="loading" class="card workspace-state">正在读取本地复盘数据</div>
|
||||
<EmptyState v-else-if="error" title="页面数据暂不可用" :description="error" class="card" />
|
||||
<EmptyState v-else-if="!data?.trade_date" title="暂无真实行情快照" :description="data?.message || '等待管理员首次同步真实收盘行情'" class="card" />
|
||||
<EmotionPage v-else-if="workspaceKey === 'emotion'" :data="data" />
|
||||
<PerformancePage v-else-if="workspaceKey === 'performance'" :data="data" />
|
||||
<PoolPage v-else :kind="workspaceKey" :data="data" />
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import type { MarketWorkspaceData } from "../../../shared/api/market";
|
||||
|
||||
const props = defineProps<{ data: MarketWorkspaceData }>();
|
||||
const days = ref<10 | 20 | 60>(20);
|
||||
const sentiment = computed(() => props.data.sentiment ?? {});
|
||||
const stats = computed<Record<string, number>>(
|
||||
() => (sentiment.value.stats as Record<string, number> | undefined) ?? {},
|
||||
);
|
||||
const components = computed<Record<string, unknown>[]>(
|
||||
() => (sentiment.value.components as Record<string, unknown>[] | undefined) ?? [],
|
||||
);
|
||||
const history = computed(() => (props.data.history ?? []).slice(-days.value));
|
||||
const graph = computed(() => (props.data.history ?? []).slice(-20));
|
||||
const width = 720;
|
||||
const height = 190;
|
||||
const inset = 18;
|
||||
|
||||
function pointX(index: number): number {
|
||||
return inset + (index / Math.max(graph.value.length - 1, 1)) * (width - inset * 2);
|
||||
}
|
||||
|
||||
function pointY(value: unknown): number {
|
||||
return height - inset - (Number(value ?? 0) / 100) * (height - inset * 2);
|
||||
}
|
||||
|
||||
const temperaturePath = computed(() =>
|
||||
graph.value.map((item, index) => `${index ? "L" : "M"}${pointX(index)},${pointY(item.temperature)}`).join(" "),
|
||||
);
|
||||
const averagePath = computed(() =>
|
||||
graph.value.map((_, index) => {
|
||||
const window = graph.value.slice(Math.max(0, index - 4), index + 1);
|
||||
const average = window.reduce((sum, item) => sum + Number(item.temperature ?? 0), 0) / window.length;
|
||||
return `${index ? "L" : "M"}${pointX(index)},${pointY(average)}`;
|
||||
}).join(" "),
|
||||
);
|
||||
|
||||
const warning = computed(() => {
|
||||
if (sentiment.value.direction === "降温") return "情绪指标继续走弱";
|
||||
if (sentiment.value.direction === "升温") return "情绪指标正在回暖";
|
||||
return "情绪指标保持平稳";
|
||||
});
|
||||
|
||||
function number(value: unknown, digits = 0): string {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed.toFixed(digits) : "";
|
||||
}
|
||||
|
||||
function amount(value: unknown): string {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? `${(parsed / 100_000_000).toFixed(2)} 亿` : "";
|
||||
}
|
||||
|
||||
function selectDays(value: number): void {
|
||||
if (value === 10 || value === 20 || value === 60) days.value = value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-header market-page-header">
|
||||
<div><h1>情绪周期</h1><p class="page-subtitle">全市场情绪温度与阶段 · 数据日期 {{ data.trade_date }}</p></div>
|
||||
<span v-if="data.message" class="tag warning">{{ data.message }}</span>
|
||||
</header>
|
||||
|
||||
<section class="emotion-overview">
|
||||
<article class="card temperature-card">
|
||||
<header class="card-header"><h2>温度走势</h2><span class="faint">情绪温度 · 五日均线</span></header>
|
||||
<div class="temperature-chart-wrap">
|
||||
<svg class="temperature-chart" :viewBox="`0 0 ${width} ${height}`" role="img" aria-label="情绪温度走势">
|
||||
<line v-for="line in [20, 40, 60, 80]" :key="line" class="temperature-grid" :x1="inset" :x2="width - inset" :y1="pointY(line)" :y2="pointY(line)" />
|
||||
<path class="temperature-average" :d="averagePath" />
|
||||
<path class="temperature-line" :d="temperaturePath" />
|
||||
<circle v-for="(item, index) in graph" :key="String(item.trade_date)" class="temperature-point" :cx="pointX(index)" :cy="pointY(item.temperature)" :r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card phase-card">
|
||||
<header class="card-header"><h2>当前阶段</h2><span class="tag">{{ sentiment.phase }}</span></header>
|
||||
<div class="phase-body">
|
||||
<div class="phase-name">{{ sentiment.phase }}</div>
|
||||
<div class="phase-confidence">置信度 {{ sentiment.confidence }}%</div>
|
||||
<p>{{ sentiment.transition_reason }}</p>
|
||||
<div class="phase-metrics">
|
||||
<span>温度 <strong>{{ sentiment.score }}</strong></span>
|
||||
<span>较前日 <strong>{{ Number(sentiment.day_change ?? 0) > 0 ? "+" : "" }}{{ sentiment.day_change }}</strong></span>
|
||||
<span>{{ sentiment.direction }}</span>
|
||||
<span>封板率 <strong>{{ number(data.overview.seal_rate, 1) }}%</strong></span>
|
||||
<span>涨停 <strong>{{ data.overview.limit_up }}</strong> / 炸板 <strong>{{ data.overview.broken }}</strong></span>
|
||||
<span>昨涨停红盘 <strong>{{ number(stats.positive_rate, 1) }}%</strong></span>
|
||||
<span>样本 <strong>{{ stats.yesterday_count }}</strong></span>
|
||||
</div>
|
||||
<div class="notice notice-warning phase-warning">{{ warning }}</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card component-card">
|
||||
<header class="card-header"><h2>评分构成</h2><span class="faint">五维加权后执行系统健康门控</span></header>
|
||||
<div class="component-grid">
|
||||
<div v-for="component in components" :key="String(component.key)" class="component-item">
|
||||
<div><span>{{ component.label }}</span><strong>{{ number(component.score, 1) }}</strong></div>
|
||||
<div class="score-track"><span :style="{ width: `${Number(component.score ?? 0)}%` }"></span></div>
|
||||
<small>权重 {{ component.weight }}%</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card history-card">
|
||||
<header class="card-header history-header">
|
||||
<h2>交易日明细</h2>
|
||||
<div class="seg-control" aria-label="明细天数">
|
||||
<button v-for="value in [10, 20, 60]" :key="value" type="button" :class="{ active: days === value }" @click="selectDays(value)">{{ value }}日</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="data-table-wrap emotion-history-wrap">
|
||||
<table class="data-table emotion-history-table">
|
||||
<thead><tr><th>日期</th><th class="numeric">温度</th><th>阶段</th><th>方向</th><th class="numeric">红盘率(%)</th><th class="numeric">封板率(%)</th><th class="numeric">涨停</th><th class="numeric">炸板</th><th class="numeric">跌停</th><th class="numeric">最高板</th><th class="numeric">成交额(亿)</th></tr></thead>
|
||||
<tbody><tr v-for="item in history" :key="String(item.trade_date)"><td>{{ item.trade_date }}</td><td class="numeric">{{ item.temperature }}</td><td>{{ item.phase }}</td><td>{{ item.direction }}</td><td class="numeric">{{ number(item.positive_rate, 1) }}</td><td class="numeric">{{ number(item.seal_rate, 1) }}</td><td class="numeric">{{ item.limit_up }}</td><td class="numeric">{{ item.broken }}</td><td class="numeric">{{ item.limit_down }}</td><td class="numeric">{{ item.max_height }}</td><td class="numeric">{{ amount(item.amount) }}</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
import type { MarketWorkspaceData } from "../../../shared/api/market";
|
||||
import EmptyState from "../../../shared/components/EmptyState.vue";
|
||||
|
||||
const props = defineProps<{ data: MarketWorkspaceData }>();
|
||||
const levels = computed(() => props.data.items ?? []);
|
||||
const overview = computed(() => props.data.overview ?? {});
|
||||
const breadthTotal = computed(() => Number(overview.value.up_count ?? 0) + Number(overview.value.down_count ?? 0) + Number(overview.value.flat_count ?? 0));
|
||||
const breadthRate = (value: unknown) => breadthTotal.value ? Number(value ?? 0) / breadthTotal.value * 100 : 0;
|
||||
const conclusion = computed(() => {
|
||||
const samples = levels.value.reduce((sum, row) => sum + Number(row.count ?? 0), 0);
|
||||
const advanced = levels.value.reduce((sum, row) => sum + Number(row.advanced ?? 0), 0);
|
||||
const rate = advanced / Math.max(samples, 1) * 100;
|
||||
if (rate >= 40) return "连板晋级表现较强,短线接力保持活跃。";
|
||||
if (rate >= 20) return "连板晋级表现中性,结构分化较为明显。";
|
||||
return "连板晋级表现偏弱,市场兑现压力较高。";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-header market-page-header">
|
||||
<div><h1>涨停表现</h1><p class="page-subtitle">昨日涨停的晋级、兑现与断板 · 数据日期 {{ data.trade_date }}</p></div>
|
||||
<span v-if="data.message" class="tag warning">{{ data.message }}</span>
|
||||
</header>
|
||||
<section v-if="levels.length" class="performance-layout">
|
||||
<div class="performance-levels">
|
||||
<article v-for="level in levels" :key="String(level.level)" class="card performance-level-card">
|
||||
<header><strong>{{ level.level }}板</strong><span>{{ level.count }}只</span></header>
|
||||
<div class="performance-outcomes">
|
||||
<span><b class="up">{{ level.advanced }}</b>晋级</span><span><b class="up">{{ level.red }}</b>红盘</span><span><b>{{ level.broken }}</b>断板</span><span><b class="warning">{{ level.opened }}</b>炸板</span><span><b class="down">{{ level.limit_down }}</b>跌停</span>
|
||||
</div>
|
||||
<footer><span>晋级率 {{ level.advance_rate }}%</span><span>兑现率 {{ level.positive_rate }}%</span><span>平均涨幅 {{ Number(level.average_change) > 0 ? "+" : "" }}{{ level.average_change }}%</span></footer>
|
||||
</article>
|
||||
</div>
|
||||
<aside class="card performance-conclusion"><header class="card-header"><h2>今日结论</h2></header><div class="card-body"><p>{{ conclusion }}</p><dl><div><dt>封板率</dt><dd>{{ overview.seal_rate }}%</dd></div><div><dt>涨停</dt><dd>{{ overview.limit_up }}家</dd></div><div><dt>炸板</dt><dd>{{ overview.broken }}家</dd></div><div><dt>跌停</dt><dd>{{ overview.limit_down }}家</dd></div></dl></div></aside>
|
||||
</section>
|
||||
<EmptyState v-else class="card" title="暂无昨日涨停样本" description="最近真实快照中没有可统计的昨日涨停记录。" />
|
||||
<section class="card breadth-card">
|
||||
<header class="card-header"><h2>市场宽度</h2><span class="faint">全市场上涨、平盘与下跌分布</span></header>
|
||||
<div class="breadth-body">
|
||||
<div class="breadth-legend"><span>上涨 <strong class="up">{{ overview.up_count }}</strong></span><span>平盘 <strong>{{ overview.flat_count }}</strong></span><span>下跌 <strong class="down">{{ overview.down_count }}</strong></span><span>红盘率 <strong>{{ breadthRate(overview.up_count).toFixed(1) }}%</strong></span></div>
|
||||
<div class="breadth-track" aria-label="市场宽度比例"><span class="breadth-up" :style="{ width: `${breadthRate(overview.up_count)}%` }"></span><span class="breadth-flat" :style="{ width: `${breadthRate(overview.flat_count)}%` }"></span><span class="breadth-down" :style="{ width: `${breadthRate(overview.down_count)}%` }"></span></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import type { MarketWorkspaceData } from "../../../shared/api/market";
|
||||
import DataTable from "../../../shared/components/DataTable.vue";
|
||||
import EmptyState from "../../../shared/components/EmptyState.vue";
|
||||
|
||||
type PoolKind = "pool" | "broken" | "limit-down" | "yesterday";
|
||||
type FilterOption = readonly [value: string, label: string];
|
||||
type Column = {
|
||||
key: string;
|
||||
label: string;
|
||||
numeric?: boolean;
|
||||
wide?: boolean;
|
||||
code?: boolean;
|
||||
sortable?: boolean;
|
||||
format?: (value: unknown, row: Record<string, unknown>) => string;
|
||||
};
|
||||
|
||||
const props = defineProps<{ kind: string; data: MarketWorkspaceData }>();
|
||||
const search = ref("");
|
||||
const filter = ref("all");
|
||||
const sortKey = ref("");
|
||||
const sortDirection = ref<"asc" | "desc">("desc");
|
||||
|
||||
const definitions: Record<PoolKind, { title: string; description: string; columns: Column[] }> = {
|
||||
pool: {
|
||||
title: "涨停池",
|
||||
description: "当日封板股票与连板结构",
|
||||
columns: [
|
||||
{ key: "code", label: "代码", code: true, sortable: true },
|
||||
{ key: "name", label: "股票", sortable: true },
|
||||
{ key: "streak", label: "连板", numeric: true, sortable: true },
|
||||
{ key: "change", label: "涨幅(%)", numeric: true, sortable: true, format: percent },
|
||||
{ key: "price", label: "价格(元)", numeric: true, sortable: true, format: decimal },
|
||||
{ key: "sector", label: "板块", sortable: true },
|
||||
{ key: "first_time", label: "首封", sortable: true },
|
||||
{ key: "last_time", label: "最后封板", sortable: true },
|
||||
{ key: "open_times", label: "开板次数", numeric: true, sortable: true },
|
||||
{ key: "turnover_rate", label: "换手率(%)", numeric: true, sortable: true, format: percent },
|
||||
{ key: "amount", label: "成交额(亿)", numeric: true, sortable: true, format: amount },
|
||||
{ key: "seal_amount", label: "封单额(亿)", numeric: true, sortable: true, format: amount },
|
||||
{ key: "reason", label: "涨停原因", wide: true },
|
||||
],
|
||||
},
|
||||
broken: {
|
||||
title: "炸板池",
|
||||
description: "触板未封股票与开板情况",
|
||||
columns: [
|
||||
{ key: "code", label: "代码", code: true, sortable: true },
|
||||
{ key: "name", label: "股票", sortable: true },
|
||||
{ key: "change", label: "现价涨幅(%)", numeric: true, sortable: true, format: percent },
|
||||
{ key: "distance_to_limit", label: "距涨停(%)", numeric: true, sortable: true, format: percent },
|
||||
{ key: "price", label: "价格(元)", numeric: true, sortable: true, format: decimal },
|
||||
{ key: "sector", label: "板块", sortable: true },
|
||||
{ key: "first_time", label: "首次触板", sortable: true },
|
||||
{ key: "open_times", label: "开板次数", numeric: true, sortable: true },
|
||||
{ key: "turnover_rate", label: "换手率(%)", numeric: true, sortable: true, format: percent },
|
||||
{ key: "amount", label: "成交额(亿)", numeric: true, sortable: true, format: amount },
|
||||
{ key: "reason", label: "炸板原因", wide: true },
|
||||
],
|
||||
},
|
||||
"limit-down": {
|
||||
title: "跌停池",
|
||||
description: "跌停风险与行业聚集",
|
||||
columns: [
|
||||
{ key: "code", label: "代码", code: true, sortable: true },
|
||||
{ key: "name", label: "股票", sortable: true },
|
||||
{ key: "change", label: "跌幅(%)", numeric: true, sortable: true, format: percent },
|
||||
{ key: "price", label: "价格(元)", numeric: true, sortable: true, format: decimal },
|
||||
{ key: "sector", label: "板块", sortable: true },
|
||||
{ key: "turnover_rate", label: "换手率(%)", numeric: true, sortable: true, format: percent },
|
||||
{ key: "amount", label: "成交额(亿)", numeric: true, sortable: true, format: amount },
|
||||
{ key: "streak", label: "连续跌停", numeric: true, sortable: true },
|
||||
{ key: "reason", label: "风险线索", wide: true },
|
||||
],
|
||||
},
|
||||
yesterday: {
|
||||
title: "昨日涨停",
|
||||
description: "昨日涨停股票的次日反馈",
|
||||
columns: [
|
||||
{ key: "code", label: "代码", code: true, sortable: true },
|
||||
{ key: "name", label: "股票", sortable: true },
|
||||
{ key: "prior_streak", label: "昨日高度", numeric: true, sortable: true },
|
||||
{ key: "current_change", label: "今日涨幅(%)", numeric: true, sortable: true, format: percent },
|
||||
{ key: "outcome", label: "今日结果", sortable: true },
|
||||
{ key: "current_streak", label: "当前高度", numeric: true, sortable: true },
|
||||
{ key: "sector", label: "板块", sortable: true },
|
||||
{ key: "reason", label: "涨停逻辑", wide: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const definition = computed(() => definitions[props.kind as PoolKind] ?? definitions.pool);
|
||||
const sourceRows = computed(() => props.data.items ?? []);
|
||||
const filters = computed<FilterOption[]>(() => props.kind === "pool"
|
||||
? [["all", "全部"], ["first", "首板"], ["second", "2板"], ["three", "3板+"]]
|
||||
: props.kind === "yesterday"
|
||||
? [["all", "全部"], ["advanced", "晋级"], ["red", "红盘"], ["broken", "断板"], ["failed", "炸板+跌停"]]
|
||||
: []);
|
||||
const rows = computed(() => {
|
||||
const keyword = search.value.trim().toLocaleLowerCase();
|
||||
const filtered = sourceRows.value.filter((row) => {
|
||||
const matchesSearch = !keyword || [row.code, row.name, row.sector]
|
||||
.some((value) => String(value ?? "").toLocaleLowerCase().includes(keyword));
|
||||
if (!matchesSearch) return false;
|
||||
if (props.kind === "pool") {
|
||||
const streak = Number(row.streak ?? 0);
|
||||
return filter.value === "all" || (filter.value === "first" && streak === 1)
|
||||
|| (filter.value === "second" && streak === 2) || (filter.value === "three" && streak >= 3);
|
||||
}
|
||||
if (props.kind === "yesterday") {
|
||||
const outcome = String(row.outcome ?? "");
|
||||
return filter.value === "all" || (filter.value === "advanced" && outcome === "晋级")
|
||||
|| (filter.value === "red" && outcome === "红盘") || (filter.value === "broken" && outcome === "断板")
|
||||
|| (filter.value === "failed" && ["炸板", "跌停"].includes(outcome));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!sortKey.value) return filtered;
|
||||
const direction = sortDirection.value === "asc" ? 1 : -1;
|
||||
return [...filtered].sort((left, right) => compare(left[sortKey.value], right[sortKey.value]) * direction);
|
||||
});
|
||||
const sectorSummary = computed(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of sourceRows.value) {
|
||||
const sector = String(row.sector ?? "").trim();
|
||||
if (sector) counts.set(sector, (counts.get(sector) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
|
||||
});
|
||||
const maxHeight = computed(() => Math.max(0, ...sourceRows.value.map((row) => Number(row.streak ?? 0))));
|
||||
const positiveRate = computed(() => sourceRows.value.length
|
||||
? sourceRows.value.filter((row) => Number(row.current_change ?? 0) > 0).length / sourceRows.value.length * 100
|
||||
: 0);
|
||||
|
||||
watch(() => props.kind, () => { filter.value = "all"; search.value = ""; sortKey.value = ""; });
|
||||
|
||||
function sort(key: string): void {
|
||||
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
else { sortKey.value = key; sortDirection.value = "desc"; }
|
||||
}
|
||||
|
||||
function exportCsv(): void {
|
||||
const columns = definition.value.columns;
|
||||
const values = [columns.map((column) => column.label), ...rows.value.map((row) => columns.map((column) => {
|
||||
const value = column.format ? column.format(row[column.key], row) : String(row[column.key] ?? "");
|
||||
return `"${value.replaceAll('"', '""')}"`;
|
||||
}))];
|
||||
const blob = new Blob(["\ufeff", values.map((line) => line.join(",")).join("\r\n")], { type: "text/csv;charset=utf-8" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `${definition.value.title}-${props.data.trade_date}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
function compare(left: unknown, right: unknown): number {
|
||||
const a = Number(left); const b = Number(right);
|
||||
if (Number.isFinite(a) && Number.isFinite(b)) return a - b;
|
||||
return String(left ?? "").localeCompare(String(right ?? ""), "zh-CN");
|
||||
}
|
||||
function numeric(value: unknown, digits: number): string {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number.toFixed(digits) : "";
|
||||
}
|
||||
function percent(value: unknown): string { return numeric(value, 2); }
|
||||
function decimal(value: unknown): string { return numeric(value, 2); }
|
||||
function amount(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? (number / 100_000_000).toFixed(2) : "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-header market-page-header">
|
||||
<div><h1>{{ definition.title }}</h1><p class="page-subtitle">{{ definition.description }} · 数据日期 {{ data.trade_date }}</p></div>
|
||||
<span v-if="data.message" class="tag warning">{{ data.message }}</span>
|
||||
</header>
|
||||
<section v-if="kind === 'yesterday'" class="pool-summary-grid">
|
||||
<article class="card summary-metric"><span>昨日涨停</span><strong>{{ sourceRows.length }}</strong></article>
|
||||
<article class="card summary-metric"><span>晋级</span><strong>{{ sourceRows.filter((row) => row.outcome === '晋级').length }}</strong></article>
|
||||
<article class="card summary-metric"><span>红盘兑现率</span><strong>{{ positiveRate.toFixed(1) }}%</strong></article>
|
||||
</section>
|
||||
<section class="pool-layout" :class="{ 'pool-layout-single': !['pool', 'limit-down'].includes(kind) }">
|
||||
<article class="card pool-table-card">
|
||||
<header class="pool-toolbar">
|
||||
<div v-if="filters.length" class="seg-control pool-filters">
|
||||
<button v-for="item in filters" :key="item[0]" type="button" :class="{ active: filter === item[0] }" @click="filter = item[0]">{{ item[1] }}</button>
|
||||
</div>
|
||||
<input v-model="search" class="input pool-search" type="search" placeholder="搜索代码、股票或板块" aria-label="搜索股池" />
|
||||
<button class="btn btn-small" type="button" @click="exportCsv">导出 CSV</button>
|
||||
</header>
|
||||
<DataTable v-if="rows.length" :columns="definition.columns" :rows="rows" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
|
||||
<EmptyState v-else title="暂无符合条件个股" description="当前真实快照中没有满足筛选条件的记录。" />
|
||||
</article>
|
||||
<aside v-if="kind === 'pool'" class="pool-aside">
|
||||
<article class="card"><header class="card-header"><h2>连板高度</h2></header><div class="pool-stat-body"><strong>{{ maxHeight }}板</strong><span>当前市场最高连板</span></div></article>
|
||||
<article class="card"><header class="card-header"><h2>热点板块</h2></header><ol class="sector-list"><li v-for="item in sectorSummary" :key="item[0]"><span>{{ item[0] }}</span><strong>{{ item[1] }}家</strong></li></ol></article>
|
||||
</aside>
|
||||
<aside v-else-if="kind === 'limit-down'" class="pool-aside">
|
||||
<article class="card"><header class="card-header"><h2>风险行业聚集</h2></header><ol class="sector-list"><li v-for="item in sectorSummary" :key="item[0]"><span>{{ item[0] }}</span><strong>{{ item[1] }}家</strong></li></ol><p class="risk-note">仅表示当日跌停数量聚集,不构成个股确定性结论。</p></article>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user