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:
commit
aabc81ba5b
88 files changed
+12603
No files matched your search
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import zhCn from "element-plus/es/locale/lang/zh-cn"
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-config-provider :locale="zhCn">
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
import { http } from "@/http"
|
||||
|
||||
export interface ProcessStatus {
|
||||
state: "running" | "stopped" | "restarting"
|
||||
pid: number | null
|
||||
started_at: string | null
|
||||
next_run_at: string
|
||||
}
|
||||
|
||||
export interface SourceInfo {
|
||||
code: string
|
||||
name: string
|
||||
list_url: string
|
||||
enabled: boolean
|
||||
interval_seconds: number
|
||||
backfill_hours: number
|
||||
consecutive_failures: number
|
||||
last_success_at: string | null
|
||||
last_error: string | null
|
||||
paused_until: string | null
|
||||
process: ProcessStatus | null
|
||||
today_new: number
|
||||
today_revised: number
|
||||
total_items: number
|
||||
}
|
||||
|
||||
export interface SourcesInfo {
|
||||
manager_alive: boolean
|
||||
db_size_bytes: number
|
||||
sources: SourceInfo[]
|
||||
}
|
||||
|
||||
export interface CrawlLog {
|
||||
id: number
|
||||
source_code: string
|
||||
run_id: string | null
|
||||
ts: string
|
||||
level: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export function listSources(): Promise<SourcesInfo> {
|
||||
return http.get("/crawler/sources")
|
||||
}
|
||||
|
||||
export function patchSource(code: string, body: { enabled?: boolean, interval_seconds?: number }): Promise<void> {
|
||||
return http.patch(`/crawler/sources/${code}`, body)
|
||||
}
|
||||
|
||||
export function listLogs(source: string, limit = 200): Promise<CrawlLog[]> {
|
||||
return http.get("/crawler/logs", { params: { source, limit } })
|
||||
}
|
||||
|
||||
export function restartCrawler(source: string): Promise<void> {
|
||||
return http.post("/crawler/restart", { source })
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { http } from "@/http"
|
||||
|
||||
export type Market = "spot" | "um"
|
||||
|
||||
export interface IntervalProgress {
|
||||
done: boolean
|
||||
percent: number
|
||||
}
|
||||
|
||||
export interface MarketSymbol {
|
||||
id: number
|
||||
market: Market
|
||||
symbol: string
|
||||
base: string
|
||||
quote: string
|
||||
price_precision: number
|
||||
volume_precision: number
|
||||
status: "pending" | "backfilling" | "ready" | "error" | "delisted"
|
||||
is_default: boolean
|
||||
added_at: string
|
||||
last_error: string | null
|
||||
progress: Record<string, IntervalProgress>
|
||||
}
|
||||
|
||||
export interface TradableSymbol {
|
||||
symbol: string
|
||||
base: string
|
||||
quote: string
|
||||
}
|
||||
|
||||
/** 币安 24hr ticker 原始字段(只列用到的) */
|
||||
export interface Ticker24h {
|
||||
lastPrice: string
|
||||
priceChangePercent: string
|
||||
highPrice: string
|
||||
lowPrice: string
|
||||
volume: string
|
||||
quoteVolume: string
|
||||
}
|
||||
|
||||
/** [open_time, open, high, low, close, volume, quote_volume] */
|
||||
export type KlineRow = [number, number, number, number, number, number, number]
|
||||
|
||||
export function listSymbols(): Promise<MarketSymbol[]> {
|
||||
return http.get("/market/symbols")
|
||||
}
|
||||
|
||||
export function addSymbol(market: Market, symbol: string): Promise<void> {
|
||||
return http.post("/market/symbols", { market, symbol })
|
||||
}
|
||||
|
||||
export function deleteSymbol(id: number): Promise<void> {
|
||||
return http.delete(`/market/symbols/${id}`)
|
||||
}
|
||||
|
||||
export function listTradable(market: Market): Promise<TradableSymbol[]> {
|
||||
return http.get("/market/exchange-info", { params: { market } })
|
||||
}
|
||||
|
||||
export function listKlines(params: { market: Market, symbol: string, interval: string, end?: number, limit?: number }): Promise<KlineRow[]> {
|
||||
return http.get("/market/klines", { params })
|
||||
}
|
||||
|
||||
export function getTicker(market: Market, symbol: string): Promise<Ticker24h> {
|
||||
return http.get("/market/ticker", { params: { market, symbol } })
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { http } from "@/http"
|
||||
|
||||
export interface NewsBrief {
|
||||
id: number
|
||||
source_code: string
|
||||
url: string
|
||||
title: string
|
||||
summary: string
|
||||
excerpt: string
|
||||
author: string | null
|
||||
tags: string[]
|
||||
is_paywalled: boolean
|
||||
published_at: string
|
||||
first_seen_at: string
|
||||
revision_no: number
|
||||
}
|
||||
|
||||
export interface NewsDetail extends NewsBrief {
|
||||
content_html: string
|
||||
source_updated_at: string | null
|
||||
last_seen_at: string
|
||||
extra: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface Revision {
|
||||
revision_no: number
|
||||
title: string
|
||||
summary: string
|
||||
content_html: string
|
||||
captured_at: string
|
||||
}
|
||||
|
||||
export interface NewsQuery {
|
||||
source?: string[]
|
||||
q?: string
|
||||
start?: string
|
||||
end?: string
|
||||
cursor?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface NewsEvent {
|
||||
id: number
|
||||
source_code: string
|
||||
kind: "new" | "revised"
|
||||
}
|
||||
|
||||
export function listNews(params: NewsQuery): Promise<{ items: NewsBrief[], next_cursor: string | null }> {
|
||||
// source 需要序列化为重复参数 source=a&source=b
|
||||
return http.get("/news", { params, paramsSerializer: { indexes: null } })
|
||||
}
|
||||
|
||||
export function getNews(id: number): Promise<NewsDetail> {
|
||||
return http.get(`/news/${id}`)
|
||||
}
|
||||
|
||||
export function listRevisions(id: number): Promise<Revision[]> {
|
||||
return http.get(`/news/${id}/revisions`)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** 订阅 SSE 事件;组件卸载时关闭。断线由浏览器自动重连 */
|
||||
export function useEventSource<T>(url: string, event: string, onMessage: (data: T) => void) {
|
||||
const connected = ref(false)
|
||||
const source = new EventSource(url)
|
||||
source.addEventListener("open", () => connected.value = true)
|
||||
source.addEventListener("error", () => connected.value = false)
|
||||
source.addEventListener(event, e => onMessage(JSON.parse((e as MessageEvent).data)))
|
||||
onBeforeUnmount(() => source.close())
|
||||
return { connected }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { listSources } from "@/apis/crawler"
|
||||
|
||||
const names = ref<Record<string, string>>({})
|
||||
|
||||
/** 来源 code → 中文名,全局只请求一次 */
|
||||
export function useSourceNames() {
|
||||
if (Object.keys(names.value).length === 0) {
|
||||
listSources().then((info) => {
|
||||
names.value = Object.fromEntries(info.sources.map(s => [s.code, s.name]))
|
||||
})
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import axios from "axios"
|
||||
import { ElMessage } from "element-plus"
|
||||
import { router } from "@/router"
|
||||
|
||||
// 鉴权靠 httpOnly cookie,同源请求自动携带
|
||||
export const http = axios.create({ baseURL: "/api", timeout: 15000 })
|
||||
|
||||
http.interceptors.response.use(
|
||||
response => response.data,
|
||||
(error) => {
|
||||
const url = error.config?.url
|
||||
// /auth/me 的 401 由路由守卫处理;其余接口 401 说明登录过期
|
||||
if (error.response?.status === 401 && url !== "/auth/login") {
|
||||
if (url !== "/auth/me")
|
||||
router.replace({ name: "login", query: { redirect: router.currentRoute.value.fullPath } })
|
||||
} else {
|
||||
ElMessage.error(error.response?.data?.detail ?? error.message)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import { useUserStore } from "@/pinia/user"
|
||||
import { menuRoutes } from "@/router"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
async function logout() {
|
||||
await userStore.logout()
|
||||
router.replace({ name: "login" })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="h-full">
|
||||
<el-aside width="200px" class="border-r border-r-solid border-[var(--el-border-color)]">
|
||||
<div class="h-14 flex-center text-lg font-bold">
|
||||
AlphaPulse
|
||||
</div>
|
||||
<el-menu :default-active="route.path" router class="border-r-0!">
|
||||
<el-menu-item v-for="item in menuRoutes" :key="item.path" :index="item.path">
|
||||
{{ item.meta.title }}
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
<el-header class="flex-y-center justify-end gap-3 border-b border-b-solid border-[var(--el-border-color)]">
|
||||
<span>{{ userStore.username }}</span>
|
||||
<el-button link @click="logout">
|
||||
退出
|
||||
</el-button>
|
||||
</el-header>
|
||||
<el-main>
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createPinia } from "pinia"
|
||||
import App from "@/App.vue"
|
||||
import { router } from "@/router"
|
||||
import "element-plus/dist/index.css"
|
||||
import "element-plus/theme-chalk/dark/css-vars.css"
|
||||
import "@/styles/index.css"
|
||||
import "virtual:uno.css"
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount("#app")
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { http } from "@/http"
|
||||
|
||||
export const useUserStore = defineStore("user", () => {
|
||||
const username = ref<string>()
|
||||
|
||||
async function fetchMe() {
|
||||
const me: { username: string } = await http.get("/auth/me")
|
||||
username.value = me.username
|
||||
}
|
||||
|
||||
async function login(name: string, password: string) {
|
||||
const me: { username: string } = await http.post("/auth/login", { username: name, password })
|
||||
username.value = me.username
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await http.post("/auth/logout")
|
||||
username.value = undefined
|
||||
}
|
||||
|
||||
return { username, fetchMe, login, logout }
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createRouter, createWebHistory } from "vue-router"
|
||||
import Layout from "@/layouts/index.vue"
|
||||
import { useUserStore } from "@/pinia/user"
|
||||
|
||||
export const menuRoutes = [
|
||||
{ path: "/news", name: "news", component: () => import("@/pages/news/index.vue"), meta: { title: "新闻流" } },
|
||||
{ path: "/crawler", name: "crawler", component: () => import("@/pages/crawler/index.vue"), meta: { title: "采集控制台" } },
|
||||
{ path: "/market", name: "market", component: () => import("@/pages/market/index.vue"), meta: { title: "行情" } }
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: "/login", name: "login", component: () => import("@/pages/login/index.vue") },
|
||||
{ path: "/", component: Layout, redirect: "/news", children: menuRoutes },
|
||||
{ path: "/:pathMatch(.*)*", redirect: "/" }
|
||||
]
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (to.name === "login")
|
||||
return true
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.username) {
|
||||
try {
|
||||
await userStore.fetchMe()
|
||||
} catch {
|
||||
return { name: "login", query: { redirect: to.fullPath } }
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Ref } from "vue"
|
||||
|
||||
/** 持久化到 localStorage 的 ref;值需可 JSON 序列化 */
|
||||
export function usePersisted<T>(key: string, initial: T): Ref<T> {
|
||||
const fullKey = `alphapulse.${key}`
|
||||
const raw = localStorage.getItem(fullKey)
|
||||
const state = ref(raw === null ? initial : JSON.parse(raw)) as Ref<T>
|
||||
watch(state, value => localStorage.setItem(fullKey, JSON.stringify(value)), { deep: true })
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import dayjs from "dayjs"
|
||||
|
||||
export function formatTime(value: string | null | undefined, pattern = "MM-DD HH:mm:ss") {
|
||||
return value ? dayjs(value).format(pattern) : "-"
|
||||
}
|
||||
|
||||
/** HTML 转纯文本行;DOMParser 解析的文档不会执行脚本 */
|
||||
export function htmlToLines(html: string): string[] {
|
||||
const marked = html.replace(/<br\s*\/?>|<\/(?:p|div|li|h\d)>/gi, "\n")
|
||||
const text = new DOMParser().parseFromString(marked, "text/html").body.textContent ?? ""
|
||||
return text.split("\n").map(line => line.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
export interface DiffLine {
|
||||
type: "same" | "add" | "del"
|
||||
text: string
|
||||
}
|
||||
|
||||
/** 基于最长公共子序列的逐行对比 */
|
||||
export function diffLines(before: string[], after: string[]): DiffLine[] {
|
||||
const n = before.length
|
||||
const m = after.length
|
||||
const lcs = Array.from({ length: n + 1 }, () => Array.from<number>({ length: m + 1 }).fill(0))
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--)
|
||||
lcs[i][j] = before[i] === after[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1])
|
||||
}
|
||||
const result: DiffLine[] = []
|
||||
let i = 0
|
||||
let j = 0
|
||||
while (i < n && j < m) {
|
||||
if (before[i] === after[j]) {
|
||||
result.push({ type: "same", text: before[i] })
|
||||
i++
|
||||
j++
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
result.push({ type: "del", text: before[i++] })
|
||||
} else {
|
||||
result.push({ type: "add", text: after[j++] })
|
||||
}
|
||||
}
|
||||
while (i < n) result.push({ type: "del", text: before[i++] })
|
||||
while (j < m) result.push({ type: "add", text: after[j++] })
|
||||
return result
|
||||
}
|
||||
Reference in new issue
Block a user