rebuild(audit): complete entity detail and market preview
This commit is contained in:
@@ -1,12 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import type { MarketEntity } from "../../shared/api/market";
|
||||
import { marketApi, type EntityDetail, type EntityMetric, type MarketEntity, type ThemeDetailData } from "../../shared/api/market";
|
||||
import { reviewApi } from "../../shared/api/review";
|
||||
import DataTable from "../../shared/components/DataTable.vue";
|
||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
import MarketPreviewPanel from "../../shared/market/MarketPreviewPanel.vue";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import StockNotes from "../../pages/review/StockNotes.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const market = useMarketStore();
|
||||
const ui = useUiStore();
|
||||
const detail = ref<EntityDetail | null>(null);
|
||||
const theme = ref<ThemeDetailData | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const watchlisted = ref(false);
|
||||
const watchBusy = ref(false);
|
||||
const sortKey = ref("change");
|
||||
const sortDirection = ref<"asc" | "desc">("desc");
|
||||
|
||||
const entity = computed<MarketEntity>(() => ({
|
||||
entity_type: String(route.params.entityType) as MarketEntity["entity_type"],
|
||||
identifier: String(route.params.identifier),
|
||||
@@ -14,15 +31,153 @@ const entity = computed<MarketEntity>(() => ({
|
||||
name: String(route.query.name ?? route.params.identifier),
|
||||
sector: route.query.sector ? String(route.query.sector) : null,
|
||||
}));
|
||||
const resolvedEntity = computed(() => detail.value?.entity ?? entity.value);
|
||||
const themeRows = computed(() => {
|
||||
const rows = theme.value?.members ?? [];
|
||||
const direction = sortDirection.value === "asc" ? 1 : -1;
|
||||
return [...rows].sort((left, right) => compare(left[sortKey.value], right[sortKey.value]) * direction);
|
||||
});
|
||||
const themeColumns = [
|
||||
{ key: "code", label: "代码", code: true, sortable: true },
|
||||
{ key: "name", label: "股票", sortable: true },
|
||||
{ key: "change", label: "涨跌幅(%)", numeric: true, sortable: true, format: decimal },
|
||||
{ key: "close", label: "收盘(元)", numeric: true, sortable: true, format: decimal },
|
||||
{ key: "amount", label: "成交额(亿)", numeric: true, sortable: true, format: amountCell },
|
||||
{ key: "quoted", label: "行情状态", format: (value: unknown) => value ? "正常交易" : "当日无行情" },
|
||||
];
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
detail.value = null;
|
||||
theme.value = null;
|
||||
try {
|
||||
const [marketDetail, review, themeDetail] = await Promise.all([
|
||||
marketApi.detail(entity.value, market.selectedDate),
|
||||
entity.value.entity_type === "stock" ? reviewApi.workspace(market.selectedDate) : null,
|
||||
entity.value.entity_type === "theme" ? marketApi.themeDetail(entity.value.identifier, market.selectedDate) : null,
|
||||
]);
|
||||
detail.value = marketDetail;
|
||||
theme.value = themeDetail;
|
||||
watchlisted.value = Boolean(review?.watchlist.some((item) => item.identifier === entity.value.identifier));
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : "行情详情读取失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleWatch(): Promise<void> {
|
||||
if (watchBusy.value) return;
|
||||
watchBusy.value = true;
|
||||
try {
|
||||
if (watchlisted.value) await reviewApi.removeWatch(resolvedEntity.value.identifier);
|
||||
else await reviewApi.addWatch(resolvedEntity.value.identifier);
|
||||
watchlisted.value = !watchlisted.value;
|
||||
ui.showToast(watchlisted.value ? "已加入自选" : "已移出自选");
|
||||
} catch (reason) {
|
||||
ui.showToast(reason instanceof Error ? reason.message : "自选操作失败");
|
||||
} finally {
|
||||
watchBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openTrend(): void {
|
||||
void router.push({ path: "/workspace/heaven", query: { mode: "trend", query: resolvedEntity.value.code } });
|
||||
}
|
||||
|
||||
function sort(key: string): void {
|
||||
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
else { sortKey.value = key; sortDirection.value = "desc"; }
|
||||
}
|
||||
|
||||
function formatMetric(metric: EntityMetric): string {
|
||||
if (metric.value === null || metric.value === undefined) return "";
|
||||
if (metric.key === "amount") return amount(metric.value);
|
||||
if (metric.key === "volume") return `${(metric.value / 10_000).toFixed(2)} 万`;
|
||||
return `${metric.value.toFixed(2)} ${metric.unit}`.trim();
|
||||
}
|
||||
function money(value: number | null | undefined): string {
|
||||
return value === null || value === undefined ? "" : `${(value * 100).toFixed(2)} 万`;
|
||||
}
|
||||
function decimal(value: unknown): string {
|
||||
const parsed = Number(value); return Number.isFinite(parsed) ? parsed.toFixed(2) : "";
|
||||
}
|
||||
function amount(value: unknown): string {
|
||||
const parsed = Number(value); return Number.isFinite(parsed) ? `${(parsed / 100_000_000).toFixed(2)} 亿` : "";
|
||||
}
|
||||
function amountCell(value: unknown): string {
|
||||
const parsed = Number(value); return Number.isFinite(parsed) ? (parsed / 100_000_000).toFixed(2) : "";
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
watch([() => route.params.identifier, () => market.selectedDate], load, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page-frame entity-detail-page">
|
||||
<header class="page-header entity-heading">
|
||||
<div><h1>{{ entity.name }}</h1><p class="page-subtitle">{{ entity.code }}<template v-if="entity.sector"> · {{ entity.sector }}</template></p></div>
|
||||
<span class="tag">最新真实行情</span>
|
||||
</header>
|
||||
<MarketPreviewPanel :entity="entity" class="card entity-chart" />
|
||||
<StockNotes v-if="entity.entity_type === 'stock'" :code="entity.code" :name="entity.name" />
|
||||
<div v-if="loading" class="card workspace-state">正在读取完整行情详情</div>
|
||||
<EmptyState v-else-if="error" class="card" title="行情详情暂不可用" :description="error" />
|
||||
<template v-else-if="detail">
|
||||
<header class="page-header entity-detail-heading">
|
||||
<div>
|
||||
<span class="entity-detail-code">{{ detail.entity.code }}</span>
|
||||
<h1>{{ detail.entity.name }}</h1>
|
||||
<span v-if="detail.entity.sector" class="tag">{{ detail.entity.sector }}</span>
|
||||
</div>
|
||||
<div class="entity-detail-price">
|
||||
<strong class="numeric">{{ detail.price.toFixed(2) }}</strong>
|
||||
<span v-if="detail.change !== null" class="numeric" :class="detail.change >= 0 ? 'up' : 'down'">{{ detail.change >= 0 ? '+' : '' }}{{ detail.change.toFixed(2) }}%</span>
|
||||
<small>数据日期 {{ detail.trade_date }}</small>
|
||||
</div>
|
||||
<div v-if="detail.entity.entity_type === 'stock'" class="page-actions entity-detail-actions">
|
||||
<button class="btn" type="button" :disabled="watchBusy" @click="toggleWatch">{{ watchlisted ? "移出自选" : "加入自选" }}</button>
|
||||
<button class="btn btn-primary" type="button" @click="openTrend">进入观势</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="entity-detail-grid">
|
||||
<MarketPreviewPanel :entity="resolvedEntity" class="card entity-chart" />
|
||||
<article class="card entity-metrics-card">
|
||||
<header class="card-header"><h2>交易数据</h2><span class="faint">对应 {{ detail.trade_date }}</span></header>
|
||||
<dl class="entity-metrics">
|
||||
<div v-for="metric in detail.metrics" :key="metric.key"><dt>{{ metric.label }}</dt><dd class="numeric">{{ formatMetric(metric) }}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div v-if="detail.entity.entity_type === 'stock'" class="entity-stock-grid">
|
||||
<article class="card entity-flow-card">
|
||||
<header class="card-header"><h2>资金流</h2><span class="faint">同一正式数据口径</span></header>
|
||||
<dl v-if="detail.money_flow?.available" class="entity-metrics entity-flow-metrics">
|
||||
<div><dt>当日净流入</dt><dd class="numeric">{{ money(detail.money_flow.net_million) }}</dd></div>
|
||||
<div><dt>大单净流入</dt><dd class="numeric">{{ money(detail.money_flow.large_million) }}</dd></div>
|
||||
<div><dt>近5日净流入</dt><dd class="numeric">{{ money(detail.money_flow.net_5d_million) }}</dd></div>
|
||||
<div><dt>近5日/流通市值</dt><dd class="numeric">{{ detail.money_flow.flow_to_circ_mv_5d === null ? "" : `${detail.money_flow.flow_to_circ_mv_5d.toFixed(4)}%` }}</dd></div>
|
||||
</dl>
|
||||
<EmptyState v-else title="资金流暂不可用" description="当前日期尚无达到覆盖标准的同口径资金流数据。" />
|
||||
</article>
|
||||
<article class="card entity-event-card">
|
||||
<header class="card-header"><h2>事件逻辑</h2><span v-if="detail.event" class="tag">{{ detail.event.status }}</span></header>
|
||||
<div v-if="detail.event" class="entity-event-body">
|
||||
<strong>{{ detail.event.reason || `${detail.event.status}原因待补充` }}</strong>
|
||||
<dl><div><dt>连板</dt><dd>{{ detail.event.streak ?? "" }}</dd></div><div><dt>首次</dt><dd>{{ detail.event.first_time ?? "" }}</dd></div><div><dt>最后</dt><dd>{{ detail.event.last_time ?? "" }}</dd></div><div><dt>开板</dt><dd>{{ detail.event.open_times ?? "" }}</dd></div></dl>
|
||||
</div>
|
||||
<EmptyState v-else title="当日无特殊事件" description="该股票当日不在涨停、炸板或跌停事件池中。" />
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section v-if="detail.entity.entity_type === 'theme'" class="card entity-members-card">
|
||||
<header class="card-header"><h2>题材成分股</h2><span class="faint">{{ theme?.summary.quoted_count ?? 0 }} / {{ theme?.summary.member_count ?? 0 }}只有行情</span></header>
|
||||
<DataTable v-if="themeRows.length" :columns="themeColumns" :rows="themeRows" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
|
||||
<EmptyState v-else title="暂无成分股" description="当前题材尚无可核验的成分股归档。" />
|
||||
</section>
|
||||
|
||||
<StockNotes v-if="detail.entity.entity_type === 'stock'" :code="detail.entity.code" :name="detail.entity.name" />
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { heavenApi, type HeavenReading, type HeavenSetup, type ReadingMode } from "../../shared/api/heaven";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
@@ -11,9 +12,10 @@ import InterpretDialog from "./InterpretDialog.vue";
|
||||
import TrendPanel from "./TrendPanel.vue";
|
||||
|
||||
const market = useMarketStore();
|
||||
const route = useRoute();
|
||||
const session = useSessionStore();
|
||||
const ui = useUiStore();
|
||||
const mode = ref<ReadingMode>("trend");
|
||||
const mode = ref<ReadingMode>(["trend", "fortune", "heart"].includes(String(route.query.mode)) ? String(route.query.mode) as ReadingMode : "trend");
|
||||
const setup = ref<HeavenSetup | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
@@ -75,7 +77,7 @@ watch([() => market.selectedDate, locked], load, { immediate: true });
|
||||
<div v-if="loading" class="card workspace-state">正在推演当日基础气机</div>
|
||||
<div v-else-if="error" class="notice notice-warning">{{ error }}</div>
|
||||
<div v-else :class="{ 'locked-content': locked }" :aria-disabled="locked">
|
||||
<TrendPanel v-if="mode === 'trend'" :disabled="locked" @interpret="openInterpret" />
|
||||
<TrendPanel v-if="mode === 'trend'" :disabled="locked" :initial-query="String(route.query.query || '')" @interpret="openInterpret" />
|
||||
<FortunePanel v-else-if="mode === 'fortune'" :field="setup?.fortune || null" :daily="setup?.daily_fortune || null" :disabled="locked" @interpret="openInterpret" @saved="saveFortune" />
|
||||
<HeartPanel v-else :disabled="locked" @interpret="openInterpret" />
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
import { heavenApi, type HeavenReading } from "../../shared/api/heaven";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import HexagramGraphic from "./HexagramGraphic.vue";
|
||||
|
||||
const props = defineProps<{ disabled: boolean }>();
|
||||
const props = defineProps<{ disabled: boolean; initialQuery?: string }>();
|
||||
const emit = defineEmits<{ interpret: [reading: HeavenReading] }>();
|
||||
const market = useMarketStore();
|
||||
const ui = useUiStore();
|
||||
@@ -49,6 +49,13 @@ function reading(): HeavenReading {
|
||||
created_at: "",
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const initial = props.initialQuery?.trim();
|
||||
if (!initial || props.disabled) return;
|
||||
query.value = initial;
|
||||
void load(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, reactive, ref } from "vue";
|
||||
|
||||
import type { MarketWorkspaceData } from "../../../shared/api/market";
|
||||
import MarketEntityLink from "../../../shared/market/MarketEntityLink.vue";
|
||||
|
||||
const props = defineProps<{ data: MarketWorkspaceData }>();
|
||||
const sortMode = ref<"time" | "open">("time");
|
||||
@@ -88,7 +89,7 @@ function exportCsv(): void {
|
||||
<header><div><strong>{{ group.level === 1 ? "首板" : `${group.level}板` }}</strong><span>{{ group.count }}只</span></div><small v-if="group.level > 1 && Number(group.count)">{{ group.adjacentRate === null ? "相邻梯队断层" : `相邻梯队 ${group.adjacentRate.toFixed(1)}%` }}</small></header>
|
||||
<div class="ladder-stocks">
|
||||
<article v-for="stock in group.visible" :key="String(stock.identifier)" class="ladder-stock">
|
||||
<div><strong>{{ stock.name }}</strong><span>{{ stock.code }}</span></div>
|
||||
<div><strong>{{ stock.name }}</strong><MarketEntityLink v-if="stock.identifier" :entity="{ entity_type: 'stock', identifier: String(stock.identifier), code: String(stock.code), name: String(stock.name), sector: stock.sector ? String(stock.sector) : null }" /></div>
|
||||
<p><b>{{ stock.sector || "其他" }}</b><span>{{ stock.first_time || "时间待校正" }}</span></p>
|
||||
<small>开板 {{ stock.open_times }}次 · 成交 {{ (Number(stock.amount ?? 0) / 100_000_000).toFixed(2) }}亿</small>
|
||||
</article>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { reviewApi, type AlertItem } from "../../shared/api/review";
|
||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
import MarketEntityLink from "../../shared/market/MarketEntityLink.vue";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
|
||||
const ui = useUiStore();
|
||||
@@ -38,7 +39,7 @@ onMounted(load);
|
||||
<div class="alerts-toolbar"><div class="segmented"><button type="button" :class="{ active: !unreadOnly }" @click="select(false)">全部</button><button type="button" :class="{ active: unreadOnly }" @click="select(true)">未读</button></div><button class="btn btn-small" type="button" @click="markAll">全部已读</button></div>
|
||||
<form class="alert-create" @submit.prevent="create"><div class="alert-create-grid"><label class="field"><span class="field-label">标题</span><input v-model="form.title" class="input" maxlength="80" required /></label><label class="field"><span class="field-label">提醒日期</span><input v-model="form.remind_date" class="input" type="date" required /></label><label class="field"><span class="field-label">股票代码(可选)</span><input v-model="form.code" class="input" maxlength="12" /></label></div><label class="field"><span class="field-label">提醒内容(可选)</span><textarea v-model="form.content" class="textarea" maxlength="500"></textarea></label><div class="form-actions"><button class="btn btn-primary" type="submit">保存提醒</button></div></form>
|
||||
<div v-if="loading" class="workspace-state">正在读取提醒</div>
|
||||
<div v-else-if="items.length" class="alert-list"><article v-for="item in items" :key="item.id" :class="{ unread: !item.is_read && item.due }"><div><strong>{{ item.title }}</strong><span class="tag">{{ item.due ? (item.is_read ? "已读" : "未读") : "未到期" }}</span></div><p>{{ item.content }}</p><small>{{ item.available_date }}<template v-if="item.code"> · {{ item.code }}</template></small><div class="table-actions"><button v-if="item.due && !item.is_read" class="btn btn-small" type="button" @click="mark(item)">标为已读</button><button class="btn btn-small" type="button" @click="remove(item)">删除</button></div></article></div>
|
||||
<div v-else-if="items.length" class="alert-list"><article v-for="item in items" :key="item.id" :class="{ unread: !item.is_read && item.due }"><div><strong>{{ item.title }}</strong><span class="tag">{{ item.due ? (item.is_read ? "已读" : "未读") : "未到期" }}</span></div><p>{{ item.content }}</p><small>{{ item.available_date }}<template v-if="item.code"> · <MarketEntityLink :entity="{ entity_type: 'stock', identifier: item.code, code: item.code, name: item.code, sector: null }" /></template></small><div class="table-actions"><button v-if="item.due && !item.is_read" class="btn btn-small" type="button" @click="mark(item)">标为已读</button><button class="btn btn-small" type="button" @click="remove(item)">删除</button></div></article></div>
|
||||
<EmptyState v-else title="暂无提醒" description="可以创建站内提醒,策略跟踪反馈也会自动汇总到这里。" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { marketApi, type MarketEntity } from "../../shared/api/market";
|
||||
import { reviewApi, type ReviewWorkspace, type TradeEntry, type WatchItem } from "../../shared/api/review";
|
||||
import BaseDialog from "../../shared/components/BaseDialog.vue";
|
||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
import MarketEntityLink from "../../shared/market/MarketEntityLink.vue";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import TradeDialog from "./TradeDialog.vue";
|
||||
@@ -111,7 +112,7 @@ onMounted(load);
|
||||
<template v-else-if="data">
|
||||
<section class="card review-watch-card">
|
||||
<header class="card-header"><h2>自选追踪</h2><span class="tag">{{ data.watchlist.length }} 只</span></header>
|
||||
<div v-if="data.watchlist.length" class="data-table-wrap review-watch-table"><table class="data-table"><thead><tr><th>标记</th><th>代码</th><th>股票</th><th>板块</th><th class="numeric">今日涨幅(%)</th><th class="numeric">5日涨幅(%)</th><th class="numeric">竞价关注分</th><th>跟踪备注</th><th>操作</th></tr></thead><tbody><tr v-for="item in data.watchlist" :key="item.identifier"><td class="watch-star">★</td><td>{{ item.code }}</td><td>{{ item.name }}</td><td>{{ item.sector ?? "" }}</td><td class="numeric" :class="item.pct_chg !== null && item.pct_chg >= 0 ? 'up' : 'down'">{{ display(item.pct_chg) }}</td><td class="numeric" :class="item.return_5d !== null && item.return_5d >= 0 ? 'up' : 'down'">{{ display(item.return_5d) }}</td><td class="numeric">{{ display(item.attention_score) }}</td><td><input :value="item.remark" class="review-remark" maxlength="500" aria-label="跟踪备注" @change="saveRemark(item, $event)" /></td><td><div class="table-actions"><button class="btn btn-small" type="button" @click="removeWatch(item)">移除</button></div></td></tr></tbody></table></div>
|
||||
<div v-if="data.watchlist.length" class="data-table-wrap review-watch-table"><table class="data-table"><thead><tr><th>标记</th><th>代码</th><th>股票</th><th>板块</th><th class="numeric">今日涨幅(%)</th><th class="numeric">5日涨幅(%)</th><th class="numeric">竞价关注分</th><th>跟踪备注</th><th>操作</th></tr></thead><tbody><tr v-for="item in data.watchlist" :key="item.identifier"><td class="watch-star">★</td><td><MarketEntityLink :entity="{ entity_type: 'stock', identifier: item.identifier, code: item.code, name: item.name, sector: item.sector }" /></td><td>{{ item.name }}</td><td>{{ item.sector ?? "" }}</td><td class="numeric" :class="item.pct_chg !== null && item.pct_chg >= 0 ? 'up' : 'down'">{{ display(item.pct_chg) }}</td><td class="numeric" :class="item.return_5d !== null && item.return_5d >= 0 ? 'up' : 'down'">{{ display(item.return_5d) }}</td><td class="numeric">{{ display(item.attention_score) }}</td><td><input :value="item.remark" class="review-remark" maxlength="500" aria-label="跟踪备注" @change="saveRemark(item, $event)" /></td><td><div class="table-actions"><button class="btn btn-small" type="button" @click="removeWatch(item)">移除</button></div></td></tr></tbody></table></div>
|
||||
<EmptyState v-else title="暂无自选" description="添加股票后可集中查看涨幅、竞价关注分和跟踪备注。" />
|
||||
</section>
|
||||
|
||||
@@ -119,7 +120,7 @@ onMounted(load);
|
||||
<section class="card trade-card">
|
||||
<header class="card-header"><h2>交易日志</h2><span class="tag">{{ data.trades.length }} 条</span><button class="btn btn-primary btn-small review-card-action" type="button" @click="openTrade()">交易日志</button></header>
|
||||
<div class="review-summary"><div><span>总记录</span><strong>{{ summary?.total }}</strong></div><div><span>已实现</span><strong>{{ summary?.realized }}</strong></div><div><span>胜率</span><strong>{{ summary?.win_rate === null ? "" : display(summary?.win_rate, "%") }}</strong></div><div><span>累计盈亏</span><strong>{{ display(summary?.pnl_amount) }}</strong></div><div><span>平均仓位</span><strong>{{ summary?.average_position === null ? "" : display(summary?.average_position, "%") }}</strong></div></div>
|
||||
<div v-if="data.trades.length" class="trade-scroll"><table class="data-table"><thead><tr><th>日期</th><th>代码</th><th>股票</th><th>动作</th><th class="numeric">价格(元)</th><th class="numeric">仓位(%)</th><th class="numeric">盈亏(%)</th><th>操作</th></tr></thead><tbody><tr v-for="row in data.trades" :key="row.id"><td>{{ row.trade_date }}</td><td>{{ row.code }}</td><td>{{ row.name }}</td><td>{{ row.action_label }}</td><td class="numeric">{{ display(row.price) }}</td><td class="numeric">{{ display(row.position_pct) }}</td><td class="numeric" :class="row.pnl_pct !== null && row.pnl_pct >= 0 ? 'up' : 'down'">{{ display(row.pnl_pct) }}</td><td><div class="table-actions"><button class="btn btn-small" type="button" @click="openTrade(row)">编辑</button><button class="btn btn-small" type="button" @click="deleteTrade(row)">删除</button></div></td></tr></tbody></table></div>
|
||||
<div v-if="data.trades.length" class="trade-scroll"><table class="data-table"><thead><tr><th>日期</th><th>代码</th><th>股票</th><th>动作</th><th class="numeric">价格(元)</th><th class="numeric">仓位(%)</th><th class="numeric">盈亏(%)</th><th>操作</th></tr></thead><tbody><tr v-for="row in data.trades" :key="row.id"><td>{{ row.trade_date }}</td><td><MarketEntityLink :entity="{ entity_type: 'stock', identifier: row.code, code: row.code, name: row.name, sector: null }" /></td><td>{{ row.name }}</td><td>{{ row.action_label }}</td><td class="numeric">{{ display(row.price) }}</td><td class="numeric">{{ display(row.position_pct) }}</td><td class="numeric" :class="row.pnl_pct !== null && row.pnl_pct >= 0 ? 'up' : 'down'">{{ display(row.pnl_pct) }}</td><td><div class="table-actions"><button class="btn btn-small" type="button" @click="openTrade(row)">编辑</button><button class="btn btn-small" type="button" @click="deleteTrade(row)">删除</button></div></td></tr></tbody></table></div>
|
||||
<EmptyState v-else title="暂无交易日志" description="记录交易后,摘要与明细会保留在当前页面。" />
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Candidate, ScreenerRun } from "../../shared/api/screener";
|
||||
import MarketEntityLink from "../../shared/market/MarketEntityLink.vue";
|
||||
|
||||
defineProps<{ run?: ScreenerRun; locked?: boolean }>();
|
||||
const emit = defineEmits<{ track: [candidate: Candidate] }>();
|
||||
@@ -33,7 +34,7 @@ function number(value: number | null, digits = 2): string {
|
||||
<thead><tr><th>代码</th><th>股票</th><th>行业</th><th class="numeric">收盘价(元)</th><th class="numeric">涨跌幅(%)</th><th class="numeric">综合分</th><th>主要依据</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in run.items" :key="item.identifier">
|
||||
<td class="code-column">{{ item.code }}</td><td>{{ item.name }}</td><td>{{ item.sector }}</td>
|
||||
<td class="code-column"><MarketEntityLink :entity="{ entity_type: 'stock', identifier: item.identifier, code: item.code, name: item.name, sector: item.sector || null }" /></td><td>{{ item.name }}</td><td>{{ item.sector }}</td>
|
||||
<td class="numeric">{{ number(item.close) }}</td>
|
||||
<td class="numeric" :class="{ up: Number(item.pct_chg) > 0, down: Number(item.pct_chg) < 0 }">{{ number(item.pct_chg) }}</td>
|
||||
<td class="numeric"><strong>{{ number(item.score_display, 1) }}</strong></td>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import type { Candidate, ScreenerRun, ScreenerStrategy } from "../../shared/api/screener";
|
||||
import CandidateTable from "./CandidateTable.vue";
|
||||
@@ -7,8 +7,18 @@ import CandidateTable from "./CandidateTable.vue";
|
||||
const props = defineProps<{ strategies: ScreenerStrategy[]; runs: ScreenerRun[]; locked: boolean }>();
|
||||
const emit = defineEmits<{ track: [run: ScreenerRun, candidate: Candidate] }>();
|
||||
const selected = ref(props.runs[0]?.strategy_id ?? props.strategies[0]?.id ?? "");
|
||||
const run = computed(() => props.runs.find((item) => item.strategy_id === selected.value) ?? props.runs[0]);
|
||||
const run = computed(() => props.runs.find((item) => item.strategy_id === selected.value));
|
||||
const strategy = computed(() => props.strategies.find((item) => item.id === (run.value?.strategy_id ?? selected.value)));
|
||||
const hasRun = computed(() => Boolean(run.value));
|
||||
|
||||
watch(
|
||||
() => props.runs,
|
||||
(runs) => {
|
||||
if (!runs.some((item) => item.strategy_id === selected.value)) {
|
||||
selected.value = runs[0]?.strategy_id ?? props.strategies[0]?.id ?? "";
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,7 +29,7 @@ const strategy = computed(() => props.strategies.find((item) => item.id === (run
|
||||
<span class="tag">盘后自动</span>
|
||||
</header>
|
||||
<div class="stage-flow" aria-label="自动选股流程">
|
||||
<span class="done">阶段识别</span><i></i><span class="done">策略匹配</span><i></i><span class="done">自动计算</span><i></i><span>结果归档</span>
|
||||
<span :class="{ done: hasRun }">阶段识别</span><i></i><span :class="{ done: hasRun }">策略匹配</span><i></i><span :class="{ done: hasRun }">自动计算</span><i></i><span :class="{ done: hasRun }">结果归档</span>
|
||||
</div>
|
||||
<nav v-if="runs.length > 1" class="stage-run-tabs" aria-label="当日阶段策略">
|
||||
<button v-for="item in runs" :key="item.id" type="button" :class="{ active: selected === item.strategy_id }" @click="selected = item.strategy_id">{{ item.strategy_name }}</button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from "vue-router";
|
||||
|
||||
import { screenerApi, type StrategyTrack } from "../../shared/api/screener";
|
||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
import MarketEntityLink from "../../shared/market/MarketEntityLink.vue";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
|
||||
const router = useRouter();
|
||||
@@ -31,6 +32,6 @@ onMounted(load);
|
||||
<div v-if="loading" class="card workspace-state">正在读取跟踪记录</div>
|
||||
<EmptyState v-else-if="error" class="card" title="跟踪记录暂不可用" :description="error" />
|
||||
<EmptyState v-else-if="!rows.length" class="card" title="暂无跟踪记录" description="从任一候选结果中点击“加入跟踪”后,记录会出现在这里。" />
|
||||
<section v-else class="card tracking-table"><div class="data-table-wrap"><table class="data-table"><thead><tr><th>代码</th><th>股票</th><th>策略</th><th>入选日期</th><th class="numeric">入选价(元)</th><th class="numeric">T+1开盘(%)</th><th class="numeric">T+1收盘(%)</th><th class="numeric">T+3收盘(%)</th><th class="numeric">T+5收盘(%)</th><th class="numeric">最大涨幅(%)</th><th class="numeric">最大回撤(%)</th><th>操作</th></tr></thead><tbody><tr v-for="row in rows" :key="row.id"><td>{{ row.code }}</td><td>{{ row.name }}</td><td>{{ row.strategy_name }}</td><td>{{ row.selection_date }}</td><td class="numeric">{{ number(row.entry_price) }}</td><td class="numeric">{{ number(row.t1_open_return) }}</td><td class="numeric">{{ number(row.t1_return) }}</td><td class="numeric">{{ number(row.t3_return) }}</td><td class="numeric">{{ number(row.t5_return) }}</td><td class="numeric up">{{ number(row.max_gain) }}</td><td class="numeric down">{{ number(row.max_drawdown) }}</td><td><button class="btn btn-small" type="button" @click="remove(row)">停止</button></td></tr></tbody></table></div></section>
|
||||
<section v-else class="card tracking-table"><div class="data-table-wrap"><table class="data-table"><thead><tr><th>代码</th><th>股票</th><th>策略</th><th>入选日期</th><th class="numeric">入选价(元)</th><th class="numeric">T+1开盘(%)</th><th class="numeric">T+1收盘(%)</th><th class="numeric">T+3收盘(%)</th><th class="numeric">T+5收盘(%)</th><th class="numeric">最大涨幅(%)</th><th class="numeric">最大回撤(%)</th><th>操作</th></tr></thead><tbody><tr v-for="row in rows" :key="row.id"><td><MarketEntityLink :entity="{ entity_type: 'stock', identifier: row.identifier, code: row.code, name: row.name, sector: row.sector }" /></td><td>{{ row.name }}</td><td>{{ row.strategy_name }}</td><td>{{ row.selection_date }}</td><td class="numeric">{{ number(row.entry_price) }}</td><td class="numeric">{{ number(row.t1_open_return) }}</td><td class="numeric">{{ number(row.t1_return) }}</td><td class="numeric">{{ number(row.t3_return) }}</td><td class="numeric">{{ number(row.t5_return) }}</td><td class="numeric up">{{ number(row.max_gain) }}</td><td class="numeric down">{{ number(row.max_drawdown) }}</td><td><button class="btn btn-small" type="button" @click="remove(row)">停止</button></td></tr></tbody></table></div></section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -56,6 +56,33 @@ export type ChartSeries = {
|
||||
points: ChartPoint[];
|
||||
};
|
||||
|
||||
export type EntityMetric = { key: string; label: string; value: number | null; unit: string };
|
||||
export type EntityDetail = {
|
||||
entity: MarketEntity;
|
||||
trade_date: string;
|
||||
observed_at: string;
|
||||
price: number;
|
||||
previous_close: number | null;
|
||||
change: number | null;
|
||||
metrics: EntityMetric[];
|
||||
money_flow: {
|
||||
available: boolean;
|
||||
net_million: number | null;
|
||||
large_million: number | null;
|
||||
net_5d_million: number | null;
|
||||
flow_to_circ_mv_5d: number | null;
|
||||
} | null;
|
||||
event: {
|
||||
status: string;
|
||||
reason: string;
|
||||
streak: number | null;
|
||||
first_time: string | null;
|
||||
last_time: string | null;
|
||||
open_times: number | null;
|
||||
seal_amount: number | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type MarketWorkspaceData = {
|
||||
trade_date: string | null;
|
||||
observed_at?: string;
|
||||
@@ -125,6 +152,11 @@ export const marketApi = {
|
||||
const identifier = encodeURIComponent(entity.identifier);
|
||||
return api.get<ChartSeries>(`/market/entities/${type}/${identifier}/charts/${interval}`);
|
||||
},
|
||||
detail(entity: MarketEntity, date: string): Promise<EntityDetail> {
|
||||
const type = encodeURIComponent(entity.entity_type);
|
||||
const identifier = encodeURIComponent(entity.identifier);
|
||||
return api.get<EntityDetail>(`/market/entities/${type}/${identifier}/detail?date=${encodeURIComponent(date)}`);
|
||||
},
|
||||
workspace(key: string, date: string): Promise<MarketWorkspaceData> {
|
||||
return api.get<MarketWorkspaceData>(
|
||||
`/market/workspaces/${encodeURIComponent(key)}?date=${encodeURIComponent(date)}`,
|
||||
|
||||
@@ -67,6 +67,7 @@ export type ScreenerWorkspace = {
|
||||
};
|
||||
export type StrategyTrack = {
|
||||
id: number;
|
||||
identifier: string;
|
||||
code: string;
|
||||
name: string;
|
||||
sector: string;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import MarketEntityLink from "../market/MarketEntityLink.vue";
|
||||
|
||||
type TableColumn = {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -9,12 +11,13 @@ type TableColumn = {
|
||||
format?: (value: unknown, row: Record<string, unknown>) => string;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
columns: TableColumn[];
|
||||
rows: Record<string, unknown>[];
|
||||
sortKey: string;
|
||||
sortDirection: "asc" | "desc";
|
||||
}>();
|
||||
entityType?: "stock" | "sector" | "theme" | "index";
|
||||
}>(), { entityType: "stock" });
|
||||
const emit = defineEmits<{ sort: [key: string] }>();
|
||||
|
||||
function ariaSort(key: string, sortable?: boolean): "ascending" | "descending" | "none" | undefined {
|
||||
@@ -42,7 +45,12 @@ function ariaSort(key: string, sortable?: boolean): "ascending" | "descending" |
|
||||
<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" :data-label="column.label" :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] ?? "") }}
|
||||
<MarketEntityLink
|
||||
v-if="column.code && row.identifier && row.code && row.name"
|
||||
:entity="{ entity_type: entityType, identifier: String(row.identifier), code: String(row.code), name: String(row.name), sector: row.sector ? String(row.sector) : null }"
|
||||
:label="String(column.format ? column.format(row[column.key], row) : row[column.key])"
|
||||
/>
|
||||
<template v-else>{{ column.format ? column.format(row[column.key], row) : (row[column.key] ?? "") }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import type { MarketEntity } from "../api/market";
|
||||
import MarketPreviewPanel from "./MarketPreviewPanel.vue";
|
||||
|
||||
const props = defineProps<{ entity: MarketEntity; label?: string }>();
|
||||
const router = useRouter();
|
||||
const anchor = ref<HTMLElement | null>(null);
|
||||
const visible = ref(false);
|
||||
const position = ref({ left: 0, top: 0 });
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const text = computed(() => props.label ?? props.entity.code);
|
||||
|
||||
function show(): void {
|
||||
if (matchMedia("(hover: none)").matches) return;
|
||||
if (timer) clearTimeout(timer);
|
||||
const rect = anchor.value?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const width = Math.min(480, window.innerWidth - 32);
|
||||
const height = 360;
|
||||
position.value = {
|
||||
left: Math.max(16, Math.min(rect.left, window.innerWidth - width - 16)),
|
||||
top: rect.bottom + height + 12 <= window.innerHeight
|
||||
? rect.bottom + 8
|
||||
: Math.max(8, rect.top - height - 8),
|
||||
};
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
function scheduleClose(): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => { visible.value = false; }, 180);
|
||||
}
|
||||
|
||||
function openDetail(): void {
|
||||
visible.value = false;
|
||||
void router.push({
|
||||
name: "entity-detail",
|
||||
params: { entityType: props.entity.entity_type, identifier: props.entity.identifier },
|
||||
query: { code: props.entity.code, name: props.entity.name, sector: props.entity.sector ?? undefined },
|
||||
});
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => timer && clearTimeout(timer));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button ref="anchor" class="market-entity-link" type="button" @mouseenter="show" @mouseleave="scheduleClose" @focus="show" @blur="scheduleClose" @click="openDetail">{{ text }}</button>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="market-hover-popover" :style="{ left: `${position.left}px`, top: `${position.top}px` }" @mouseenter="show" @mouseleave="scheduleClose">
|
||||
<MarketPreviewPanel :entity="entity" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -124,3 +124,143 @@
|
||||
.entity-chart {
|
||||
min-height: var(--s-400);
|
||||
}
|
||||
|
||||
.market-entity-link {
|
||||
padding: 0;
|
||||
color: var(--color-primary);
|
||||
background: var(--c-transparent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.market-entity-link:hover {
|
||||
color: var(--color-primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.market-hover-popover {
|
||||
position: fixed;
|
||||
z-index: var(--z-popover);
|
||||
width: min(var(--s-480), calc(100vw - var(--s-32)));
|
||||
height: var(--s-360);
|
||||
overflow: hidden;
|
||||
border: var(--s-1) solid var(--color-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.market-hover-popover .market-preview {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.entity-detail-page {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--layout-gap);
|
||||
}
|
||||
|
||||
.entity-detail-heading {
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.entity-detail-heading > div:first-child {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.entity-detail-code {
|
||||
color: var(--color-text-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.entity-detail-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.entity-detail-price strong {
|
||||
font-size: var(--font-24);
|
||||
}
|
||||
|
||||
.entity-detail-price small {
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
.entity-detail-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.entity-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--s-320);
|
||||
gap: var(--layout-gap);
|
||||
}
|
||||
|
||||
.entity-metrics-card,
|
||||
.entity-flow-card,
|
||||
.entity-event-card,
|
||||
.entity-members-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.entity-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-1);
|
||||
background: var(--color-divider);
|
||||
}
|
||||
|
||||
.entity-metrics > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--s-4);
|
||||
padding: var(--s-10) var(--s-12);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.entity-metrics dt,
|
||||
.entity-event-body dt {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.entity-metrics dd,
|
||||
.entity-event-body dd {
|
||||
margin: 0;
|
||||
font-weight: var(--weight-600);
|
||||
}
|
||||
|
||||
.entity-stock-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--layout-gap);
|
||||
}
|
||||
|
||||
.entity-event-body {
|
||||
display: grid;
|
||||
gap: var(--s-16);
|
||||
padding: var(--s-16);
|
||||
}
|
||||
|
||||
.entity-event-body > strong {
|
||||
line-height: var(--s-20);
|
||||
}
|
||||
|
||||
.entity-event-body dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--s-8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.entity-event-body dl > div {
|
||||
display: grid;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,31 @@
|
||||
padding: var(--s-12) var(--s-10);
|
||||
}
|
||||
|
||||
.entity-detail-heading,
|
||||
.entity-detail-heading > div:first-child,
|
||||
.entity-detail-price,
|
||||
.entity-detail-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.entity-detail-actions {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.entity-detail-grid,
|
||||
.entity-stock-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.entity-metrics {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.market-hover-popover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-context-bar {
|
||||
display: grid;
|
||||
gap: var(--s-10);
|
||||
|
||||
Reference in New Issue
Block a user