rebuild(audit): complete entity detail and market preview

This commit is contained in:
leefer
2026-07-30 09:25:52 +08:00
parent d1b3b977d2
commit 4fc8691eee
27 changed files with 946 additions and 35 deletions
@@ -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>