Refactor frontend workspaces and centralize API contracts

Organize domain APIs and types under common/apis, shared components under named folders, and private workspace logic alongside each page.

Use Bearer sessions, explicit response envelopes, session-aware routing and streams, and configurable login captcha presentation for the deployed backend contract.

Preserve news, economic calendar, crawler, market, chat, Skills, settings, and demo trading workflows with responsive layouts and explicit configuration saves.

Validate with frontend lint, type checking, 22 test groups, production builds, and deployed desktop/mobile page checks.
This commit is contained in:
eezd committed 2026-10-26 11:39:52 +08:00
1 parent 77b33e5e77
commit bc480fb806
102 files changed
+4716 -1707

No files matched your search

@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { CalendarSourceInfo } from "@@/apis/crawler/type"
import { formatTime } from "@@/utils/text"
defineProps<{ source: CalendarSourceInfo }>()
const date = (at: string | null) => at ? formatTime(at) : "尚无记录"
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="flex items-center gap-2">
<img :src="`/flags/${source.region_code.toLowerCase()}.svg`" alt="" class="h-4 w-6 object-cover">
<span class="text-sm font-semibold">{{ source.name }}</span>
<el-tag size="small" type="info">
日历
</el-tag>
<div class="flex-1" />
<el-tag :type="!source.worker_alive ? 'danger' : source.errors.length ? 'warning' : source.schedule_success_at ? 'success' : 'info'" size="small">
{{ !source.worker_alive ? "进程离线" : source.errors.length ? "采集异常" : source.schedule_success_at ? "同步正常" : "待同步" }}
</el-tag>
</div>
</template>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-xs text-slate-500">
<dt>进程心跳</dt><dd>{{ date(source.heartbeat_at) }}</dd>
<dt>日程最近检查</dt><dd>{{ date(source.schedule_checked_at) }}</dd>
<dt>日程最近成功</dt><dd>{{ date(source.schedule_success_at) }}</dd>
<dt>最近取得结果</dt><dd>{{ date(source.latest_result_at) }}</dd>
<dt>下一场公布</dt><dd>{{ source.next_event_at ? date(source.next_event_at) : "暂未取得后续日程" }}</dd>
<dt>已到点待结果</dt><dd :class="source.overdue_count ? 'text-amber-600' : ''">
{{ source.overdue_count }} 场(最近 45 天)
</dd>
<dt>已存公告范围</dt>
<dd>{{ source.history_start ? `${date(source.history_start)} ~ ${date(source.history_end)}` : "尚无当期公告" }}</dd>
</dl>
<div v-for="error in source.errors" :key="error" class="mt-2 break-words rounded bg-rose-50 p-2 text-xs text-rose-700">
{{ error }}
</div>
<p class="mt-3 text-xs text-slate-400">
独立日历进程统一采集;范围仅表示已存首末日期,不代表历史完整。时间为北京时间。
</p>
</el-card>
</template>
@@ -1,11 +1,11 @@
<script lang="ts" setup>
import type { CrawlLog, SourceInfo } from "@/apis/crawler"
import type { NewsBrief } from "@/apis/news"
import type { CrawlLog, SourceInfo } from "@@/apis/crawler/type"
import type { NewsBrief } from "@@/apis/news/type"
import { patchSourceApi } from "@@/apis/crawler"
import { listNewsApi } from "@@/apis/news"
import { formatTime } from "@@/utils/text"
import { CircleCheck, RefreshRight } from "@element-plus/icons-vue"
import dayjs from "dayjs"
import { patchSource } from "@/apis/crawler"
import { listNews } from "@/apis/news"
import { formatTime } from "@/utils/text"
/** logs 只用来发现「本轮完成」以刷新最新入库;alert 为连续失败达到告警阈值 */
const props = defineProps<{
@@ -23,24 +23,24 @@ const latest = ref<NewsBrief[]>([])
const interval = ref(props.source.interval_seconds)
async function loadLatest() {
latest.value = (await listNews({ source: [props.source.code], limit: 5 })).items
latest.value = (await listNewsApi({ source: [props.source.code], limit: 5 })).items
}
// 本来源有新的“本轮完成”日志时刷新最新入库列表
watch(() => props.logs.at(-1), (log) => {
if (log?.message.startsWith("本轮完成") && !log.message.includes("新增 0,修订 0"))
loadLatest()
return loadLatest()
})
async function toggleEnabled(enabled: boolean | string | number) {
await patchSource(props.source.code, { enabled: Boolean(enabled) })
await patchSourceApi(props.source.code, { enabled: Boolean(enabled) })
emit("changed")
}
async function saveInterval() {
if (interval.value === props.source.interval_seconds)
return
await patchSource(props.source.code, { interval_seconds: interval.value })
await patchSourceApi(props.source.code, { interval_seconds: interval.value })
ElMessage.success("已保存,下一轮起生效")
emit("changed")
}
@@ -67,13 +67,13 @@ const history = computed(() => {
async function startHistory() {
// 按北京时间当天 0 点
await patchSource(props.source.code, { history_target: dayjs(`${historyDate.value}T00:00:00+08:00`).toISOString() })
await patchSourceApi(props.source.code, { history_target: dayjs(`${historyDate.value}T00:00:00+08:00`).toISOString() })
ElMessage.success("已开始,下一轮起分段回补")
emit("changed")
}
async function cancelHistory() {
await patchSource(props.source.code, { history_target: null })
await patchSourceApi(props.source.code, { history_target: null })
ElMessage.success("已取消,断点保留")
emit("changed")
}
@@ -93,7 +93,7 @@ const statusTag = computed(() => {
return { type: "success", text: "运行中" } as const
})
loadLatest()
onMounted(loadLatest)
</script>
<template>
@@ -160,7 +160,7 @@ loadLatest()
<span v-else>不可用</span>
</div>
<template v-if="!source.supports_history">
该来源的列表接口只能翻最近几页,无法回补历史
此来源不支持控制台连续翻页回补;官方归档由独立任务处理。
</template>
<template v-else>
<div class="flex items-center gap-2">
@@ -0,0 +1,54 @@
import type { CrawlLog } from "@@/apis/crawler/type"
import { listLogsApi, subscribeCrawlerApi } from "@@/apis/crawler"
import { useEventSource } from "@/http/useEventSource"
export function useCrawlerLogs(onStarted: (code: string) => void) {
const MAX_LOGS = 1000
const LEVELS = ["INFO", "WARNING", "ERROR"]
const logs = reactive<Record<string, CrawlLog[]>>({})
const logSource = ref("")
const levels = ref([...LEVELS])
const autoScroll = ref(true)
const logBox = ref<HTMLElement>()
// 各来源日志按全局自增 id 合并
const visibleLogs = computed(() => Object.values(logs).flat().filter(log => (!logSource.value || log.source_code === logSource.value)
&& (levels.value.includes(log.level) || (log.level === "CRITICAL" && levels.value.includes("ERROR")))).sort((a, b) => a.id - b.id).slice(-MAX_LOGS))
watch(() => visibleLogs.value.at(-1)?.id, async () => {
if (!autoScroll.value)
return
await nextTick()
logBox.value?.scrollTo({ top: logBox.value.scrollHeight })
})
function levelClass(level: string) {
if (level === "WARNING")
return "bg-amber-500/20 text-amber-300"
if (level === "ERROR" || level === "CRITICAL")
return "bg-rose-500/20 text-rose-300"
return "bg-sky-500/20 text-sky-300"
}
function appendLog(log: CrawlLog) {
const list = (logs[log.source_code] ??= [])
// SSE 可能早于历史日志到达,按 id 去重
if (list.length && list.at(-1)!.id >= log.id)
return
list.push(log)
if (list.length > MAX_LOGS)
list.splice(0, list.length - MAX_LOGS)
if (log.message.startsWith("子进程已启动"))
onStarted(log.source_code)
}
const { connected } = useEventSource<CrawlLog>(subscribeCrawlerApi, appendLog)
async function loadHistory(code: string) {
const history = await listLogsApi(code, 200)
const live = logs[code] ?? []
const firstLiveId = live[0]?.id ?? Infinity
logs[code] = [...history.filter(log => log.id < firstLiveId), ...live]
}
return { logs, logSource, levels, autoScroll, logBox, visibleLogs, levelClass, connected, loadHistory, LEVELS }
}
+159 -71
View File
@@ -1,85 +1,72 @@
<script lang="ts" setup>
import type { CrawlLog, SourcesInfo } from "@/apis/crawler"
import type { SourcesInfo } from "@@/apis/crawler/type"
import { listSourcesApi, restartCrawlerApi } from "@@/apis/crawler"
import { useSessionScope } from "@@/composables/useSessionScope"
import { formatTime } from "@@/utils/text"
import { Monitor, RefreshRight } from "@element-plus/icons-vue"
import { listLogs, listSources, restartCrawler } from "@/apis/crawler"
import { useEventSource } from "@/composables/useEventSource"
import SourcePanel from "@/pages/crawler/components/SourcePanel.vue"
import { formatTime } from "@/utils/text"
import { logRequestError } from "@/http/axios"
import CalendarSourcePanel from "./components/CalendarSourcePanel.vue"
import SourcePanel from "./components/SourcePanel.vue"
import { useCrawlerLogs } from "./composables/useCrawlerLogs"
const sessionScope = useSessionScope()
const MAX_LOGS = 1000
// 连续失败达到该次数时卡片告警
const FAILURE_ALERT = 3
const RESTART_TIMEOUT_MS = 60_000
const LEVELS = ["INFO", "WARNING", "ERROR"]
const info = ref<SourcesInfo>()
const logs = reactive<Record<string, CrawlLog[]>>({})
const loading = ref(false)
const failed = ref(false)
const detailVisible = ref(false)
const detailCode = ref("")
const detailKind = ref<"news" | "calendar">("news")
const newsDetail = computed(() => detailKind.value === "news" ? info.value?.sources.find(s => s.code === detailCode.value) : undefined)
const calendarDetail = computed(() => detailKind.value === "calendar" ? info.value?.calendar_sources.find(s => s.code === detailCode.value) : undefined)
function openDetail(code: string, kind: "news" | "calendar") {
detailCode.value = code
detailKind.value = kind
detailVisible.value = true
}
const collector = ref("all")
const region = ref("all")
const regions = { all: "全部地域", CN: "中国", US: "美国", EA: "欧元区", GB: "英国", DE: "德国", FR: "法国", GLOBAL: "全球" }
const newsSources = computed(() => info.value?.sources.filter(s => region.value === "all" || s.region_code === region.value) ?? [])
const calendarSources = computed(() => info.value?.calendar_sources.filter(s => region.value === "all" || s.region_code === region.value) ?? [])
// 正在等待重新上线的来源
const restarting = reactive(new Set<string>())
const { logs, logSource, levels, autoScroll, logBox, visibleLogs, levelClass, connected, loadHistory, LEVELS } = useCrawlerLogs(code => restarting.delete(code))
// 日志面板筛选:空字符串为全部来源
const logSource = ref("")
const levels = ref([...LEVELS])
const autoScroll = ref(true)
const logBox = ref<HTMLElement>()
async function refresh() {
info.value = await listSources()
loading.value = info.value === undefined
try {
info.value = await listSourcesApi()
failed.value = false
} catch (error) {
failed.value = true
throw error
} finally {
loading.value = false
}
}
const sourceNames = computed(() => Object.fromEntries(info.value?.sources.map(s => [s.code, s.name]) ?? []))
// 各来源日志按全局自增 id 合并
const visibleLogs = computed(() => Object.values(logs).flat().filter(log => (!logSource.value || log.source_code === logSource.value)
&& (levels.value.includes(log.level) || (log.level === "CRITICAL" && levels.value.includes("ERROR")))).sort((a, b) => a.id - b.id).slice(-MAX_LOGS))
watch(() => visibleLogs.value.at(-1)?.id, async () => {
if (!autoScroll.value)
return
await nextTick()
logBox.value?.scrollTo({ top: logBox.value.scrollHeight })
})
function levelClass(level: string) {
if (level === "WARNING")
return "bg-amber-500/20 text-amber-300"
if (level === "ERROR" || level === "CRITICAL")
return "bg-rose-500/20 text-rose-300"
return "bg-sky-500/20 text-sky-300"
}
function appendLog(log: CrawlLog) {
const list = (logs[log.source_code] ??= [])
// SSE 可能早于历史日志到达,按 id 去重
if (list.length && list.at(-1)!.id >= log.id)
return
list.push(log)
if (list.length > MAX_LOGS)
list.splice(0, list.length - MAX_LOGS)
if (log.message.startsWith("子进程已启动"))
restarting.delete(log.source_code)
}
const { connected } = useEventSource<CrawlLog>("/api/crawler/logs/stream", "log", appendLog)
async function loadHistory(code: string) {
const history = await listLogs(code, 200)
const live = logs[code] ?? []
const firstLiveId = live[0]?.id ?? Infinity
logs[code] = [...history.filter(log => log.id < firstLiveId), ...live]
}
async function restart(source: string) {
const label = source === "all" ? "全部来源" : info.value?.sources.find(s => s.code === source)?.name
await ElMessageBox.confirm(`确定重启 ${label} 的采集进程?`, "重启确认", { type: "warning" })
sessionScope.assertCurrent()
const codes = source === "all" ? info.value!.sources.map(s => s.code) : [source]
codes.forEach(code => restarting.add(code))
try {
await restartCrawler(source)
await restartCrawlerApi(source)
} catch (error) {
codes.forEach(code => restarting.delete(code))
throw error
}
setTimeout(() => {
if (!sessionScope.isCurrent()) return
const stuck = codes.filter(code => restarting.has(code))
if (stuck.length) {
stuck.forEach(code => restarting.delete(code))
@@ -92,14 +79,27 @@ function formatBytes(bytes: number) {
return bytes > 1024 ** 3 ? `${(bytes / 1024 ** 3).toFixed(2)} GB` : `${(bytes / 1024 ** 2).toFixed(1)} MB`
}
const timer = setInterval(refresh, 3000)
const timer = setInterval(() => refresh().catch(logRequestError), 3000)
onBeforeUnmount(() => clearInterval(timer))
refresh().then(() => Promise.all(info.value!.sources.map(s => loadHistory(s.code))))
onMounted(async () => {
await refresh()
await Promise.all(info.value!.sources.map(s => loadHistory(s.code)))
})
</script>
<template>
<div v-if="info" class="flex flex-col gap-4">
<div v-if="!info" v-loading="loading" class="min-h-40">
<el-result v-if="failed" icon="error" title="采集状态读取失败">
<template #extra>
<el-button @click="refresh">
重试
</el-button>
</template>
</el-result>
</div>
<div v-else class="flex flex-col gap-4">
<el-alert v-if="failed" type="error" :closable="false" title="状态刷新失败,以下为上次读取的数据" />
<el-card shadow="never" body-class="flex flex-wrap items-center gap-x-6 gap-y-2">
<div class="flex items-center gap-2 text-sm text-slate-900 font-semibold">
<el-icon><Monitor /></el-icon>
@@ -123,24 +123,112 @@ refresh().then(() => Promise.all(info.value!.sources.map(s => loadHistory(s.code
</div>
<div class="flex-1" />
<el-button type="danger" plain :icon="RefreshRight" :loading="restarting.size > 0" @click="restart('all')">
重启全部采集
重启全部新闻采集
</el-button>
</el-card>
<div class="text-sm text-slate-900 font-semibold">
采集来源({{ info.sources.length }})
</div>
<div class="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4">
<SourcePanel
v-for="source in info.sources" :key="source.code"
:source="source"
:logs="logs[source.code] ?? []"
:restarting="restarting.has(source.code)"
:alert="source.consecutive_failures >= FAILURE_ALERT"
@restart="restart(source.code)"
@changed="refresh"
/>
<div class="flex flex-wrap items-center gap-3">
<el-select v-model="collector" class="w-40">
<el-option value="all" label="全部采集类型" /><el-option value="news" label="新闻" /><el-option value="calendar" label="经济日历" />
</el-select>
<el-select v-model="region" class="w-36">
<el-option v-for="(name, code) in regions" :key="code" :value="code" :label="name" />
</el-select>
</div>
<el-card v-if="collector !== 'calendar'" shadow="never" body-class="p-0">
<template #header>
新闻来源 · {{ newsSources.length }}
</template>
<el-table :data="newsSources" size="small" @row-click="row => openDetail(row.code, 'news')">
<el-table-column prop="name" label="来源" min-width="170" />
<el-table-column label="地域" width="95">
<template #default="{ row }">
{{ regions[row.region_code as keyof typeof regions] ?? row.region_code }}
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag size="small" :type="row.enabled && row.process?.state === 'running' ? 'success' : 'info'">
{{ !row.enabled ? '已停用' : row.process?.state === 'running' ? '运行中' : '未运行' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="频率" width="85">
<template #default="{ row }">
{{ row.interval_seconds }} 秒
</template>
</el-table-column>
<el-table-column label="今日新增 / 修订" width="130">
<template #default="{ row }">
{{ row.today_new }} / {{ row.today_revised }}
</template>
</el-table-column>
<el-table-column label="最近成功" width="155">
<template #default="{ row }">
{{ row.last_success_at ? formatTime(row.last_success_at) : '—' }}
</template>
</el-table-column>
<el-table-column label="异常 / 回补" min-width="230">
<template #default="{ row }">
<span :class="row.consecutive_failures >= FAILURE_ALERT ? 'text-red-600' : ''">{{ row.last_error || (row.history_done ? '历史回补完成' : row.history_position ? `回补至 ${row.history_position}` : '—') }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click.stop="openDetail(row.code, 'news')">
详情
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card v-if="collector !== 'news'" shadow="never" body-class="p-0">
<template #header>
经济日历 · {{ calendarSources.length }} <span class="text-xs text-slate-500 font-normal">(只读)</span>
</template>
<el-table :data="calendarSources" size="small" @row-click="row => openDetail(row.code, 'calendar')">
<el-table-column prop="name" label="来源" min-width="170" />
<el-table-column label="地域" width="95">
<template #default="{ row }">
{{ regions[row.region_code as keyof typeof regions] }}
</template>
</el-table-column>
<el-table-column label="采集进程" width="110">
<template #default="{ row }">
<el-tag :type="row.worker_alive ? 'success' : 'danger'" size="small">
{{ row.worker_alive ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="日程更新" min-width="155">
<template #default="{ row }">
{{ row.schedule_success_at ? formatTime(row.schedule_success_at) : '—' }}
</template>
</el-table-column>
<el-table-column label="下个事件" min-width="155">
<template #default="{ row }">
{{ row.next_event_at ? formatTime(row.next_event_at) : '—' }}
</template>
</el-table-column>
<el-table-column prop="overdue_count" label="待取结果" width="90" />
<el-table-column label="异常" min-width="230">
<template #default="{ row }">
<span class="text-red-600">{{ row.errors.join(';') || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click.stop="openDetail(row.code, 'calendar')">
详情
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer v-model="detailVisible" :title="newsDetail?.name ?? calendarDetail?.name ?? '来源详情'" size="min(640px, 100vw)">
<SourcePanel v-if="newsDetail" :key="newsDetail.code" :source="newsDetail" :logs="logs[newsDetail.code] ?? []" :restarting="restarting.has(newsDetail.code)" :alert="newsDetail.consecutive_failures >= FAILURE_ALERT" @restart="restart(newsDetail.code)" @changed="refresh" />
<CalendarSourcePanel v-if="calendarDetail" :source="calendarDetail" />
</el-drawer>
<el-card shadow="never">
<template #header>