Initial AlphaPulse implementation: news crawler and Binance klines

- Backend (FastAPI, SQLAlchemy 2, Alembic): single-admin cookie auth,
  news/crawler/market APIs with SSE and WebSocket streaming
- Crawler (Scrapy + Scrapy-Redis): wscn_news, wscn_live, cls_depth,
  cls_telegraph spiders, idempotent upserts with revisions, raw response
  archiving, per-source subprocess supervision, restart and backoff
- Market worker: full-history Binance spot/USD-M kline backfill,
  realtime WS stream, hourly and on-reconnect catch-up
- Frontend (Vue 3, Element Plus, UnoCSS, KLineChart): news feed,
  crawler console with live logs, Binance-style chart page
- Deploy: docker compose, nginx site, migrate and daily backup scripts
This commit is contained in:
eezd committed 2026-10-13 13:32:55 +08:00
commit aabc81ba5b
88 files changed
+12603

No files matched your search

@@ -0,0 +1,153 @@
<script lang="ts" setup>
import type { CrawlLog, SourceInfo } from "@/apis/crawler"
import type { NewsBrief } from "@/apis/news"
import { patchSource } from "@/apis/crawler"
import { listNews } from "@/apis/news"
import { formatTime } from "@/utils/text"
const props = defineProps<{
source: SourceInfo
logs: CrawlLog[]
restarting: boolean
}>()
const emit = defineEmits<{
restart: []
changed: []
}>()
const LEVELS = ["INFO", "WARNING", "ERROR"]
const levels = ref([...LEVELS])
const autoScroll = ref(true)
const logBox = ref<HTMLElement>()
const latest = ref<NewsBrief[]>([])
const interval = ref(props.source.interval_seconds)
const visibleLogs = computed(() => props.logs.filter(log => levels.value.includes(log.level) || (log.level === "CRITICAL" && levels.value.includes("ERROR"))))
watch(() => props.logs.length, async () => {
if (!autoScroll.value)
return
await nextTick()
logBox.value?.scrollTo({ top: logBox.value.scrollHeight })
}, { immediate: true })
async function loadLatest() {
latest.value = (await listNews({ source: [props.source.code], limit: 15 })).items
}
// 本来源有新的“本轮完成”日志时刷新最新入库列表
watch(() => props.logs.at(-1), (log) => {
if (log?.message.startsWith("本轮完成") && !log.message.includes("新增 0,修订 0"))
loadLatest()
})
async function toggleEnabled(enabled: boolean | string | number) {
await patchSource(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 })
ElMessage.success("已保存,下一轮起生效")
emit("changed")
}
const statusTag = computed(() => {
const process = props.source.process
if (props.restarting || process?.state === "restarting")
return { type: "warning", text: "重启中" } as const
if (!process)
return { type: "danger", text: "管理进程未运行" } as const
if (process.state === "stopped")
return { type: "danger", text: "子进程未运行" } as const
if (!props.source.enabled)
return { type: "info", text: "已停用" } as const
if (props.source.paused_until && new Date(props.source.paused_until) > new Date())
return { type: "warning", text: "失败退避中" } as const
return { type: "success", text: "运行中" } as const
})
loadLatest()
</script>
<template>
<div class="h-full flex flex-col gap-3">
<el-descriptions :column="4" border size="small">
<template #extra>
<el-button type="warning" :loading="restarting" @click="emit('restart')">
重启该来源
</el-button>
</template>
<el-descriptions-item label="状态">
<el-tag :type="statusTag.type" size="small">
{{ statusTag.text }}
</el-tag>
<span v-if="source.process?.pid" class="ml-2 text-xs text-[var(--el-text-color-secondary)]">pid {{ source.process.pid }}</span>
</el-descriptions-item>
<el-descriptions-item label="上次成功">
{{ formatTime(source.last_success_at) }}
</el-descriptions-item>
<el-descriptions-item label="连续失败">
<span :class="{ 'text-red-500 font-bold': source.consecutive_failures > 0 }">{{ source.consecutive_failures }}</span>
</el-descriptions-item>
<el-descriptions-item label="下次运行">
{{ formatTime(source.paused_until && source.paused_until > (source.process?.next_run_at ?? "") ? source.paused_until : source.process?.next_run_at) }}
</el-descriptions-item>
<el-descriptions-item label="今日新增 / 修订">
{{ source.today_new }} / {{ source.today_revised }}
</el-descriptions-item>
<el-descriptions-item label="累计条目">
{{ source.total_items }}
</el-descriptions-item>
<el-descriptions-item label="启用">
<el-switch :model-value="source.enabled" @change="toggleEnabled" />
</el-descriptions-item>
<el-descriptions-item label="采集间隔">
<el-input-number v-model="interval" :min="5" :max="86400" size="small" controls-position="right" class="w-28" @change="saveInterval" />
秒
</el-descriptions-item>
<el-descriptions-item v-if="source.last_error" label="最近错误" :span="4">
<span class="whitespace-pre-wrap text-xs text-red-500">{{ source.last_error }}</span>
</el-descriptions-item>
</el-descriptions>
<div class="min-h-0 flex flex-1 gap-3">
<el-card class="w-2/5 flex flex-col" body-class="min-h-0 flex-1 overflow-auto" shadow="never">
<template #header>
<span>最新入库</span>
<el-link :href="source.list_url" target="_blank" type="primary" class="ml-2">
原站
</el-link>
</template>
<div v-for="item in latest" :key="item.id" class="mb-2 text-sm">
<span class="mr-2 text-xs text-[var(--el-text-color-secondary)]">{{ formatTime(item.published_at, "MM-DD HH:mm") }}</span>
<el-link :href="item.url" target="_blank">
{{ item.title || item.excerpt.slice(0, 60) }}
</el-link>
</div>
</el-card>
<el-card class="w-3/5 flex flex-col" body-class="min-h-0 flex-1 flex flex-col" shadow="never">
<template #header>
<div class="flex items-center gap-3">
<span>实时日志</span>
<el-checkbox-group v-model="levels" size="small">
<el-checkbox-button v-for="level in LEVELS" :key="level" :value="level">
{{ level }}
</el-checkbox-button>
</el-checkbox-group>
<el-switch v-model="autoScroll" active-text="自动滚动" size="small" />
</div>
</template>
<div ref="logBox" class="min-h-0 flex-1 overflow-auto rounded bg-[#1e1e1e] p-2 text-xs text-[#d4d4d4] font-mono leading-5">
<div v-for="log in visibleLogs" :key="log.id" class="whitespace-pre-wrap break-all">
<span class="text-[#808080]">{{ formatTime(log.ts) }}</span>
<span :class="{ 'text-yellow-400': log.level === 'WARNING', 'text-red-400': log.level === 'ERROR' || log.level === 'CRITICAL' }" class="mx-2">{{ log.level }}</span>{{ log.message }}
</div>
</div>
</el-card>
</div>
</div>
</template>
+114
View File
@@ -0,0 +1,114 @@
<script lang="ts" setup>
import type { CrawlLog, SourcesInfo } from "@/apis/crawler"
import { listLogs, listSources, restartCrawler } from "@/apis/crawler"
import { useEventSource } from "@/composables/useEventSource"
import SourcePanel from "@/pages/crawler/components/SourcePanel.vue"
const MAX_LOGS = 1000
// 连续失败达到该次数时 TAB 标红
const FAILURE_ALERT = 3
const RESTART_TIMEOUT_MS = 60_000
const info = ref<SourcesInfo>()
const activeTab = ref<string>()
const logs = reactive<Record<string, CrawlLog[]>>({})
// 正在等待重新上线的来源
const restarting = reactive(new Set<string>())
async function refresh() {
info.value = await listSources()
activeTab.value ??= info.value.sources[0]?.code
}
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" })
const codes = source === "all" ? info.value!.sources.map(s => s.code) : [source]
codes.forEach(code => restarting.add(code))
try {
await restartCrawler(source)
} catch (error) {
codes.forEach(code => restarting.delete(code))
throw error
}
setTimeout(() => {
const stuck = codes.filter(code => restarting.has(code))
if (stuck.length) {
stuck.forEach(code => restarting.delete(code))
ElMessage.warning(`${stuck.join("、")} ${RESTART_TIMEOUT_MS / 1000}s 内未收到上线日志,请查看日志`)
}
}, RESTART_TIMEOUT_MS)
}
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)
onBeforeUnmount(() => clearInterval(timer))
refresh().then(() => Promise.all(info.value!.sources.map(s => loadHistory(s.code))))
</script>
<template>
<div v-if="info" class="h-full flex flex-col gap-3">
<div class="flex items-center gap-3">
<el-tag :type="info.manager_alive ? 'success' : 'danger'">
{{ info.manager_alive ? "管理进程运行中" : "管理进程未运行" }}
</el-tag>
<el-tag :type="connected ? 'success' : 'danger'">
{{ connected ? "日志推送已连接" : "日志推送断开" }}
</el-tag>
<span class="text-sm text-[var(--el-text-color-secondary)]">数据库 {{ formatBytes(info.db_size_bytes) }}</span>
<div class="flex-1" />
<el-button type="danger" :loading="restarting.size > 0" @click="restart('all')">
重启全部采集
</el-button>
</div>
<el-tabs v-model="activeTab" type="border-card" class="crawler-tabs min-h-0 flex flex-1 flex-col">
<el-tab-pane v-for="source in info.sources" :key="source.code" :name="source.code" class="h-full">
<template #label>
<span :class="{ 'text-red-500 font-bold': source.consecutive_failures >= FAILURE_ALERT }">
{{ source.name }}
</span>
<el-badge v-if="source.consecutive_failures >= FAILURE_ALERT" :value="source.consecutive_failures" class="ml-1" />
</template>
<SourcePanel
:source="source"
:logs="logs[source.code] ?? []"
:restarting="restarting.has(source.code)"
@restart="restart(source.code)"
@changed="refresh"
/>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.crawler-tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts" setup>
import { useUserStore } from "@/pinia/user"
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const form = reactive({ username: "", password: "" })
const loading = ref(false)
async function submit() {
loading.value = true
try {
await userStore.login(form.username, form.password)
router.replace((route.query.redirect as string) || "/")
} finally {
loading.value = false
}
}
</script>
<template>
<div class="h-full flex-center">
<el-card class="w-90">
<template #header>
<div class="text-center text-lg font-bold">
AlphaPulse
</div>
</template>
<el-form :model="form" @submit.prevent="submit">
<el-form-item>
<el-input v-model="form.username" placeholder="用户名" autocomplete="username" />
</el-form-item>
<el-form-item>
<el-input v-model="form.password" type="password" placeholder="密码" show-password autocomplete="current-password" />
</el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" class="w-full">
登录
</el-button>
</el-form>
</el-card>
</div>
</template>
+90
View File
@@ -0,0 +1,90 @@
import type { DeepPartial, Period, Styles } from "klinecharts"
export const UP = "#0ecb81"
export const DOWN = "#f6465d"
const GRID = "#2b3139"
const TEXT = "#848e9c"
/** “分时”用 1m 数据画面积图,与币安一致 */
export const TIME_LINE = "time"
export const INTERVAL_LABELS: Record<string, string> = {
[TIME_LINE]: "分时",
"1s": "1秒",
"1m": "1分",
"3m": "3分",
"5m": "5分",
"15m": "15分",
"30m": "30分",
"1h": "1小时",
"2h": "2小时",
"4h": "4小时",
"6h": "6小时",
"8h": "8小时",
"12h": "12小时",
"1d": "1日",
"3d": "3日",
"1w": "1周",
"1M": "1月"
}
export const DEFAULT_FAVORITE_INTERVALS = [TIME_LINE, "15m", "1h", "4h", "1d", "1w"]
export const MAIN_INDICATORS = ["MA", "EMA", "BOLL", "SAR"]
export const SUB_INDICATORS = ["VOL", "MACD", "KDJ", "RSI", "OBV", "WR", "CCI", "DMI", "TRIX"]
export const DRAW_TOOLS = [
{ name: "horizontalStraightLine", label: "水平线" },
{ name: "horizontalRayLine", label: "水平射线" },
{ name: "verticalStraightLine", label: "垂直线" },
{ name: "straightLine", label: "直线" },
{ name: "rayLine", label: "射线" },
{ name: "segment", label: "线段" },
{ name: "priceLine", label: "价格线" },
{ name: "parallelStraightLine", label: "平行线" },
{ name: "priceChannelLine", label: "价格通道" },
{ name: "fibonacciLine", label: "斐波那契" },
{ name: "simpleAnnotation", label: "标注" }
]
/** 后端周期字符串 → KLineChart 周期 */
export function toPeriod(interval: string): Period {
if (interval === TIME_LINE)
return { type: "minute", span: 1 }
const span = Number.parseInt(interval)
const unit = interval.slice(String(span).length)
const type = ({ s: "second", m: "minute", h: "hour", d: "day", w: "week", M: "month" } as const)[unit]
if (!type)
throw new Error(`未知周期:${interval}`)
return { type, span }
}
/** 在内置 dark 主题上覆盖币安配色 */
export const BINANCE_STYLES: DeepPartial<Styles> = {
grid: {
horizontal: { color: GRID },
vertical: { color: GRID }
},
candle: {
bar: {
upColor: UP,
downColor: DOWN,
noChangeColor: TEXT,
upBorderColor: UP,
downBorderColor: DOWN,
noChangeBorderColor: TEXT,
upWickColor: UP,
downWickColor: DOWN,
noChangeWickColor: TEXT
},
priceMark: {
last: { upColor: UP, downColor: DOWN, noChangeColor: TEXT }
}
},
indicator: {
ohlc: { upColor: UP, downColor: DOWN, noChangeColor: TEXT }
},
xAxis: { axisLine: { color: GRID }, tickText: { color: TEXT } },
yAxis: { axisLine: { color: GRID }, tickText: { color: TEXT } },
separator: { color: GRID }
}
@@ -0,0 +1,70 @@
<script lang="ts" setup>
import type { Market, TradableSymbol } from "@/apis/market"
import { addSymbol, listTradable } from "@/apis/market"
const emit = defineEmits<{ added: [market: Market, symbol: string] }>()
const visible = defineModel<boolean>({ required: true })
const market = ref<Market>("spot")
const symbol = ref("")
const tradable = ref<TradableSymbol[]>([])
const loading = ref(false)
const submitting = ref(false)
const options = computed(() => tradable.value.map(s => ({ value: s.symbol, label: `${s.base}/${s.quote}` })))
watch([visible, market], async ([open]) => {
if (!open)
return
symbol.value = ""
loading.value = true
try {
tradable.value = await listTradable(market.value)
} finally {
loading.value = false
}
}, { immediate: true })
async function submit() {
submitting.value = true
try {
await addSymbol(market.value, symbol.value)
ElMessage.success(`已添加 ${symbol.value},后台开始补齐全历史数据`)
emit("added", market.value, symbol.value)
visible.value = false
} finally {
submitting.value = false
}
}
</script>
<template>
<el-dialog v-model="visible" title="新增交易对" width="460px">
<el-form label-width="64px">
<el-form-item label="市场">
<el-radio-group v-model="market">
<el-radio-button value="spot">
现货
</el-radio-button>
<el-radio-button value="um">
U 本位永续
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="交易对">
<el-select-v2
v-model="symbol" :options="options" :loading="loading" filterable placeholder="搜索,如 SNDKUSDT"
class="w-full"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">
取消
</el-button>
<el-button type="primary" :disabled="!symbol" :loading="submitting" @click="submit">
确认并补齐数据
</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,75 @@
<script lang="ts" setup>
import type { MarketSymbol } from "@/apis/market"
import { deleteSymbol } from "@/apis/market"
import { INTERVAL_LABELS } from "@/pages/market/chart"
defineProps<{ symbols: MarketSymbol[] }>()
const emit = defineEmits<{ changed: [] }>()
const visible = defineModel<boolean>({ required: true })
const STATUS = {
pending: { text: "排队中", type: "info" },
backfilling: { text: "补齐中", type: "warning" },
ready: { text: "已就绪", type: "success" },
error: { text: "出错", type: "danger" },
delisted: { text: "已下架", type: "info" }
} as const
function overall(s: MarketSymbol) {
const values = Object.values(s.progress)
return Math.round(values.reduce((sum, p) => sum + p.percent, 0) / values.length)
}
async function remove(s: MarketSymbol) {
await ElMessageBox.confirm(`删除 ${s.symbol}(${s.market === "spot" ? "现货" : "永续"})及其全部 K 线?之后可重新添加并补齐。`, "删除确认", { type: "warning" })
await deleteSymbol(s.id)
ElMessage.success("已删除")
emit("changed")
}
</script>
<template>
<el-drawer v-model="visible" title="交易对管理" size="640px">
<el-table :data="symbols" row-key="id">
<el-table-column type="expand">
<template #default="{ row }">
<div class="grid grid-cols-3 gap-x-6 gap-y-1 px-4">
<div v-for="(p, interval) in row.progress" :key="interval" class="flex items-center gap-2 text-xs">
<span class="w-12">{{ INTERVAL_LABELS[interval] }}</span>
<el-progress :percentage="p.percent" :status="p.done ? 'success' : undefined" class="flex-1" />
</div>
</div>
</template>
</el-table-column>
<el-table-column label="交易对" min-width="120">
<template #default="{ row }">
{{ row.base }}/{{ row.quote }}
<el-tag size="small" class="ml-1">
{{ row.market === "spot" ? "现货" : "永续" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tooltip :disabled="!row.last_error" :content="row.last_error ?? ''">
<el-tag :type="STATUS[row.status as keyof typeof STATUS].type" size="small">
{{ STATUS[row.status as keyof typeof STATUS].text }}
</el-tag>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="补齐进度" min-width="140">
<template #default="{ row }">
<el-progress :percentage="overall(row as MarketSymbol)" :status="row.status === 'ready' ? 'success' : undefined" />
</template>
</el-table-column>
<el-table-column width="70">
<template #default="{ row }">
<el-button link type="danger" @click="remove(row as MarketSymbol)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
</template>
+369
View File
@@ -0,0 +1,369 @@
<script lang="ts" setup>
import type { CandleType, Chart, DataLoader, KLineData } from "klinecharts"
import type { KlineMessage, TickerMessage } from "./useMarketSocket"
import type { KlineRow, Market, MarketSymbol } from "@/apis/market"
import { dispose, init } from "klinecharts"
import { getTicker, listKlines, listSymbols } from "@/apis/market"
import { usePersisted } from "@/utils/storage"
import { BINANCE_STYLES, DEFAULT_FAVORITE_INTERVALS, DOWN, DRAW_TOOLS, INTERVAL_LABELS, MAIN_INDICATORS, SUB_INDICATORS, TIME_LINE, toPeriod, UP } from "./chart"
import AddSymbolDialog from "./components/AddSymbolDialog.vue"
import SymbolManager from "./components/SymbolManager.vue"
import { useMarketSocket } from "./useMarketSocket"
const PAGE_SIZE = 500
const CANDLE_PANE = "candle_pane"
const SYMBOLS_REFRESH_MS = 5000
const symbols = ref<MarketSymbol[]>([])
const selection = usePersisted<{ market: Market, symbol: string }>("market.selection", { market: "spot", symbol: "BTCUSDT" })
const interval = usePersisted("market.interval", "15m")
const favorites = usePersisted("market.favorites", DEFAULT_FAVORITE_INTERVALS)
const mainIndicators = usePersisted("market.mainIndicators", ["MA"])
const subIndicators = usePersisted("market.subIndicators", ["VOL"])
// 指标参数,逗号分隔,如 MA: "7,25,99"
const indicatorParams = usePersisted<Record<string, string>>("market.indicatorParams", { MA: "7,25,99" })
const candleType = usePersisted<CandleType>("market.candleType", "candle_solid")
const addVisible = ref(false)
const managerVisible = ref(false)
const editingFavorites = ref(false)
const chartEl = ref<HTMLElement>()
const current = computed(() => symbols.value.find(s => s.market === selection.value.market && s.symbol === selection.value.symbol))
// “分时”用 1m 数据
const dataInterval = computed(() => interval.value === TIME_LINE ? "1m" : interval.value)
const intervals = computed(() => Object.keys(INTERVAL_LABELS).filter(i => i !== "1s" || selection.value.market === "spot"))
const backfillProgress = computed(() => {
const progress = current.value?.progress[dataInterval.value]
return progress && !progress.done && dataInterval.value !== "1s" ? progress.percent : null
})
const ticker = reactive({ last: 0, open: 0, high: 0, low: 0, volume: 0, quoteVolume: 0, changePercent: 0 })
const priceColor = computed(() => ticker.changePercent >= 0 ? UP : DOWN)
let chart: Chart | null = null
let barCallback: ((bar: KLineData) => void) | undefined
// 切换交易对或周期后,丢弃旧请求的返回
let loadVersion = 0
function toBar(r: KlineRow): KLineData {
return { timestamp: r[0], open: r[1], high: r[2], low: r[3], close: r[4], volume: r[5], turnover: r[6] }
}
const dataLoader: DataLoader = {
async getBars({ type, timestamp, callback }) {
if (type === "backward") {
callback([], { backward: false })
return
}
const version = loadVersion
const { market, symbol } = selection.value
const rows = await listKlines({
market,
symbol,
interval: dataInterval.value,
end: type === "forward" ? timestamp ?? undefined : undefined,
limit: PAGE_SIZE
})
if (version === loadVersion)
callback(rows.map(toBar), { forward: rows.length === PAGE_SIZE, backward: false })
},
subscribeBar({ callback }) {
barCallback = callback
},
unsubscribeBar() {
barCallback = undefined
}
}
const { connected, subscribe } = useMarketSocket(
(k: KlineMessage) => barCallback?.({ timestamp: k.t, open: +k.o, high: +k.h, low: +k.l, close: +k.c, volume: +k.v, turnover: +k.q }),
(t: TickerMessage) => {
Object.assign(ticker, { last: +t.c, open: +t.o, high: +t.h, low: +t.l, volume: +t.v, quoteVolume: +t.q })
ticker.changePercent = ticker.open ? (ticker.last - ticker.open) / ticker.open * 100 : 0
}
)
async function loadTicker() {
const { market, symbol } = selection.value
const t = await getTicker(market, symbol)
Object.assign(ticker, {
last: +t.lastPrice,
high: +t.highPrice,
low: +t.lowPrice,
volume: +t.volume,
quoteVolume: +t.quoteVolume,
changePercent: +t.priceChangePercent
})
}
function applyChartState() {
const s = current.value
if (!chart || !s)
return
loadVersion++
chart.setStyles({ candle: { type: interval.value === TIME_LINE ? "area" : candleType.value } })
chart.setSymbol({ ticker: s.symbol, pricePrecision: s.price_precision, volumePrecision: s.volume_precision })
chart.setPeriod(toPeriod(dataInterval.value))
subscribe({ market: s.market, symbol: s.symbol, interval: dataInterval.value })
}
function applyIndicators() {
if (!chart)
return
MAIN_INDICATORS.forEach(name => chart!.removeIndicator({ paneId: CANDLE_PANE, name }))
SUB_INDICATORS.forEach(name => chart!.removeIndicator({ name }))
const calcParams = (name: string) => {
const raw = indicatorParams.value[name]?.trim()
return raw ? { calcParams: raw.split(/[,,\s]+/).map(Number).filter(n => !Number.isNaN(n)) } : {}
}
mainIndicators.value.forEach(name => chart!.createIndicator({ name, paneId: CANDLE_PANE, ...calcParams(name) }, true))
subIndicators.value.forEach(name => chart!.createIndicator({ name, ...calcParams(name) }))
}
function selectSymbol(value: string) {
const [market, symbol] = value.split(":") as [Market, string]
selection.value = { market, symbol }
if (market === "um" && interval.value === "1s")
interval.value = "1m"
}
function toggleFavorite(i: string) {
favorites.value = favorites.value.includes(i)
? favorites.value.filter(f => f !== i)
: intervals.value.filter(x => x === i || favorites.value.includes(x))
}
async function refreshSymbols() {
symbols.value = await listSymbols()
}
function onAdded(market: Market, symbol: string) {
refreshSymbols().then(() => selectSymbol(`${market}:${symbol}`))
}
function formatNumber(value: number, digits = 2) {
return value.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits })
}
function formatBig(value: number) {
if (value >= 1e8)
return `${(value / 1e8).toFixed(2)}亿`
if (value >= 1e4)
return `${(value / 1e4).toFixed(2)}万`
return formatNumber(value)
}
watch([() => selection.value.market, () => selection.value.symbol], () => {
applyChartState()
loadTicker()
})
watch([interval, candleType], applyChartState)
watch([mainIndicators, subIndicators, indicatorParams], applyIndicators, { deep: true })
let resizeObserver: ResizeObserver | undefined
const refreshTimer = setInterval(refreshSymbols, SYMBOLS_REFRESH_MS)
onMounted(async () => {
await refreshSymbols()
if (!current.value && symbols.value.length)
selection.value = { market: symbols.value[0].market, symbol: symbols.value[0].symbol }
chart = init(chartEl.value!, { locale: "zh-CN", timezone: "Asia/Shanghai", styles: "dark" })
chart!.setStyles(BINANCE_STYLES)
chart!.setDataLoader(dataLoader)
applyChartState()
applyIndicators()
loadTicker()
resizeObserver = new ResizeObserver(() => chart?.resize())
resizeObserver.observe(chartEl.value!)
})
onBeforeUnmount(() => {
clearInterval(refreshTimer)
resizeObserver?.disconnect()
if (chartEl.value)
dispose(chartEl.value)
})
</script>
<template>
<div class="market h-full flex flex-col overflow-hidden rounded bg-[#161a1e] text-[#eaecef]">
<!-- 顶栏:交易对与 24h 行情 -->
<div class="flex flex-wrap items-center gap-6 border-b border-b-solid border-[#2b3139] px-4 py-2">
<el-select
:model-value="`${selection.market}:${selection.symbol}`" filterable class="symbol-select w-52"
@change="selectSymbol"
>
<el-option-group v-for="market in (['spot', 'um'] as const)" :key="market" :label="market === 'spot' ? '现货' : 'U 本位永续'">
<el-option
v-for="s in symbols.filter(x => x.market === market)" :key="s.id"
:value="`${s.market}:${s.symbol}`" :label="`${s.base}/${s.quote}${s.market === 'um' ? ' 永续' : ''}`"
>
<span>{{ s.base }}/{{ s.quote }}</span>
<span v-if="s.status !== 'ready'" class="float-right text-xs text-[#f0b90b]">{{ s.status === "error" ? "出错" : "补齐中" }}</span>
</el-option>
</el-option-group>
</el-select>
<div class="text-xl font-bold" :style="{ color: priceColor }">
{{ current ? formatNumber(ticker.last, current.price_precision) : "-" }}
</div>
<div class="stat">
<div>24h涨跌</div>
<div :style="{ color: priceColor }">
{{ ticker.changePercent >= 0 ? "+" : "" }}{{ ticker.changePercent.toFixed(2) }}%
</div>
</div>
<div class="stat">
<div>24h最高</div>
<div>{{ current ? formatNumber(ticker.high, current.price_precision) : "-" }}</div>
</div>
<div class="stat">
<div>24h最低</div>
<div>{{ current ? formatNumber(ticker.low, current.price_precision) : "-" }}</div>
</div>
<div class="stat">
<div>24h成交量({{ current?.base }})</div>
<div>{{ formatBig(ticker.volume) }}</div>
</div>
<div class="stat">
<div>24h成交额({{ current?.quote }})</div>
<div>{{ formatBig(ticker.quoteVolume) }}</div>
</div>
<div class="flex-1" />
<span class="text-xs" :class="connected ? 'text-[#0ecb81]' : 'text-[#f6465d]'">● {{ connected ? "实时" : "断开" }}</span>
<el-button size="small" type="warning" @click="addVisible = true">
新增交易对
</el-button>
<el-button size="small" @click="managerVisible = true">
管理
</el-button>
</div>
<!-- 周期与指标工具栏 -->
<div class="flex items-center gap-1 border-b border-b-solid border-[#2b3139] px-3 py-1 text-sm">
<span
v-for="i in favorites.filter(f => intervals.includes(f))" :key="i"
class="tool" :class="{ active: interval === i }" @click="interval = i"
>{{ INTERVAL_LABELS[i] }}</span>
<el-popover placement="bottom-start" :width="300" trigger="click">
<template #reference>
<span class="tool" :class="{ active: !favorites.includes(interval) }">
{{ favorites.includes(interval) ? "更多" : INTERVAL_LABELS[interval] }} ▾
</span>
</template>
<div class="mb-2 flex items-center justify-between text-xs">
<span>选择周期</span>
<el-link type="primary" underline="never" @click="editingFavorites = !editingFavorites">
{{ editingFavorites ? "完成" : "编辑常用" }}
</el-link>
</div>
<div class="grid grid-cols-4 gap-1">
<el-button
v-for="i in intervals" :key="i" size="small" class="ml-0!"
:type="editingFavorites ? (favorites.includes(i) ? 'warning' : 'default') : (interval === i ? 'primary' : 'default')"
@click="editingFavorites ? toggleFavorite(i) : (interval = i)"
>
{{ INTERVAL_LABELS[i] }}
</el-button>
</div>
</el-popover>
<el-divider direction="vertical" />
<el-popover placement="bottom-start" :width="360" trigger="click">
<template #reference>
<span class="tool">指标 ▾</span>
</template>
<div class="mb-1 text-xs text-[var(--el-text-color-secondary)]">
主图
</div>
<el-checkbox-group v-model="mainIndicators" size="small">
<el-checkbox-button v-for="name in MAIN_INDICATORS" :key="name" :value="name">
{{ name }}
</el-checkbox-button>
</el-checkbox-group>
<div class="mb-1 mt-3 text-xs text-[var(--el-text-color-secondary)]">
副图
</div>
<el-checkbox-group v-model="subIndicators" size="small">
<el-checkbox-button v-for="name in SUB_INDICATORS" :key="name" :value="name" class="mb-1">
{{ name }}
</el-checkbox-button>
</el-checkbox-group>
<div v-if="mainIndicators.length + subIndicators.length" class="mb-1 mt-3 text-xs text-[var(--el-text-color-secondary)]">
参数(逗号分隔,留空用默认)
</div>
<div v-for="name in [...mainIndicators, ...subIndicators]" :key="name" class="mb-1 flex items-center gap-2">
<span class="w-12 text-xs">{{ name }}</span>
<el-input :model-value="indicatorParams[name]" size="small" placeholder="默认" @change="(v: string) => indicatorParams[name] = v" />
</div>
</el-popover>
<el-popover placement="bottom-start" :width="200" trigger="click">
<template #reference>
<span class="tool">设置 ▾</span>
</template>
<el-radio-group v-model="candleType" size="small" class="flex flex-col items-start">
<el-radio value="candle_solid">
实心蜡烛
</el-radio>
<el-radio value="candle_stroke">
空心蜡烛
</el-radio>
<el-radio value="candle_up_stroke">
涨空心
</el-radio>
<el-radio value="ohlc">
美国线
</el-radio>
</el-radio-group>
</el-popover>
<div class="flex-1" />
<span v-if="backfillProgress !== null" class="text-xs text-[#f0b90b]">
{{ INTERVAL_LABELS[dataInterval] }} 历史数据补齐中 {{ backfillProgress }}%,最新部分暂未完整
</span>
</div>
<div class="min-h-0 flex flex-1">
<!-- 画线工具栏 -->
<div class="flex flex-col gap-1 border-r border-r-solid border-[#2b3139] p-1 text-xs">
<span v-for="tool in DRAW_TOOLS" :key="tool.name" class="tool text-center" :title="tool.label" @click="chart?.createOverlay(tool.name)">
{{ tool.label }}
</span>
<span class="tool text-center text-[#f6465d]" title="清除全部画线" @click="chart?.removeOverlay()">清除</span>
</div>
<div ref="chartEl" class="min-w-0 flex-1" />
</div>
<AddSymbolDialog v-model="addVisible" @added="onAdded" />
<SymbolManager v-model="managerVisible" :symbols="symbols" @changed="refreshSymbols" />
</div>
</template>
<style scoped>
.stat {
font-size: 12px;
}
.stat > div:first-child {
color: #848e9c;
}
.tool {
padding: 2px 8px;
border-radius: 4px;
color: #848e9c;
cursor: pointer;
white-space: nowrap;
}
.tool:hover {
color: #eaecef;
background: #2b3139;
}
.tool.active {
color: #f0b90b;
}
.symbol-select :deep(.el-select__wrapper) {
background: #2b3139;
box-shadow: none;
}
.symbol-select :deep(.el-select__selected-item) {
color: #eaecef;
font-weight: 600;
}
</style>
+78
View File
@@ -0,0 +1,78 @@
import type { Market } from "@/apis/market"
export interface KlineMessage {
t: number
o: string
h: string
l: string
c: string
v: string
q: string
x: boolean
}
export interface TickerMessage {
c: string
o: string
h: string
l: string
v: string
q: string
E: number
}
export interface Subscription {
market: Market
symbol: string
interval: string
}
const RETRY_MAX_MS = 30_000
/** 行情 WebSocket:断线指数退避重连,重连后自动恢复当前订阅;切换订阅后丢弃旧订阅的残留消息 */
export function useMarketSocket(onKline: (k: KlineMessage) => void, onTicker: (t: TickerMessage) => void) {
const connected = ref(false)
let socket: WebSocket | undefined
let current: Subscription | undefined
let retryMs = 1000
let closed = false
function connect() {
socket = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/api/market/ws`)
socket.onopen = () => {
connected.value = true
retryMs = 1000
if (current)
socket!.send(JSON.stringify(current))
}
socket.onmessage = (event) => {
const message = JSON.parse(event.data)
if (!current || message.market !== current.market || message.symbol !== current.symbol)
return
if (message.type === "kline" && message.interval === current.interval)
onKline(message.data)
else if (message.type === "ticker")
onTicker(message.data)
}
socket.onclose = () => {
connected.value = false
if (closed)
return
setTimeout(connect, retryMs)
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS)
}
}
function subscribe(subscription: Subscription) {
current = subscription
if (socket?.readyState === WebSocket.OPEN)
socket.send(JSON.stringify(subscription))
}
connect()
onBeforeUnmount(() => {
closed = true
socket?.close()
})
return { connected, subscribe }
}
@@ -0,0 +1,90 @@
<script lang="ts" setup>
import type { NewsDetail, Revision } from "@/apis/news"
import { getNews, listRevisions } from "@/apis/news"
import { useSourceNames } from "@/composables/useSourceNames"
import { diffLines, formatTime, htmlToLines } from "@/utils/text"
const newsId = defineModel<number | null>({ required: true })
const sourceNames = useSourceNames()
const detail = ref<NewsDetail>()
const revisions = ref<Revision[]>([])
const activeTab = ref("content")
// 当前查看的修订序号,与上一版对比
const selectedRevision = ref<number>()
watch(newsId, async (id) => {
if (id == null)
return
activeTab.value = "content"
detail.value = undefined
const [news, revs] = await Promise.all([getNews(id), listRevisions(id)])
detail.value = news
revisions.value = revs
selectedRevision.value = revs[0]?.revision_no
})
// 第三方正文放进无脚本权限的 sandbox iframe,避免其中的脚本访问本站
const srcdoc = computed(() => detail.value && `<!doctype html><html><head><meta charset="utf-8">
<meta name="referrer" content="no-referrer"><base target="_blank">
<style>body{font:15px/1.8 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:#303133;margin:0 4px}img{max-width:100%;height:auto}</style>
</head><body>${detail.value.content_html}</body></html>`)
const diff = computed(() => {
const index = revisions.value.findIndex(r => r.revision_no === selectedRevision.value)
if (index < 0)
return []
const current = revisions.value[index]
const previous = revisions.value[index + 1]
const toLines = (r?: Revision) => r ? [r.title, r.summary, ...htmlToLines(r.content_html)].filter(Boolean) : []
return diffLines(toLines(previous), toLines(current))
})
</script>
<template>
<el-drawer :model-value="newsId != null" size="50%" :with-header="false" @close="newsId = null">
<div v-if="detail" class="h-full flex flex-col gap-3">
<div class="text-lg font-bold">
{{ detail.title || detail.excerpt.slice(0, 60) }}
</div>
<div class="flex flex-wrap items-center gap-2 text-sm text-[var(--el-text-color-secondary)]">
<el-tag size="small">
{{ sourceNames[detail.source_code] ?? detail.source_code }}
</el-tag>
<span>{{ formatTime(detail.published_at, "YYYY-MM-DD HH:mm:ss") }}</span>
<span v-if="detail.author">{{ detail.author }}</span>
<el-tag v-if="detail.is_paywalled" size="small" type="warning">
付费(仅公开部分)
</el-tag>
<el-tag v-for="tag in detail.tags" :key="tag" size="small" type="info">
{{ tag }}
</el-tag>
<el-link :href="detail.url" target="_blank" type="primary">
原文
</el-link>
</div>
<el-tabs v-model="activeTab" class="min-h-0 flex flex-1 flex-col">
<el-tab-pane label="正文" name="content" class="h-full">
<iframe :srcdoc="srcdoc" sandbox="allow-popups allow-popups-to-escape-sandbox" class="h-full w-full border-0" />
</el-tab-pane>
<el-tab-pane :label="`修订(${revisions.length})`" name="revisions" class="h-full overflow-auto">
<el-radio-group v-model="selectedRevision" size="small" class="mb-3">
<el-radio-button v-for="r in revisions" :key="r.revision_no" :value="r.revision_no">
v{{ r.revision_no }} · {{ formatTime(r.captured_at) }}
</el-radio-button>
</el-radio-group>
<div class="text-sm leading-7">
<div
v-for="(line, i) in diff" :key="i"
:class="{ 'bg-green-100': line.type === 'add', 'bg-red-100 line-through': line.type === 'del' }"
class="px-2"
>
{{ line.type === "add" ? "+ " : line.type === "del" ? "- " : " " }}{{ line.text }}
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
<div v-else v-loading="true" class="h-full" />
</el-drawer>
</template>
+117
View File
@@ -0,0 +1,117 @@
<script lang="ts" setup>
import type { NewsBrief, NewsEvent } from "@/apis/news"
import { listNews } from "@/apis/news"
import { useEventSource } from "@/composables/useEventSource"
import { useSourceNames } from "@/composables/useSourceNames"
import NewsDrawer from "@/pages/news/components/NewsDrawer.vue"
import { formatTime } from "@/utils/text"
const sourceNames = useSourceNames()
const filters = reactive({
source: [] as string[],
q: "",
range: null as [Date, Date] | null
})
const items = ref<NewsBrief[]>([])
const nextCursor = ref<string | null>(null)
const loading = ref(false)
const finished = ref(false)
const pendingCount = ref(0)
const openedId = ref<number | null>(null)
async function load(reset = false) {
if (loading.value || (!reset && finished.value))
return
loading.value = true
try {
const page = await listNews({
source: filters.source.length ? filters.source : undefined,
q: filters.q || undefined,
start: filters.range?.[0].toISOString(),
end: filters.range?.[1].toISOString(),
cursor: reset ? undefined : nextCursor.value ?? undefined,
limit: 30
})
items.value = reset ? page.items : [...items.value, ...page.items]
nextCursor.value = page.next_cursor
finished.value = page.next_cursor === null
if (reset)
pendingCount.value = 0
} finally {
loading.value = false
}
}
function search() {
finished.value = false
load(true)
}
// 只提示符合当前来源筛选的新条目;关键字和时间范围由刷新后的查询决定
const { connected } = useEventSource<NewsEvent>("/api/news/stream", "news", (event) => {
if (event.kind === "new" && (filters.source.length === 0 || filters.source.includes(event.source_code)))
pendingCount.value++
})
load(true)
</script>
<template>
<div class="h-full flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-3">
<el-checkbox-group v-model="filters.source" @change="search">
<el-checkbox-button v-for="(name, code) in sourceNames" :key="code" :value="code">
{{ name }}
</el-checkbox-button>
</el-checkbox-group>
<el-input v-model="filters.q" placeholder="关键字" clearable class="w-60" @keyup.enter="search" @clear="search" />
<el-date-picker
v-model="filters.range" type="datetimerange" start-placeholder="开始" end-placeholder="结束"
class="max-w-90" @change="search"
/>
<el-button type="primary" @click="search">
查询
</el-button>
<el-tag :type="connected ? 'success' : 'danger'" size="small">
{{ connected ? "实时推送已连接" : "实时推送断开" }}
</el-tag>
</div>
<el-alert
v-if="pendingCount > 0" :title="`有 ${pendingCount} 条新消息,点击刷新`" type="info" :closable="false"
class="cursor-pointer" @click="search"
/>
<div v-infinite-scroll="() => load()" :infinite-scroll-disabled="loading || finished" class="min-h-0 flex-1 overflow-auto">
<div
v-for="item in items" :key="item.id"
class="cursor-pointer border-b border-b-solid border-[var(--el-border-color-lighter)] px-2 py-3 hover:bg-[var(--el-fill-color-light)]"
@click="openedId = item.id"
>
<div class="mb-1 flex items-center gap-2 text-xs text-[var(--el-text-color-secondary)]">
<el-tag size="small">
{{ sourceNames[item.source_code] ?? item.source_code }}
</el-tag>
<span>{{ formatTime(item.published_at) }}</span>
<el-tag v-if="item.revision_no > 1" size="small" type="warning">
已修订 {{ item.revision_no - 1 }} 次
</el-tag>
<el-tag v-if="item.is_paywalled" size="small" type="info">
付费
</el-tag>
</div>
<div v-if="item.title" class="font-bold">
{{ item.title }}
</div>
<div class="line-clamp-3 text-sm text-[var(--el-text-color-regular)]">
{{ item.summary || item.excerpt }}
</div>
</div>
<div class="py-4 text-center text-sm text-[var(--el-text-color-secondary)]">
{{ loading ? "加载中…" : finished ? "没有更多了" : "" }}
</div>
</div>
<NewsDrawer v-model="openedId" />
</div>
</template>