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

+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+46
View File
@@ -0,0 +1,46 @@
import antfu from "@antfu/eslint-config"
// 更多自定义配置可查阅仓库:https://github.com/antfu/eslint-config
export default antfu(
{
// 使用外部格式化程序格式化 css、html、markdown 等文件
formatters: true,
// 启用样式规则
stylistic: {
// 缩进级别
indent: 2,
// 引号风格 'single' | 'double'
quotes: "double",
// 是否启用分号
semi: false
},
// 忽略文件
ignores: ["types/auto-imports.d.ts", "types/components.d.ts"]
},
{
// 对所有文件都生效的规则
rules: {
// vue
"vue/block-order": ["error", { order: ["script", "template", "style"] }],
"vue/attributes-order": "off",
// ts
"ts/no-use-before-define": "off",
// node
"node/prefer-global/process": "off",
// style
"style/comma-dangle": ["error", "never"],
"style/brace-style": ["error", "1tbs"],
// regexp
"regexp/no-unused-capturing-group": "off",
// other
"no-console": "off",
"no-debugger": "off",
"symbol-description": "off",
"antfu/if-newline": "off",
"no-unused-vars": ["warn"],
"vue/no-unused-vars": ["warn"],
"unused-imports/no-unused-vars": ["warn"]
}
}
)
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AlphaPulse</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
server {
listen 80;
root /usr/share/nginx/html;
location /api/ {
proxy_pass http://api:8000;
proxy_http_version 1.1;
# SSE 不能被缓冲;WebSocket 需要升级头
proxy_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 1h;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}
map $http_upgrade $connection_upgrade {
default upgrade;
"" "";
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "alphapulse-web",
"type": "module",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"lint": "eslint . --fix"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.20.0",
"dayjs": "^1.11.23",
"element-plus": "^2.14.7",
"klinecharts": "^10.0.3",
"pinia": "^4.0.3",
"vue": "^3.5.43",
"vue-router": "^5.4.0"
},
"devDependencies": {
"@antfu/eslint-config": "^9.5.1",
"@types/node": "^26.6.5",
"@vitejs/plugin-vue": "^6.0.10",
"eslint": "^10.12.0",
"eslint-plugin-format": "^2.0.1",
"typescript": "^6.0.3",
"unocss": "^66.10.5",
"unplugin-auto-import": "^21.1.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.3.4",
"vue-tsc": "^3.3.13"
}
}
+5360
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
minimumReleaseAgeExcludePrune: true
trustPolicy: no-downgrade
shellEmulator: true
onlyBuiltDependencies:
- esbuild
+9
View File
@@ -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>
+56
View File
@@ -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 })
}
+66
View File
@@ -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 } })
}
+59
View File
@@ -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`)
}
+10
View File
@@ -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 }
}
+13
View File
@@ -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
}
+21
View File
@@ -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)
}
)
+39
View File
@@ -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>
+9
View File
@@ -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>
+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>
+22
View File
@@ -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 }
})
+32
View File
@@ -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
})
+10
View File
@@ -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;
}
+10
View File
@@ -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
}
+45
View File
@@ -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
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "esnext",
"jsx": "preserve",
"lib": ["esnext", "dom"],
"useDefineForClassFields": true,
"module": "esnext",
"moduleResolution": "bundler",
"paths": {
"@/*": ["./src/*"]
},
"resolveJsonModule": true,
"types": ["vite/client", "element-plus/global"],
"allowImportingTsExtensions": true,
"strict": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.vue", "types/**/*.d.ts", "*.ts"],
"exclude": ["node_modules", "dist"]
}
+94
View File
@@ -0,0 +1,94 @@
/* eslint-disable */
/* prettier-ignore */
/* oxlint-disable */
/* oxfmt-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const ElMessage: typeof import('element-plus/es').ElMessage
const ElMessageBox: typeof import('element-plus/es').ElMessageBox
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const createPinia: typeof import('pinia').createPinia
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const defineStore: typeof import('pinia').defineStore
const effectScope: typeof import('vue').effectScope
const getActivePinia: typeof import('pinia').getActivePinia
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const mapActions: typeof import('pinia').mapActions
const mapGetters: typeof import('pinia').mapGetters
const mapState: typeof import('pinia').mapState
const mapStores: typeof import('pinia').mapStores
const mapWritableState: typeof import('pinia').mapWritableState
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const setActivePinia: typeof import('pinia').setActivePinia
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const storeToRefs: typeof import('pinia').storeToRefs
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useLink: typeof import('vue-router').useLink
const useModel: typeof import('vue').useModel
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+61
View File
@@ -0,0 +1,61 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckboxButton: typeof import('element-plus/es')['ElCheckboxButton']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSelectV2: typeof import('element-plus/es')['ElSelectV2']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vInfiniteScroll: typeof import('element-plus/es')['ElInfiniteScroll']
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
+7
View File
@@ -0,0 +1,7 @@
import "vue-router"
declare module "vue-router" {
interface RouteMeta {
title?: string
}
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig, presetAttributify, presetWind3 } from "unocss"
export default defineConfig({
// 预设
presets: [
// 属性化模式 & 无值的属性模式
presetAttributify({
prefix: "un-",
prefixedOnly: false
}),
// 默认预设
presetWind3({
important: "#app"
})
],
// 自定义规则
rules: [],
// 自定义快捷方式
shortcuts: {
"wh-full": "w-full h-full",
"flex-center": "flex justify-center items-center",
"flex-x-center": "flex justify-center",
"flex-y-center": "flex items-center"
}
})
+43
View File
@@ -0,0 +1,43 @@
import { resolve } from "node:path"
import vue from "@vitejs/plugin-vue"
import UnoCSS from "unocss/vite"
import AutoImport from "unplugin-auto-import/vite"
import { ElementPlusResolver } from "unplugin-vue-components/resolvers"
import Components from "unplugin-vue-components/vite"
import { defineConfig } from "vite"
export default defineConfig({
resolve: {
alias: {
"@": resolve(import.meta.dirname, "src")
}
},
server: {
// 只监听本机,通过 SSH 隧道访问;对外只走 https://alpha.134333.xyz
host: "127.0.0.1",
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:8000",
ws: true
}
}
},
build: {
reportCompressedSize: false,
chunkSizeWarningLimit: 2048
},
plugins: [
vue(),
UnoCSS(),
AutoImport({
imports: ["vue", "vue-router", "pinia"],
dts: "types/auto-imports.d.ts",
resolvers: [ElementPlusResolver()]
}),
Components({
dts: "types/components.d.ts",
resolvers: [ElementPlusResolver()]
})
]
})