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
+16 -1
View File
@@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { marketApi } from "../../shared/api/market";
import { useMarketStore } from "../../shared/stores/market";
import { useSessionStore } from "../../shared/stores/session";
import { useUiStore } from "../../shared/stores/ui";
@@ -14,6 +15,7 @@ const session = useSessionStore();
const ui = useUiStore();
const menuOpen = ref(false);
const menuRoot = ref<HTMLElement | null>(null);
const refreshing = ref(false);
const title = computed(() => {
if (route.name === "system") return "系统管理";
@@ -56,6 +58,19 @@ async function endSession(action: "logout" | "switch-account"): Promise<void> {
}
}
async function refreshMarket(): Promise<void> {
if (refreshing.value) return;
refreshing.value = true;
try {
const result = await marketApi.syncSnapshot(market.selectedDate);
ui.showToast(`${result.trade_date} 行情快照已更新,刷新页面后读取新数据`);
} catch (error) {
ui.showToast(error instanceof Error ? error.message : "行情刷新失败,请稍后重试");
} finally {
refreshing.value = false;
}
}
onMounted(() => document.addEventListener("mousedown", closeOnOutside));
onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside));
</script>
@@ -74,7 +89,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside))
<button class="btn btn-small" type="button" :title="ui.theme === 'light' ? '切换夜间模式' : '切换日间模式'" @click="ui.toggleTheme">
{{ ui.theme === "light" ? "夜间" : "日间" }}
</button>
<button v-if="session.isAdmin" class="btn btn-small desktop-only" type="button" @click="ui.showToast('行情刷新任务暂不可用')">后台刷新</button>
<button v-if="session.isAdmin" class="btn btn-small desktop-only" type="button" :disabled="refreshing" @click="refreshMarket">{{ refreshing ? "刷新中" : "后台刷新" }}</button>
<button v-if="session.isAdmin" class="btn btn-small desktop-only" type="button" @click="router.push('/system')">系统管理</button>
<div class="identity-cluster">
<button v-if="session.isMember" class="tag tag-vip" type="button" title="查看会员状态" @click="ui.openDialog('membership')">V 会员</button>
@@ -3,6 +3,7 @@ import { computed } from "vue";
import { useRoute } from "vue-router";
import EmptyState from "../../shared/components/EmptyState.vue";
import MarketWorkspaceView from "../../pages/market/MarketWorkspaceView.vue";
import { useMarketStore } from "../../shared/stores/market";
import { useSessionStore } from "../../shared/stores/session";
import { useUiStore } from "../../shared/stores/ui";
@@ -16,10 +17,16 @@ const workspace = computed(() => findWorkspace(String(route.params.workspace)) ?
const locked = computed(
() => ["screener", "mentor", "heaven"].includes(workspace.value.key) && !session.account?.smart_access,
);
const implementedMarket = computed(() =>
["emotion", "pool", "broken", "limit-down", "yesterday", "performance"].includes(
workspace.value.key,
),
);
</script>
<template>
<main class="page-frame">
<MarketWorkspaceView v-if="implementedMarket" :workspace-key="workspace.key" />
<main v-else class="page-frame">
<header class="page-header">
<h1>{{ workspace.title }}</h1>
<p class="page-subtitle">{{ workspace.description }} · 数据日期 {{ market.selectedDate }}</p>
+1
View File
@@ -11,6 +11,7 @@ import "./shared/styles/shell.css";
import "./shared/styles/auth.css";
import "./shared/styles/account.css";
import "./shared/styles/market.css";
import "./shared/styles/market-workspace.css";
import "./shared/styles/system.css";
import "./shared/styles/mobile.css";
@@ -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>
+19
View File
@@ -56,6 +56,17 @@ export type ChartSeries = {
points: ChartPoint[];
};
export type MarketWorkspaceData = {
trade_date: string | null;
observed_at?: string;
carried_forward: boolean;
message: string;
overview: Record<string, number>;
sentiment?: Record<string, unknown>;
history?: Record<string, unknown>[];
items?: Record<string, unknown>[];
};
export const marketApi = {
summary(date?: string): Promise<MarketSummary> {
const query = date ? `?date=${encodeURIComponent(date)}` : "";
@@ -69,4 +80,12 @@ export const marketApi = {
const identifier = encodeURIComponent(entity.identifier);
return api.get<ChartSeries>(`/market/entities/${type}/${identifier}/charts/${interval}`);
},
workspace(key: string, date: string): Promise<MarketWorkspaceData> {
return api.get<MarketWorkspaceData>(
`/market/workspaces/${encodeURIComponent(key)}?date=${encodeURIComponent(date)}`,
);
},
syncSnapshot(date: string): Promise<{ trade_date: string; temperature: number }> {
return api.post(`/market/snapshot-sync?date=${encodeURIComponent(date)}`);
},
};
@@ -0,0 +1,45 @@
<script setup lang="ts">
type TableColumn = {
key: string;
label: string;
numeric?: boolean;
wide?: boolean;
code?: boolean;
sortable?: boolean;
format?: (value: unknown, row: Record<string, unknown>) => string;
};
defineProps<{
columns: TableColumn[];
rows: Record<string, unknown>[];
sortKey: string;
sortDirection: "asc" | "desc";
}>();
const emit = defineEmits<{ sort: [key: string] }>();
</script>
<template>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th class="index-column">序号</th>
<th v-for="column in columns" :key="column.key" :class="{ numeric: column.numeric, 'wide-column': column.wide, 'code-column': column.code }">
<button v-if="column.sortable" type="button" @click="emit('sort', column.key)">
{{ column.label }}<span v-if="sortKey === column.key">{{ sortDirection === "asc" ? "↑" : "↓" }}</span>
</button>
<span v-else>{{ column.label }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in rows" :key="String(row.identifier ?? index)">
<td class="index-column numeric">{{ index + 1 }}</td>
<td v-for="column in columns" :key="column.key" :class="{ numeric: column.numeric, 'wide-column': column.wide, 'code-column': column.code, up: column.key.includes('change') && Number(row[column.key]) > 0, down: column.key.includes('change') && Number(row[column.key]) < 0 }">
{{ column.format ? column.format(row[column.key], row) : (row[column.key] ?? "") }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -0,0 +1,118 @@
.market-workspace-page {
display: grid;
align-content: start;
gap: var(--layout-gap);
}
.market-workspace-page > .page-header {
margin-bottom: 0;
}
.market-page-header {
justify-content: space-between;
}
.workspace-state {
min-height: var(--s-200);
display: grid;
place-items: center;
color: var(--color-text-secondary);
}
.emotion-overview,
.performance-layout,
.pool-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--s-320);
gap: var(--layout-gap);
align-items: start;
}
.temperature-chart-wrap {
min-height: var(--s-200);
padding: var(--s-10) var(--s-14);
}
.temperature-chart {
width: 100%;
height: var(--s-200);
overflow: visible;
}
.temperature-grid { stroke: var(--color-divider); stroke-width: var(--s-1); }
.temperature-line { fill: none; stroke: var(--color-primary); stroke-width: var(--s-2); }
.temperature-average { fill: none; stroke: var(--color-warning); stroke-width: var(--s-2); stroke-dasharray: var(--s-6) var(--s-4); }
.temperature-point { fill: var(--color-surface); stroke: var(--color-primary); stroke-width: var(--s-2); }
.phase-body { display: grid; gap: var(--s-8); padding: var(--s-14); }
.phase-name { color: var(--color-primary); font-size: var(--font-18); font-weight: var(--weight-800); }
.phase-confidence { color: var(--color-text-secondary); font-size: var(--font-12); }
.phase-body > p { color: var(--color-text-secondary); line-height: var(--s-20); }
.phase-metrics { display: flex; flex-wrap: wrap; gap: var(--s-8) var(--s-14); color: var(--color-text-secondary); font-size: var(--font-12); }
.phase-metrics strong { color: var(--color-text); }
.phase-warning { margin-top: var(--s-4); }
.component-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--s-14);
padding: var(--s-14);
}
.component-item { display: grid; gap: var(--s-6); }
.component-item > div:first-child { display: flex; justify-content: space-between; gap: var(--s-8); }
.component-item small { color: var(--color-text-faint); font-size: var(--font-11); }
.score-track { height: var(--s-6); overflow: hidden; border-radius: var(--radius-round); background: var(--color-surface-muted); }
.score-track span { display: block; height: 100%; border-radius: inherit; background: var(--color-primary); }
.history-header { justify-content: space-between; }
.history-header .seg-control { margin-left: 0; }
.emotion-history-wrap { max-height: none; }
.pool-layout { grid-template-columns: minmax(0, 1fr) var(--s-320); }
.pool-layout-single { grid-template-columns: minmax(0, 1fr); }
.pool-table-card { min-width: 0; overflow: hidden; }
.pool-toolbar { min-height: var(--s-46); display: flex; align-items: center; gap: var(--s-8); padding: var(--s-7) var(--s-10); border-bottom: var(--s-1) solid var(--color-divider); }
.pool-filters { margin-left: 0; }
.pool-search { width: var(--s-260); min-height: var(--s-32); margin-left: auto; }
.pool-aside { display: grid; gap: var(--layout-gap); }
.pool-stat-body { display: grid; gap: var(--s-6); padding: var(--s-16); }
.pool-stat-body strong { color: var(--color-primary); font-size: var(--font-18); }
.pool-stat-body span, .risk-note { color: var(--color-text-secondary); font-size: var(--font-12); }
.sector-list { display: grid; gap: var(--s-1); margin: 0; padding: var(--s-8) var(--s-14); list-style: none; }
.sector-list li { display: flex; justify-content: space-between; gap: var(--s-8); padding: var(--s-8) 0; border-bottom: var(--s-1) solid var(--color-divider); }
.sector-list strong { font-variant-numeric: tabular-nums; }
.risk-note { padding: 0 var(--s-14) var(--s-14); line-height: var(--s-18); }
.pool-summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--layout-gap); }
.summary-metric { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s-12); padding: var(--s-12) var(--s-14); }
.summary-metric span { color: var(--color-text-secondary); }
.summary-metric strong { font-size: var(--font-17); }
.data-table-wrap { width: 100%; overflow-x: auto; }
.data-table th.numeric, .data-table td.numeric { text-align: right; font-variant-numeric: tabular-nums; }
.data-table th.index-column, .data-table td.index-column { width: var(--s-44); }
.data-table th.code-column, .data-table td.code-column { min-width: var(--s-64); }
.data-table th.wide-column, .data-table td.wide-column { width: var(--s-200); min-width: var(--s-200); white-space: normal; }
.data-table th button { display: inline-flex; align-items: center; gap: var(--s-4); padding: 0; color: inherit; background: var(--c-transparent); font-weight: inherit; }
.performance-layout { grid-template-columns: minmax(0, 1fr) var(--s-320); }
.performance-levels { min-width: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(var(--s-320), 1fr)); gap: var(--layout-gap); }
.performance-level-card { min-width: 0; padding: var(--s-12) var(--s-14); }
.performance-level-card > header, .performance-level-card > footer { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--s-8) var(--s-14); }
.performance-level-card > header span, .performance-level-card > footer { color: var(--color-text-secondary); font-size: var(--font-11); }
.performance-outcomes { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--s-6); margin: var(--s-12) 0; }
.performance-outcomes span { display: grid; gap: var(--s-4); padding: var(--s-8); border-radius: var(--control-radius); background: var(--color-surface-muted); color: var(--color-text-secondary); font-size: var(--font-11); text-align: center; }
.performance-outcomes b { color: var(--color-text); font-size: var(--font-15); }
.performance-conclusion { position: sticky; top: calc(var(--shell-topbar-height) + var(--shell-summary-height) + var(--s-12)); }
.performance-conclusion p { color: var(--color-text-secondary); line-height: var(--s-20); }
.performance-conclusion dl { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-8); margin: var(--s-14) 0 0; }
.performance-conclusion dl div { padding: var(--s-8); border-radius: var(--control-radius); background: var(--color-surface-muted); }
.performance-conclusion dt { color: var(--color-text-secondary); font-size: var(--font-11); }
.performance-conclusion dd { margin: var(--s-4) 0 0; font-weight: var(--weight-700); font-variant-numeric: tabular-nums; }
.breadth-body { display: grid; gap: var(--s-10); padding: var(--s-14); }
.breadth-legend { display: flex; flex-wrap: wrap; gap: var(--s-8) var(--s-20); color: var(--color-text-secondary); }
.breadth-track { height: var(--s-10); display: flex; overflow: hidden; border-radius: var(--radius-round); background: var(--color-surface-muted); }
.breadth-track span { height: 100%; }
.breadth-up { background: var(--color-up); }
.breadth-flat { background: var(--color-text-faint); }
.breadth-down { background: var(--color-down); }
@@ -163,4 +163,38 @@
.member-editor {
position: static;
}
.emotion-overview,
.pool-layout,
.performance-layout {
grid-template-columns: minmax(0, 1fr);
}
.component-grid {
grid-template-columns: 1fr 1fr;
}
.pool-toolbar {
align-items: stretch;
flex-wrap: wrap;
}
.pool-filters {
width: 100%;
overflow-x: auto;
}
.pool-search {
width: 100%;
margin-left: 0;
}
.pool-summary-grid,
.performance-levels {
grid-template-columns: minmax(0, 1fr);
}
.performance-conclusion {
position: static;
}
}