rebuild(runtime): govern market operations and job truth

This commit is contained in:
leefer
2026-07-30 10:14:29 +08:00
parent 4fc8691eee
commit d8f0dd930c
39 changed files with 2224 additions and 79 deletions
+11 -2
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { operationsApi } from "../../shared/api/operations";
import { findWorkspace } from "../workspaceRegistry";
const route = useRoute();
@@ -9,12 +10,20 @@ const title = computed(() => {
if (typeof route.meta.title === "string") return route.meta.title;
return findWorkspace(String(route.params.workspace ?? ""))?.title ?? "小白复盘";
});
const runtimeMessage = ref("正在读取行情状态");
let timer: ReturnType<typeof setInterval> | undefined;
async function loadStatus(): Promise<void> {
try { runtimeMessage.value = (await operationsApi.status()).message; }
catch { runtimeMessage.value = "行情状态暂不可用"; }
}
onMounted(() => { void loadStatus(); timer = setInterval(loadStatus, 15_000); });
onBeforeUnmount(() => { if (timer) clearInterval(timer); });
</script>
<template>
<footer class="statusbar">
<span>{{ title }}</span>
<span class="statusbar-center">股市有风险投资需谨慎</span>
<span class="statusbar-right">等待行情数据</span>
<span class="statusbar-right">{{ runtimeMessage }}</span>
</footer>
</template>
@@ -2,6 +2,8 @@
import { onMounted, reactive, ref } from "vue";
import { api } from "../../shared/api/client";
import { operationsApi, type JobRun } from "../../shared/api/operations";
import { useMarketStore } from "../../shared/stores/market";
import { useUiStore } from "../../shared/stores/ui";
type CredentialStatus = { name: string; configured: boolean; updated_at: string | null };
@@ -12,6 +14,7 @@ const labels: Record<string, string> = {
ifind_access_token: "iFinD Access Token",
};
const ui = useUiStore();
const market = useMarketStore();
const statuses = ref<CredentialStatus[]>([]);
const values = reactive<Record<string, string>>({});
const loading = ref(true);
@@ -19,11 +22,36 @@ const errorMessage = ref("");
const syncing = ref(false);
const syncResult = ref("");
const syncError = ref("");
const jobs = ref<JobRun[]>([]);
const backfill = reactive({ start_date: market.selectedDate, end_date: market.selectedDate });
const backfilling = ref(false);
const operationMessage = ref("");
const event = reactive({
trade_date: market.selectedDate,
identifier: "",
event_type: "limit_up" as "limit_up" | "broken" | "limit_down",
reason: "",
first_time: "",
last_time: "",
open_times: null as number | null,
});
const eventBusy = ref(false);
const eventHistory = ref<Record<string, unknown>[]>([]);
const jobLabels: Record<string, string> = {
"market.refresh": "行情刷新",
"market.backfill": "历史回补",
"auction.collect": "竞价采集",
"market.event-supplement": "事件补充",
"screener.after-close": "盘后选股",
};
const statusLabels = { running: "执行中", completed: "已完成", failed: "失败" };
async function load(): Promise<void> {
loading.value = true;
try {
statuses.value = await api.get<CredentialStatus[]>("/admin/system/credentials");
jobs.value = await operationsApi.jobs();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "行情凭据读取失败。";
} finally {
@@ -31,6 +59,70 @@ async function load(): Promise<void> {
}
}
async function loadJobs(): Promise<void> {
try { jobs.value = await operationsApi.jobs(); }
catch (error) { ui.showToast(error instanceof Error ? error.message : "任务状态读取失败"); }
}
async function runBackfill(): Promise<void> {
backfilling.value = true;
operationMessage.value = "";
try {
const result = await operationsApi.backfill(backfill.start_date, backfill.end_date);
operationMessage.value = `已完成 ${Number(result.completed ?? 0)} 个交易日回补`;
ui.showToast("历史数据回补已完成");
await loadJobs();
} catch (error) {
ui.showToast(error instanceof Error ? error.message : "历史回补失败");
} finally { backfilling.value = false; }
}
async function supplementEvents(): Promise<void> {
eventBusy.value = true;
try {
const result = await operationsApi.supplementEvents(event.trade_date);
operationMessage.value = `事件原因已匹配 ${Number(result.matched ?? 0)}`;
ui.showToast("事件原因补充完成");
await loadJobs();
} catch (error) {
ui.showToast(error instanceof Error ? error.message : "事件补充暂不可用");
} finally { eventBusy.value = false; }
}
async function reviseEvent(): Promise<void> {
eventBusy.value = true;
try {
await operationsApi.reviseEvent(event.trade_date, event.identifier.trim(), {
event_type: event.event_type,
reason: event.reason,
first_time: event.first_time,
last_time: event.last_time,
open_times: event.open_times,
});
operationMessage.value = "人工修订已保存,并优先于自动补充内容";
ui.showToast("事件原因修订已保存");
await showEventHistory();
} catch (error) {
ui.showToast(error instanceof Error ? error.message : "事件修订失败");
} finally { eventBusy.value = false; }
}
async function showEventHistory(): Promise<void> {
if (!event.identifier.trim()) return;
try {
eventHistory.value = await operationsApi.eventHistory(
event.trade_date, event.identifier.trim(),
);
} catch (error) {
ui.showToast(error instanceof Error ? error.message : "修订记录读取失败");
}
}
function duration(value: number | null): string {
if (value === null) return "";
return value < 1000 ? `${value} ms` : `${(value / 1000).toFixed(1)} s`;
}
async function save(name: string): Promise<void> {
const value = values[name]?.trim();
if (!value) {
@@ -97,11 +189,32 @@ onMounted(load);
</div>
</section>
<section class="card">
<header class="card-header"><h2>历史数据回补</h2><span class="tag">暂不可用</span></header>
<div class="card-body disabled-row">
<p class="muted">历史数据回补将在行情数据迁移完成后开放</p>
<button class="btn" type="button" disabled>开始回补</button>
</div>
<header class="card-header"><h2>历史数据回补</h2><span class="faint">单次最多15个交易日</span></header>
<form class="card-body operation-form" @submit.prevent="runBackfill">
<label class="field"><span class="field-label">开始日期</span><input v-model="backfill.start_date" class="input" type="date" required /></label>
<label class="field"><span class="field-label">结束日期</span><input v-model="backfill.end_date" class="input" type="date" required /></label>
<button class="btn" type="submit" :disabled="backfilling">{{ backfilling ? "正在回补" : "开始回补" }}</button>
</form>
</section>
<section class="card">
<header class="card-header"><h2>事件原因治理</h2><span class="faint">人工修订优先并保留记录</span></header>
<form class="card-body event-form" @submit.prevent="reviseEvent">
<label class="field"><span class="field-label">数据日期</span><input v-model="event.trade_date" class="input" type="date" required /></label>
<label class="field"><span class="field-label">股票代码</span><input v-model="event.identifier" class="input" placeholder="000001.SZ" required /></label>
<label class="field"><span class="field-label">事件类型</span><select v-model="event.event_type" class="select"><option value="limit_up">涨停</option><option value="broken">炸板</option><option value="limit_down">跌停</option></select></label>
<label class="field event-reason"><span class="field-label">原因</span><input v-model="event.reason" class="input" maxlength="200" required /></label>
<label class="field"><span class="field-label">首次时间</span><input v-model="event.first_time" class="input" type="time" /></label>
<label class="field"><span class="field-label">最后时间</span><input v-model="event.last_time" class="input" type="time" /></label>
<label class="field"><span class="field-label">开板次数</span><input v-model.number="event.open_times" class="input" type="number" min="0" /></label>
<div class="table-actions event-actions"><button class="btn btn-primary" type="submit" :disabled="eventBusy">保存修订</button><button class="btn" type="button" :disabled="eventBusy" @click="supplementEvents">自动补充</button><button class="btn" type="button" @click="showEventHistory">查看记录</button></div>
</form>
<div v-if="eventHistory.length" class="data-table-wrap event-history"><table class="data-table"><thead><tr><th>时间</th><th>来源</th><th>类型</th><th>原因</th><th>修订人</th></tr></thead><tbody><tr v-for="row in eventHistory" :key="String(row.id)"><td>{{ String(row.created_at).replace('T', ' ').slice(0, 19) }}</td><td>{{ row.source === 'admin' ? '人工修订' : '自动补充' }}</td><td>{{ row.event_type }}</td><td>{{ row.reason }}</td><td>{{ row.created_by_name ?? '' }}</td></tr></tbody></table></div>
</section>
<section class="card">
<header class="card-header"><h2>后台任务状态</h2><button class="btn btn-small" type="button" @click="loadJobs">刷新状态</button></header>
<p v-if="operationMessage" class="notice">{{ operationMessage }}</p>
<div v-if="jobs.length" class="data-table-wrap operation-jobs"><table class="data-table"><thead><tr><th>任务</th><th>数据日期</th><th>状态</th><th>开始时间</th><th class="numeric">耗时</th><th class="numeric">覆盖率(%</th><th>说明</th></tr></thead><tbody><tr v-for="job in jobs" :key="job.id"><td>{{ jobLabels[job.kind] ?? job.kind }}</td><td>{{ job.requested_date }}</td><td><span class="tag" :class="{ warning: job.status === 'failed' }">{{ statusLabels[job.status] }}</span></td><td>{{ job.started_at.replace('T', ' ').slice(0, 19) }}</td><td class="numeric">{{ duration(job.duration_ms) }}</td><td class="numeric">{{ job.coverage === null ? '' : (job.coverage * 100).toFixed(1) }}</td><td>{{ job.error_message }}</td></tr></tbody></table></div>
<p v-else class="card-body muted">暂无后台任务记录</p>
</section>
</div>
</template>