rebuild(stage-6): deliver emotion and market pools

This commit is contained in:
leefer
2026-07-30 03:03:53 +08:00
parent 889963862a
commit 59f6011ae8
30 changed files with 1751 additions and 9 deletions
@@ -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>