Refactor frontend workspaces and centralize API contracts

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

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

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

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

No files matched your search

@@ -0,0 +1,196 @@
<script setup lang="ts">
import type { CalendarHistory, CalendarItem, CalendarRevision, CalendarSeriesValue } from "@@/apis/calendar/type"
import { calendarHistoryApi, calendarRevisionsApi, calendarSeriesApi } from "@@/apis/calendar"
import dayjs from "dayjs"
import timezone from "dayjs/plugin/timezone"
import utc from "dayjs/plugin/utc"
dayjs.extend(utc)
dayjs.extend(timezone)
const zone = "Asia/Shanghai"
const formatted = (time: string) => dayjs(time).tz(zone).format("MM-DD HH:mm:ss")
const value = (v: string | null, unit: string) => v === null ? "—" : `${v}${unit}`
const detail = defineModel<CalendarItem | null>({ required: true })
const revisions = ref<CalendarRevision[]>([])
const revisionsLoading = ref(false)
const revisionError = ref(false)
const history = ref<CalendarHistory[]>([])
const historyLoading = ref(false)
const historyError = ref(false)
const series = ref<CalendarSeriesValue[]>([])
const seriesLoading = ref(false)
const seriesError = ref(false)
const detailTab = ref("history")
let detailSeq = 0
let disposed = false
async function load(item: CalendarItem) {
const current = ++detailSeq
revisions.value = []
history.value = []
series.value = []
seriesLoading.value = true
seriesError.value = false
revisionsLoading.value = true
historyLoading.value = true
revisionError.value = false
historyError.value = false
await Promise.allSettled([
calendarRevisionsApi(item.release_id).then((result) => {
if (!disposed && current === detailSeq) revisions.value = result
}).catch(() => {
if (!disposed && current === detailSeq) revisionError.value = true
}).finally(() => {
if (!disposed && current === detailSeq) revisionsLoading.value = false
}),
calendarHistoryApi(item.release_id, item.indicator).then((result) => {
if (!disposed && current === detailSeq) history.value = result
}).catch(() => {
if (!disposed && current === detailSeq) historyError.value = true
}).finally(() => {
if (!disposed && current === detailSeq) historyLoading.value = false
}),
calendarSeriesApi(item.release_id, item.indicator).then((result) => {
if (!disposed && current === detailSeq) series.value = result
}).catch(() => {
if (!disposed && current === detailSeq) seriesError.value = true
}).finally(() => {
if (!disposed && current === detailSeq) seriesLoading.value = false
})
])
}
watch(() => detail.value ? `${detail.value.release_id}:${detail.value.indicator}:${detail.value.revision_no}` : "", (key, old) => {
++detailSeq
if (!detail.value) return
if (key.split(":").slice(0, 2).join(":") !== old?.split(":").slice(0, 2).join(":")) detailTab.value = "history"
void load(detail.value)
}, { immediate: true })
onBeforeUnmount(() => {
disposed = true
++detailSeq
})
</script>
<template>
<el-drawer :model-value="!!detail" title="事件详情" size="min(820px, 100vw)" @close="detail = null">
<template v-if="detail">
<h2 class="detail-name">
<img class="country-flag" :src="`/flags/${detail.country_code.toLowerCase()}.svg`" :alt="detail.country_name"> {{ detail.event_name }}
</h2>
<p>{{ detail.title }}</p>
<p v-if="detail.period">
统计期:{{ detail.period }}
</p>
<p>预定公布:{{ formatted(detail.scheduled_at) }}</p>
<p>首次获取:{{ detail.first_received_at ? formatted(detail.first_received_at) : "尚未获取" }}</p>
<p>最近检查:{{ detail.last_checked_at ? formatted(detail.last_checked_at) : "尚未检查" }}</p>
<p v-if="detail.previous_basis">
前值口径:{{ detail.previous_basis }}
</p>
<el-alert v-if="detail.error" :closable="false" type="error" :title="detail.error" />
<el-alert :closable="false" type="info" title="影响简评" :description="detail.explanation" />
<p><a :href="detail.release_url ?? detail.schedule_url" target="_blank" rel="noopener noreferrer">查看官方来源 ↗</a></p>
<el-tabs v-model="detailTab">
<el-tab-pane label="历次公布值" name="history">
<p class="calendar-note">
同一指标、同一发布阶段的当期公告值(含本期)。首次记录值不保证是原始初值,缺失历史留空;当前官方修订序列在下方独立展示,不能用于还原历史公布时点。
</p>
<el-alert v-if="historyError" :closable="false" type="error" title="历史公布值获取失败" />
<el-table v-loading="historyLoading" :data="history" stripe empty-text="尚无已采集的历史公布值">
<el-table-column prop="period" label="统计期" min-width="100" />
<el-table-column label="公布时间" min-width="155">
<template #default="{ row }">
{{ dayjs(row.scheduled_at).tz(zone).format("YYYY-MM-DD HH:mm") }}
</template>
</el-table-column>
<el-table-column label="前值" width="90">
<template #default="{ row }">
{{ value(row.previous, detail.unit) }}
</template>
</el-table-column>
<el-table-column label="首次记录值" width="110">
<template #default="{ row }">
{{ value(row.first_recorded_actual, detail.unit) }}
</template>
</el-table-column>
<el-table-column label="当期公告值" width="120">
<template #default="{ row }">
<strong>{{ value(row.actual, detail.unit) }}</strong><el-tag v-if="row.revised" size="small" type="warning">
已修订
</el-tag>
</template>
</el-table-column>
<el-table-column label="来源" width="80">
<template #default="{ row }">
<a v-if="row.release_url" :href="row.release_url" target="_blank" rel="noopener noreferrer">来源 ↗</a>
</template>
</el-table-column>
</el-table>
<el-alert v-if="seriesError" :closable="false" type="error" title="当前官方历史序列获取失败" />
<div v-if="seriesLoading || series.length" v-loading="seriesLoading">
<h3>当前官方历史终值</h3>
<p class="calendar-note">
下列数值按官方当前序列单列,可能已修订,与上方当期公告值口径不同;初值事件也不能将这些终值视为当时已知数据。
</p>
<el-table :data="series" stripe>
<el-table-column prop="period" label="统计期" width="100" />
<el-table-column label="当前终值" width="120">
<template #default="{ row }">
{{ value(row.value, detail.unit) }}
</template>
</el-table-column>
<el-table-column label="采集时间" min-width="155">
<template #default="{ row }">
{{ dayjs(row.captured_at).tz(zone).format("YYYY-MM-DD HH:mm") }}
</template>
</el-table-column>
<el-table-column label="来源" width="80">
<template #default="{ row }">
<a :href="row.source_url" target="_blank" rel="noopener noreferrer">来源 ↗</a>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="本期修订记录" name="revisions">
<el-alert v-if="revisionError" :closable="false" type="error" title="修订记录获取失败" />
<div v-loading="revisionsLoading">
<div v-for="r in revisions" :key="r.revision_no" class="revision">
<strong>版本 {{ r.revision_no }}</strong> · {{ formatted(r.captured_at) }}
<p>预定时间 {{ formatted(r.payload.scheduled_at) }}</p>
<p v-if="r.payload.values[detail.indicator]">
公布值 {{ value(r.payload.values[detail.indicator].actual, detail.unit) }}
</p>
</div>
</div>
</el-tab-pane>
</el-tabs>
</template>
</el-drawer>
</template>
<style scoped>
.country-flag {
width: 25px;
height: 16px;
object-fit: cover;
border: 1px solid #e2e8f0;
vertical-align: -3px;
}
.detail-name {
display: flex;
align-items: center;
gap: 7px;
}
.calendar-note {
color: #64748b;
font-size: 12px;
line-height: 1.8;
}
.revision {
border-bottom: 1px solid #e2e8f0;
padding: 12px 0;
color: #475569;
font-size: 13px;
}
</style>
+376
View File
@@ -0,0 +1,376 @@
<script lang="ts" setup>
import type { CalendarData, CalendarItem } from "@@/apis/calendar/type"
import { listCalendarApi } from "@@/apis/calendar"
import { ArrowLeft, ArrowRight, Refresh } from "@element-plus/icons-vue"
import dayjs from "dayjs"
import timezone from "dayjs/plugin/timezone"
import utc from "dayjs/plugin/utc"
import CalendarDetail from "./components/CalendarDetail.vue"
dayjs.extend(utc)
dayjs.extend(timezone)
const zone = "Asia/Shanghai"
const today = () => dayjs().tz(zone).format("YYYY-MM-DD")
const selected = ref(today())
const days = ref(7)
const data = ref<CalendarData | null>(null)
const loading = ref(false)
const failed = ref(false)
const now = ref(Date.now())
const detail = ref<CalendarItem | null>(null)
const country = ref("")
const impactLabels = { pending: "待公布", bullish: "利多", bearish: "利空", neutral: "影响较小", uncertain: "待判断" }
const countries = computed(() => data.value?.countries ?? [])
const labels = { scheduled: "待公布", waiting: "获取中", delayed: "取数延迟", released: "已公布", withdrawn: "已撤回", error: "采集异常", historical: "仅有日程", unavailable: "官方未公布" }
const sources: Record<string, string> = { bls: "BLS", bea: "BEA", fed: "美联储", umich: "密歇根大学", ecb: "欧洲央行", eurostat: "Eurostat", boe: "英国央行", ons: "ONS", destatis: "Destatis", insee: "INSEE" }
const formatted = (time: string) => dayjs(time).tz(zone).format("MM-DD HH:mm:ss")
const value = (v: string | null, unit: string) => v === null ? "—" : `${v}${unit}`
const groups = computed(() => {
const result = new Map<string, CalendarItem[]>()
for (const item of data.value?.items ?? []) {
if (country.value && item.country_code !== country.value) continue
const key = dayjs(item.scheduled_at).tz(zone).format("YYYY-MM-DD")
const rows = result.get(key) ?? []
rows.push(item)
result.set(key, rows)
}
return [...result.entries()]
})
const online = computed(() => !!data.value?.worker && now.value - Date.parse(data.value.worker.at) < 30000)
const scheduleErrors = computed(() => Object.values(data.value?.worker?.sources ?? {}).filter(s => s.error || now.value - Date.parse(s.checked_at) > 7200000))
const hasSchedule = computed(() => Object.keys(data.value?.worker?.sources ?? {}).length > 0)
const upcoming = computed(() => data.value?.items.find(i => (!country.value || i.country_code === country.value) && i.status === "scheduled" && Date.parse(i.scheduled_at) > now.value))
const countdown = computed(() => {
if (!upcoming.value) return ""
const seconds = Math.max(0, Math.floor((Date.parse(upcoming.value.scheduled_at) - now.value) / 1000))
return `${Math.floor(seconds / 3600)}时 ${Math.floor(seconds % 3600 / 60)}分 ${seconds % 60}秒`
})
let seq = 0
let timer: ReturnType<typeof setTimeout> | undefined
let clock: ReturnType<typeof setInterval> | undefined
let disposed = false
async function load() {
const current = ++seq
clearTimeout(timer)
loading.value = data.value === null
try {
const result = await listCalendarApi(selected.value, days.value)
if (disposed || current !== seq) return
data.value = result
failed.value = false
} catch {
// HTTP 层已提示具体错误;保留上次数据并标为失联,不显示为实时成功。
if (current === seq) failed.value = true
} finally {
if (current === seq && !disposed) {
loading.value = false
const due = data.value?.items.some(i => ["scheduled", "waiting", "delayed"].includes(i.status) && Math.abs(Date.parse(i.scheduled_at) - Date.now()) < 900000)
timer = setTimeout(load, failed.value ? 30000 : due ? 3000 : 30000)
}
}
}
function move(direction: number) {
selected.value = dayjs(selected.value).add(direction * days.value, "day").format("YYYY-MM-DD")
}
function openDetail(item: CalendarItem) {
detail.value = item
}
watch([selected, days], () => {
data.value = null
load()
})
watch(data, () => {
if (!detail.value) return
const item = data.value?.items.find(i => i.release_id === detail.value?.release_id && i.indicator === detail.value?.indicator)
if (item) {
detail.value = item
}
})
onMounted(() => {
load()
clock = setInterval(() => {
now.value = Date.now()
}, 1000)
})
onUnmounted(() => {
disposed = true
++seq
clearTimeout(timer)
clearInterval(clock)
})
</script>
<template>
<div class="calendar-page">
<div class="calendar-heading">
<div>
<h1>经济日历</h1>
<p>北京时间 UTC+8 · 各国经济数据与重要事件</p>
</div>
<el-tag :type="online && !failed ? 'success' : 'danger'">
{{ online && !failed ? "采集在线" : "采集或连接离线" }}
</el-tag>
</div>
<el-alert v-if="failed || !online || !hasSchedule || scheduleErrors.length" :closable="false" type="warning" show-icon title="日历同步未就绪或来源异常,当前列表不能用于排除重大事件。" />
<div v-if="upcoming" class="next-event">
<span>下个事件</span><strong><img class="country-flag" :src="`/flags/${upcoming.country_code.toLowerCase()}.svg`" :alt="upcoming.country_name"> {{ upcoming.event_name }}</strong>
<span>{{ formatted(upcoming.scheduled_at) }}</span><b>{{ countdown }}</b>
</div>
<el-card shadow="never" class="calendar-card">
<div class="calendar-toolbar">
<el-button :icon="ArrowLeft" circle aria-label="上一段日期" @click="move(-1)" />
<el-date-picker v-model="selected" type="date" value-format="YYYY-MM-DD" :clearable="false" />
<el-button :icon="ArrowRight" circle aria-label="下一段日期" @click="move(1)" />
<el-button @click="selected = today()">
今天
</el-button>
<el-select v-model="country" class="country-select" placeholder="全部国家" aria-label="国家筛选">
<el-option label="全部国家" value="" />
<el-option v-for="option in countries" :key="option.code" :label="option.name" :value="option.code" />
</el-select>
<el-radio-group v-model="days">
<el-radio-button :value="1">
日历
</el-radio-button>
<el-radio-button :value="7">
周历
</el-radio-button>
</el-radio-group>
<el-button :icon="Refresh" :loading="loading" @click="load">
刷新
</el-button>
</div>
<p class="calendar-note">
三星标红,四星及以上为主要事件。影响针对 BTC/ETH:优先比较预期,无预期时按前值及利率通道给参考倾向,依据不足显示待判断。
</p>
<div v-loading="loading" class="calendar-results">
<section v-for="[date, items] in groups" :key="date">
<h2>{{ date }} <small>{{ items.length }} 项指标</small></h2>
<el-table :data="items" stripe @row-click="openDetail">
<el-table-column label="时间" width="90">
<template #default="{ row }">
{{ dayjs(row.scheduled_at).tz(zone).format("HH:mm") }}
</template>
</el-table-column>
<el-table-column label="数据" min-width="230">
<template #default="{ row }">
<div class="event-name" :class="{ 'event-important': row.importance === 3, 'event-major': row.importance >= 4 }">
<img class="country-flag" :src="`/flags/${row.country_code.toLowerCase()}.svg`" :alt="row.country_name">
<span>{{ row.event_name }}</span>
<span v-if="row.importance >= 4" class="major-label">主要</span>
</div><div class="row-subtitle">
{{ sources[row.source] }} · {{ row.title }}
</div>
</template>
</el-table-column>
<el-table-column label="重要性" width="125">
<template #default="{ row }">
<span class="stars" :class="{ 'stars-important': row.importance >= 3 }">{{ "★".repeat(row.importance) }}</span>
</template>
</el-table-column>
<el-table-column label="前值" width="100">
<template #default="{ row }">
{{ value(row.previous, row.unit) }}
</template>
</el-table-column>
<el-table-column label="预期值" width="90">
<template #default="{ row }">
{{ value(row.forecast, row.unit) }}
</template>
</el-table-column>
<el-table-column label="公布值" width="110">
<template #default="{ row }">
<strong class="actual-value">{{ value(row.actual, row.unit) }}</strong>
</template>
</el-table-column>
<el-table-column label="状态" width="105">
<template #default="{ row }">
<el-tag :type="row.status === 'released' ? 'success' : row.status === 'error' ? 'danger' : 'info'">
{{ labels[row.status as CalendarItem['status']] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="影响 · BTC/ETH" min-width="155">
<template #default="{ row }">
<el-tooltip :content="row.explanation" placement="top">
<span class="impact-badge" :class="`impact-${row.impact}`">{{ impactLabels[row.impact as CalendarItem['impact']] }}</span>
</el-tooltip>
<div v-if="row.actual !== null" class="row-subtitle">
{{ row.impact_basis === "forecast" ? "比较预期" : row.impact_basis === "previous" ? "比较前值 · 参考" : "资料不足" }} · 查看依据
</div>
</template>
</el-table-column>
</el-table>
</section>
<el-empty v-if="!loading && groups.length === 0" :description="online && hasSchedule && !scheduleErrors.length && !failed ? '所选日期和国家没有已收录的事件' : '尚无可确认的日历数据'" />
</div>
<p class="calendar-note">
官方站点可能延迟;页面显示实际获取时间,不保证秒级到达。历史日程不等于当时已获取结果,简评不构成交易指令。
</p>
</el-card>
<CalendarDetail v-model="detail" />
</div>
</template>
<style scoped>
.calendar-page {
max-width: 1600px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.calendar-heading,
.calendar-toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.calendar-heading {
justify-content: space-between;
}
h1 {
margin: 0;
font-size: 20px;
color: #172b4d;
}
.calendar-heading p,
.calendar-note {
color: #64748b;
font-size: 12px;
line-height: 1.8;
}
.next-event {
background: #eaf3ff;
border: 1px solid #d4e4fc;
border-radius: 10px;
padding: 10px 12px;
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: center;
color: #355783;
font-size: 13px;
}
.next-event b {
margin-left: auto;
font-variant-numeric: tabular-nums;
color: #2563eb;
}
.calendar-toolbar {
padding-bottom: 12px;
}
.country-select {
width: 135px;
}
.country-flag {
width: 25px;
height: 16px;
object-fit: cover;
border: 1px solid #e2e8f0;
vertical-align: -3px;
flex-shrink: 0;
}
.event-name,
.detail-name {
display: flex;
align-items: center;
gap: 7px;
}
.event-name {
font-weight: 400;
}
.event-important,
.event-major,
.stars-important {
color: #e64b63;
}
.event-major {
font-weight: 700;
}
.major-label {
border: 1px solid #ffc9d1;
background: #fff0f2;
color: #d73754;
font-size: 10px;
line-height: 18px;
border-radius: 4px;
padding: 0 4px;
white-space: nowrap;
}
.impact-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 5px;
font-weight: 600;
font-size: 12px;
white-space: nowrap;
}
.impact-bullish {
background: #fff0f2;
color: #dc4660;
}
.impact-bearish {
background: #eaf8f2;
color: #35916c;
}
.impact-neutral {
background: #fff7df;
color: #bc850f;
}
.impact-pending,
.impact-uncertain {
background: #f0f3f7;
color: #718096;
}
.calendar-results h2 {
margin: 20px 0 12px;
font-size: 16px;
color: #334155;
}
h2 small {
font-size: 12px;
font-weight: normal;
color: #94a3b8;
margin-left: 12px;
}
.row-subtitle {
color: #94a3b8;
font-size: 11px;
margin-top: 5px;
line-height: 1.4;
}
.stars {
color: #e9a128;
letter-spacing: 2px;
white-space: nowrap;
}
.stars.stars-important {
color: #e64b63;
}
.actual-value {
color: #172b4d;
font-variant-numeric: tabular-nums;
}
.revision {
border-bottom: 1px solid #e2e8f0;
padding: 12px 0;
color: #475569;
font-size: 13px;
}
:deep(.el-table__row) {
cursor: pointer;
}
:deep(.el-table th.el-table__cell) {
background: #f4f7fb;
color: #64748b;
}
:deep(.el-table td.el-table__cell) {
padding: 8px 0;
}
</style>
+9 -9
View File
@@ -1,8 +1,8 @@
<script lang="ts" setup>
import type { ChatContext } from "@/apis/chat"
import { getChatContext, listChatContextVersions, saveChatContext } from "@/apis/chat"
import { renderMarkdown } from "@/utils/markdown"
import { formatTime } from "@/utils/text"
import type { ChatContext } from "@@/apis/chat/type"
import { getChatContextApi, listChatContextVersionsApi, saveChatContextApi } from "@@/apis/chat"
import { renderMarkdown } from "@@/utils/markdown"
import { formatTime } from "@@/utils/text"
const SOURCE_LABELS = { user: "我", chat: "Chat", reset: "Reset 清空" } as const
@@ -19,7 +19,7 @@ async function load() {
return
const seq = ++loadSeq
const previous = content.value
const [context, history] = await Promise.all([getChatContext(), listChatContextVersions()])
const [context, history] = await Promise.all([getChatContextApi(), listChatContextVersionsApi()])
if (seq !== loadSeq)
return
current.value = context
@@ -33,11 +33,11 @@ async function save() {
// 失效保存前的读取,保存期间也不启动新的读取,避免旧版本覆盖编辑区。
++loadSeq
try {
const saved = await saveChatContext(content.value)
const saved = await saveChatContextApi(content.value)
// 保存回执就是已保存版本,编辑区保留后续输入。
current.value = saved
ElMessage.success("已保存")
versions.value = await listChatContextVersions()
versions.value = await listChatContextVersionsApi()
} finally {
saving.value = false
}
@@ -53,7 +53,7 @@ async function restore(version: ChatContext) {
/** Chat 更新了 CONTEXT.md:编辑区没有未保存修改时直接刷新 */
async function refreshFromChat() {
if (dirty.value) {
versions.value = await listChatContextVersions()
versions.value = await listChatContextVersionsApi()
ElMessage.warning("Chat 更新了 CONTEXT.md,你有未保存的修改,未自动刷新")
return
}
@@ -61,7 +61,7 @@ async function refreshFromChat() {
}
defineExpose({ refreshFromChat, load, dirty })
load()
onMounted(load)
</script>
<template>
+3 -25
View File
@@ -1,30 +1,8 @@
<script lang="ts" setup>
import type { DisplayItem } from "../type"
import { renderMarkdown } from "@@/utils/markdown"
import { formatTime } from "@@/utils/text"
import { CircleCheck, Cpu, Loading } from "@element-plus/icons-vue"
import { renderMarkdown } from "@/utils/markdown"
import { formatTime } from "@/utils/text"
export interface ToolCard {
id: string
name: string
arguments: Record<string, unknown>
/** 未返回时为 null */
result: string | null
}
export interface DisplayItem {
key: string
kind: "user" | "assistant"
text: string
toolCalls: ToolCard[]
stopped?: boolean
error?: string
/** 生成中:这次模型调用已结束,后续文字另起一条 */
closed?: boolean
/** 生成中:模型正在思考,还没有输出文字 */
thinking?: boolean
/** 已落库的消息时间;生成中的没有 */
time?: string
}
defineProps<{ items: DisplayItem[] }>()
@@ -0,0 +1,46 @@
import type { ChatStreamHandler, SendChatRequestData } from "@@/apis/chat/type"
import { sendChatApi } from "@@/apis/chat"
import { streamCanceled } from "@/http/stream"
import { useUserStore } from "@/pinia/stores/user"
/** Chat POST 只发送一次;失败由聊天区显示,拒绝 Promise 阻止后续成功处理。 */
export function useChatStream() {
const streaming = ref(false)
const user = useUserStore()
let controller: AbortController | undefined
async function send(body: SendChatRequestData, onEvent: ChatStreamHandler) {
if (streaming.value)
throw new Error("正在生成")
const version = user.sessionVersion
const request = new AbortController()
controller = request
streaming.value = true
try {
let finished = false
await sendChatApi(body, request.signal, version, (event) => {
if (event.name === "error")
throw new Error(event.data.message)
if (event.name === "done") finished = true
onEvent(event)
})
if (!finished) throw new Error("生成连接提前中断,请核对已保存的回复")
} catch (error) {
if (!streamCanceled(error) && version === user.sessionVersion)
onEvent({ name: "error", data: { message: error instanceof Error ? error.message : "生成失败" } })
throw error
} finally {
if (controller === request) {
streaming.value = false
controller = undefined
}
}
}
function stop() {
controller?.abort()
}
watch(() => user.sessionVersion, stop, { flush: "sync" })
onBeforeUnmount(stop)
return { streaming, send, stop }
}
+72 -60
View File
@@ -1,21 +1,31 @@
<script lang="ts" setup>
import type { ChatMessage } from "@/apis/chat"
import type { Effort, Provider } from "@/apis/settings"
import type { SkillNode } from "@/apis/skills"
import type { DisplayItem } from "@/pages/chat/components/MessageList.vue"
import type { ChatMessage, ChatStreamHandler } from "@@/apis/chat/type"
import type { Effort, Provider } from "@@/apis/settings/type"
import type { SkillNode } from "@@/apis/skills/type"
import type { DisplayItem } from "./type"
import { getChatSessionApi, resetChatApi } from "@@/apis/chat"
import { listProvidersApi } from "@@/apis/settings"
import { listSkillTreeApi } from "@@/apis/skills"
import SkillTree from "@@/components/SkillTree/index.vue"
import SkillVersionDrawer from "@@/components/SkillVersionDrawer/index.vue"
import { useSessionScope } from "@@/composables/useSessionScope"
import { EFFORTS } from "@@/constants/ai"
import { usePersisted } from "@@/utils/storage"
import { RefreshLeft } from "@element-plus/icons-vue"
import { ElNotification } from "element-plus"
import { getChatSession, resetChat } from "@/apis/chat"
import { EFFORTS, listProviders } from "@/apis/settings"
import { listSkillTree } from "@/apis/skills"
import SkillTree from "@/components/SkillTree.vue"
import SkillVersionDrawer from "@/components/SkillVersionDrawer.vue"
import { useChatStream } from "@/composables/useChatStream"
import ContextTab from "@/pages/chat/components/ContextTab.vue"
import MessageList from "@/pages/chat/components/MessageList.vue"
import { usePersisted } from "@/utils/storage"
import { logRequestError } from "@/http/axios"
import { streamCanceled } from "@/http/stream"
import { useUserStore } from "@/pinia/stores/user"
import ContextTab from "./components/ContextTab.vue"
import MessageList from "./components/MessageList.vue"
import { useChatStream } from "./composables/useChatStream"
import { toItems } from "./message-items"
const sessionScope = useSessionScope()
const router = useRouter()
const skillsVisible = ref(false)
const resetting = ref(false)
const activeTab = ref<"chat" | "context">("chat")
const contextTab = ref<InstanceType<typeof ContextTab>>()
@@ -34,26 +44,6 @@ const input = ref("")
const listBox = ref<HTMLElement>()
const { streaming, send, stop } = useChatStream()
/** 把会话消息转成展示项:工具结果挂到对应的工具调用上 */
function toItems(list: ChatMessage[]): DisplayItem[] {
const items: DisplayItem[] = []
const calls = new Map<string, DisplayItem["toolCalls"][number]>()
for (const m of list) {
if (m.role === "user") {
items.push({ key: `m${m.id}`, kind: "user", text: m.content.text, toolCalls: [], time: m.created_at })
} else if (m.role === "assistant") {
const toolCalls = (m.content.tool_calls ?? []).map((c: any) => ({ ...c, result: null }))
toolCalls.forEach((c: any) => calls.set(c.id, c))
items.push({ key: `m${m.id}`, kind: "assistant", text: m.content.text, toolCalls, stopped: m.content.stopped, error: m.content.error, time: m.created_at })
} else {
const call = calls.get(m.content.tool_call_id)
if (call)
call.result = m.content.result
}
}
return items
}
const items = computed(() => [...toItems(messages.value), ...liveItems.value])
const tokenRatio = computed(() => provider.value ? estimatedTokens.value / provider.value.context_window : 0)
@@ -66,11 +56,12 @@ const RELOAD_RETRY_MS = 300
const RELOAD_RETRIES = 10
async function loadSession() {
let session = await getChatSession()
let session = await getChatSessionApi()
// 停止生成后服务端还要片刻才落库部分回复:等它结束再以服务端为准
for (let i = 0; session.generating && i < RELOAD_RETRIES; i++) {
await new Promise(resolve => setTimeout(resolve, RELOAD_RETRY_MS))
session = await getChatSession()
sessionScope.assertCurrent()
session = await getChatSessionApi()
}
messages.value = session.messages
estimatedTokens.value = session.estimated_tokens
@@ -79,7 +70,7 @@ async function loadSession() {
}
async function loadProviders() {
providers.value = await listProviders()
providers.value = await listProvidersApi()
if (!provider.value)
providerId.value = providers.value.find(p => p.is_default)?.id ?? providers.value[0]?.id ?? null
}
@@ -94,7 +85,8 @@ function currentAssistant(): DisplayItem {
return liveItems.value.at(-1)!
}
function onEvent(name: string, data: any) {
const onEvent: ChatStreamHandler = (event) => {
const { name, data } = event
if (name === "prompt") {
estimatedTokens.value = data.estimated_tokens
// OpenAI 格式推理期间没有任何推送,先显示等待状态直到出字
@@ -125,10 +117,10 @@ function onEvent(name: string, data: any) {
currentAssistant().thinking = true
} else if (name === "skill_draft") {
highlightIds.value = [...new Set([...highlightIds.value, data.skill_id])]
loadSkills()
void loadSkills().catch(logRequestError)
ElNotification({ title: "Chat 生成了 skill 草稿", message: `${data.path} v${data.version},需审阅并发布后才生效`, type: "warning" })
} else if (name === "context_updated") {
contextTab.value?.refreshFromChat()
void contextTab.value?.refreshFromChat().catch(logRequestError)
} else if (name === "error") {
const item = currentAssistant()
item.thinking = false
@@ -148,7 +140,14 @@ async function submit() {
input.value = ""
liveItems.value = [{ key: `live-user${Date.now()}`, kind: "user", text, toolCalls: [] }]
scrollToBottom()
await send({ text, provider_id: provider.value.id, reasoning_effort: effort.value }, onEvent)
const version = useUserStore().sessionVersion
try {
await send({ text, provider_id: provider.value.id, reasoning_effort: effort.value }, onEvent)
} catch (error) {
// 流的失败已显示在聊天区;取消仍需读取服务端保存的部分回复。
if (version !== useUserStore().sessionVersion) return
if (!streamCanceled(error)) return
}
// 无论完成、出错还是停止,都以服务端保存的会话为准;保留本地的错误提示
const errors = liveItems.value.filter(i => i.error)
await loadSession()
@@ -167,12 +166,18 @@ const resetVisible = ref(false)
const clearContext = ref(false)
async function doReset() {
await resetChat(clearContext.value)
resetVisible.value = false
ElMessage.success(clearContext.value ? "已清空对话和 CONTEXT.md" : "已清空对话,CONTEXT.md 保留")
clearContext.value = false
await loadSession()
await contextTab.value?.load()
if (resetting.value) return
resetting.value = true
try {
await resetChatApi(clearContext.value)
resetVisible.value = false
ElMessage.success(clearContext.value ? "已清空对话和 CONTEXT.md" : "已清空对话,CONTEXT.md 保留")
clearContext.value = false
await loadSession()
await contextTab.value?.load()
} finally {
resetting.value = false
}
}
// ---------- 侧边栏 skills ----------
@@ -182,7 +187,7 @@ const drawerSkill = ref<SkillNode | null>(null)
const drawerVisible = ref(false)
async function loadSkills() {
skills.value = await listSkillTree()
skills.value = await listSkillTreeApi()
if (drawerSkill.value)
drawerSkill.value = skills.value.find(s => s.id === drawerSkill.value!.id) ?? null
}
@@ -199,30 +204,37 @@ async function onPublished() {
drawerVisible.value = false
await loadSkills()
// 发布后 prompt 变了,刷新估算
estimatedTokens.value = (await getChatSession()).estimated_tokens
estimatedTokens.value = (await getChatSessionApi()).estimated_tokens
}
onBeforeRouteLeave(async () => {
if (!sessionScope.isCurrent()) return true
if (!useUserStore().token) return true
if (streaming.value)
await ElMessageBox.confirm("正在生成,离开页面会停止生成。继续?", "提示", { type: "warning" })
sessionScope.assertCurrent()
if (contextTab.value?.dirty)
await ElMessageBox.confirm("CONTEXT.md 有未保存的修改,离开后会丢失。继续?", "未保存", { type: "warning" })
sessionScope.assertCurrent()
})
loadProviders()
loadSession()
loadSkills()
onMounted(() => Promise.all([loadProviders(), loadSession(), loadSkills()]))
</script>
<template>
<div class="h-full flex flex-col gap-3">
<el-segmented
v-model="activeTab" :options="[{ label: 'Chat', value: 'chat' }, { label: 'CONTEXT.md', value: 'context' }]"
class="self-start"
/>
<div class="flex items-center gap-3">
<el-segmented
v-model="activeTab" :options="[{ label: 'Chat', value: 'chat' }, { label: 'CONTEXT.md', value: 'context' }]"
class="self-start"
/>
<el-button v-if="activeTab === 'chat'" size="small" @click="skillsVisible = !skillsVisible">
{{ skillsVisible ? '收起 Skills' : '查看 Skills' }}
</el-button>
</div>
<div v-show="activeTab === 'chat'" class="min-h-0 flex flex-1 gap-4">
<el-card class="w-72 flex shrink-0 flex-col" body-class="min-h-0 flex-1 flex flex-col gap-2" shadow="never">
<div v-show="activeTab === 'chat'" class="chat-workspace min-h-0 flex flex-1 flex-col gap-3 overflow-auto lg:flex-row">
<el-card v-if="skillsVisible" class="chat-skills max-h-60 w-full flex shrink-0 flex-col lg:max-h-none lg:w-64" body-class="min-h-0 flex-1 flex flex-col gap-2" shadow="never">
<template #header>
<div class="flex items-center justify-between">
Skills
@@ -298,7 +310,7 @@ loadSkills()
<SkillVersionDrawer v-model="drawerVisible" :skill="drawerSkill" @published="onPublished" />
<el-dialog v-model="resetVisible" title="Reset 清除上下文" width="440px">
<el-dialog v-model="resetVisible" title="Reset 清除上下文" width="min(440px, 94vw)" :close-on-click-modal="!resetting" :close-on-press-escape="!resetting" :show-close="!resetting">
<p class="mt-0">
清空当前对话,开始新的会话(旧对话保留在数据库中,不再展示)。skills 不受影响。
</p>
@@ -306,10 +318,10 @@ loadSkills()
同时清空 CONTEXT.md(会保存为一个空版本,可在历史版本中找回)
</el-checkbox>
<template #footer>
<el-button @click="resetVisible = false">
<el-button :disabled="resetting" @click="resetVisible = false">
取消
</el-button>
<el-button type="danger" @click="doReset">
<el-button type="danger" :loading="resetting" @click="doReset">
Reset
</el-button>
</template>
+22
View File
@@ -0,0 +1,22 @@
import type { ChatMessage } from "@@/apis/chat/type"
import type { DisplayItem } from "./type"
/** 把会话消息转成展示项:工具结果挂到对应的工具调用上 */
export function toItems(list: ChatMessage[]): DisplayItem[] {
const items: DisplayItem[] = []
const calls = new Map<string, DisplayItem["toolCalls"][number]>()
for (const m of list) {
if (m.role === "user") {
items.push({ key: `m${m.id}`, kind: "user", text: m.content.text, toolCalls: [], time: m.created_at })
} else if (m.role === "assistant") {
const toolCalls = (m.content.tool_calls ?? []).map(c => ({ ...c, result: null }))
toolCalls.forEach(c => calls.set(c.id, c))
items.push({ key: `m${m.id}`, kind: "assistant", text: m.content.text, toolCalls, stopped: m.content.stopped, error: m.content.error, time: m.created_at })
} else {
const call = calls.get(m.content.tool_call_id)
if (call)
call.result = m.content.result
}
}
return items
}
+22
View File
@@ -0,0 +1,22 @@
export interface ToolCard {
id: string
name: string
arguments: Record<string, unknown>
/** 未返回时为 null */
result: string | null
}
export interface DisplayItem {
key: string
kind: "user" | "assistant"
text: string
toolCalls: ToolCard[]
stopped?: boolean
error?: string
/** 生成中:这次模型调用已结束,后续文字另起一条 */
closed?: boolean
/** 生成中:模型正在思考,还没有输出文字 */
thinking?: boolean
/** 已落库的消息时间;生成中的没有 */
time?: string
}
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { CalendarSourceInfo } from "@@/apis/crawler/type"
import { formatTime } from "@@/utils/text"
defineProps<{ source: CalendarSourceInfo }>()
const date = (at: string | null) => at ? formatTime(at) : "尚无记录"
</script>
<template>
<el-card shadow="never">
<template #header>
<div class="flex items-center gap-2">
<img :src="`/flags/${source.region_code.toLowerCase()}.svg`" alt="" class="h-4 w-6 object-cover">
<span class="text-sm font-semibold">{{ source.name }}</span>
<el-tag size="small" type="info">
日历
</el-tag>
<div class="flex-1" />
<el-tag :type="!source.worker_alive ? 'danger' : source.errors.length ? 'warning' : source.schedule_success_at ? 'success' : 'info'" size="small">
{{ !source.worker_alive ? "进程离线" : source.errors.length ? "采集异常" : source.schedule_success_at ? "同步正常" : "待同步" }}
</el-tag>
</div>
</template>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-xs text-slate-500">
<dt>进程心跳</dt><dd>{{ date(source.heartbeat_at) }}</dd>
<dt>日程最近检查</dt><dd>{{ date(source.schedule_checked_at) }}</dd>
<dt>日程最近成功</dt><dd>{{ date(source.schedule_success_at) }}</dd>
<dt>最近取得结果</dt><dd>{{ date(source.latest_result_at) }}</dd>
<dt>下一场公布</dt><dd>{{ source.next_event_at ? date(source.next_event_at) : "暂未取得后续日程" }}</dd>
<dt>已到点待结果</dt><dd :class="source.overdue_count ? 'text-amber-600' : ''">
{{ source.overdue_count }} 场(最近 45 天)
</dd>
<dt>已存公告范围</dt>
<dd>{{ source.history_start ? `${date(source.history_start)} ~ ${date(source.history_end)}` : "尚无当期公告" }}</dd>
</dl>
<div v-for="error in source.errors" :key="error" class="mt-2 break-words rounded bg-rose-50 p-2 text-xs text-rose-700">
{{ error }}
</div>
<p class="mt-3 text-xs text-slate-400">
独立日历进程统一采集;范围仅表示已存首末日期,不代表历史完整。时间为北京时间。
</p>
</el-card>
</template>
@@ -1,11 +1,11 @@
<script lang="ts" setup>
import type { CrawlLog, SourceInfo } from "@/apis/crawler"
import type { NewsBrief } from "@/apis/news"
import type { CrawlLog, SourceInfo } from "@@/apis/crawler/type"
import type { NewsBrief } from "@@/apis/news/type"
import { patchSourceApi } from "@@/apis/crawler"
import { listNewsApi } from "@@/apis/news"
import { formatTime } from "@@/utils/text"
import { CircleCheck, RefreshRight } from "@element-plus/icons-vue"
import dayjs from "dayjs"
import { patchSource } from "@/apis/crawler"
import { listNews } from "@/apis/news"
import { formatTime } from "@/utils/text"
/** logs 只用来发现「本轮完成」以刷新最新入库;alert 为连续失败达到告警阈值 */
const props = defineProps<{
@@ -23,24 +23,24 @@ const latest = ref<NewsBrief[]>([])
const interval = ref(props.source.interval_seconds)
async function loadLatest() {
latest.value = (await listNews({ source: [props.source.code], limit: 5 })).items
latest.value = (await listNewsApi({ source: [props.source.code], limit: 5 })).items
}
// 本来源有新的“本轮完成”日志时刷新最新入库列表
watch(() => props.logs.at(-1), (log) => {
if (log?.message.startsWith("本轮完成") && !log.message.includes("新增 0,修订 0"))
loadLatest()
return loadLatest()
})
async function toggleEnabled(enabled: boolean | string | number) {
await patchSource(props.source.code, { enabled: Boolean(enabled) })
await patchSourceApi(props.source.code, { enabled: Boolean(enabled) })
emit("changed")
}
async function saveInterval() {
if (interval.value === props.source.interval_seconds)
return
await patchSource(props.source.code, { interval_seconds: interval.value })
await patchSourceApi(props.source.code, { interval_seconds: interval.value })
ElMessage.success("已保存,下一轮起生效")
emit("changed")
}
@@ -67,13 +67,13 @@ const history = computed(() => {
async function startHistory() {
// 按北京时间当天 0 点
await patchSource(props.source.code, { history_target: dayjs(`${historyDate.value}T00:00:00+08:00`).toISOString() })
await patchSourceApi(props.source.code, { history_target: dayjs(`${historyDate.value}T00:00:00+08:00`).toISOString() })
ElMessage.success("已开始,下一轮起分段回补")
emit("changed")
}
async function cancelHistory() {
await patchSource(props.source.code, { history_target: null })
await patchSourceApi(props.source.code, { history_target: null })
ElMessage.success("已取消,断点保留")
emit("changed")
}
@@ -93,7 +93,7 @@ const statusTag = computed(() => {
return { type: "success", text: "运行中" } as const
})
loadLatest()
onMounted(loadLatest)
</script>
<template>
@@ -160,7 +160,7 @@ loadLatest()
<span v-else>不可用</span>
</div>
<template v-if="!source.supports_history">
该来源的列表接口只能翻最近几页,无法回补历史
此来源不支持控制台连续翻页回补;官方归档由独立任务处理。
</template>
<template v-else>
<div class="flex items-center gap-2">
@@ -0,0 +1,54 @@
import type { CrawlLog } from "@@/apis/crawler/type"
import { listLogsApi, subscribeCrawlerApi } from "@@/apis/crawler"
import { useEventSource } from "@/http/useEventSource"
export function useCrawlerLogs(onStarted: (code: string) => void) {
const MAX_LOGS = 1000
const LEVELS = ["INFO", "WARNING", "ERROR"]
const logs = reactive<Record<string, CrawlLog[]>>({})
const logSource = ref("")
const levels = ref([...LEVELS])
const autoScroll = ref(true)
const logBox = ref<HTMLElement>()
// 各来源日志按全局自增 id 合并
const visibleLogs = computed(() => Object.values(logs).flat().filter(log => (!logSource.value || log.source_code === logSource.value)
&& (levels.value.includes(log.level) || (log.level === "CRITICAL" && levels.value.includes("ERROR")))).sort((a, b) => a.id - b.id).slice(-MAX_LOGS))
watch(() => visibleLogs.value.at(-1)?.id, async () => {
if (!autoScroll.value)
return
await nextTick()
logBox.value?.scrollTo({ top: logBox.value.scrollHeight })
})
function levelClass(level: string) {
if (level === "WARNING")
return "bg-amber-500/20 text-amber-300"
if (level === "ERROR" || level === "CRITICAL")
return "bg-rose-500/20 text-rose-300"
return "bg-sky-500/20 text-sky-300"
}
function appendLog(log: CrawlLog) {
const list = (logs[log.source_code] ??= [])
// SSE 可能早于历史日志到达,按 id 去重
if (list.length && list.at(-1)!.id >= log.id)
return
list.push(log)
if (list.length > MAX_LOGS)
list.splice(0, list.length - MAX_LOGS)
if (log.message.startsWith("子进程已启动"))
onStarted(log.source_code)
}
const { connected } = useEventSource<CrawlLog>(subscribeCrawlerApi, appendLog)
async function loadHistory(code: string) {
const history = await listLogsApi(code, 200)
const live = logs[code] ?? []
const firstLiveId = live[0]?.id ?? Infinity
logs[code] = [...history.filter(log => log.id < firstLiveId), ...live]
}
return { logs, logSource, levels, autoScroll, logBox, visibleLogs, levelClass, connected, loadHistory, LEVELS }
}
+159 -71
View File
@@ -1,85 +1,72 @@
<script lang="ts" setup>
import type { CrawlLog, SourcesInfo } from "@/apis/crawler"
import type { SourcesInfo } from "@@/apis/crawler/type"
import { listSourcesApi, restartCrawlerApi } from "@@/apis/crawler"
import { useSessionScope } from "@@/composables/useSessionScope"
import { formatTime } from "@@/utils/text"
import { Monitor, RefreshRight } from "@element-plus/icons-vue"
import { listLogs, listSources, restartCrawler } from "@/apis/crawler"
import { useEventSource } from "@/composables/useEventSource"
import SourcePanel from "@/pages/crawler/components/SourcePanel.vue"
import { formatTime } from "@/utils/text"
import { logRequestError } from "@/http/axios"
import CalendarSourcePanel from "./components/CalendarSourcePanel.vue"
import SourcePanel from "./components/SourcePanel.vue"
import { useCrawlerLogs } from "./composables/useCrawlerLogs"
const sessionScope = useSessionScope()
const MAX_LOGS = 1000
// 连续失败达到该次数时卡片告警
const FAILURE_ALERT = 3
const RESTART_TIMEOUT_MS = 60_000
const LEVELS = ["INFO", "WARNING", "ERROR"]
const info = ref<SourcesInfo>()
const logs = reactive<Record<string, CrawlLog[]>>({})
const loading = ref(false)
const failed = ref(false)
const detailVisible = ref(false)
const detailCode = ref("")
const detailKind = ref<"news" | "calendar">("news")
const newsDetail = computed(() => detailKind.value === "news" ? info.value?.sources.find(s => s.code === detailCode.value) : undefined)
const calendarDetail = computed(() => detailKind.value === "calendar" ? info.value?.calendar_sources.find(s => s.code === detailCode.value) : undefined)
function openDetail(code: string, kind: "news" | "calendar") {
detailCode.value = code
detailKind.value = kind
detailVisible.value = true
}
const collector = ref("all")
const region = ref("all")
const regions = { all: "全部地域", CN: "中国", US: "美国", EA: "欧元区", GB: "英国", DE: "德国", FR: "法国", GLOBAL: "全球" }
const newsSources = computed(() => info.value?.sources.filter(s => region.value === "all" || s.region_code === region.value) ?? [])
const calendarSources = computed(() => info.value?.calendar_sources.filter(s => region.value === "all" || s.region_code === region.value) ?? [])
// 正在等待重新上线的来源
const restarting = reactive(new Set<string>())
const { logs, logSource, levels, autoScroll, logBox, visibleLogs, levelClass, connected, loadHistory, LEVELS } = useCrawlerLogs(code => restarting.delete(code))
// 日志面板筛选:空字符串为全部来源
const logSource = ref("")
const levels = ref([...LEVELS])
const autoScroll = ref(true)
const logBox = ref<HTMLElement>()
async function refresh() {
info.value = await listSources()
loading.value = info.value === undefined
try {
info.value = await listSourcesApi()
failed.value = false
} catch (error) {
failed.value = true
throw error
} finally {
loading.value = false
}
}
const sourceNames = computed(() => Object.fromEntries(info.value?.sources.map(s => [s.code, s.name]) ?? []))
// 各来源日志按全局自增 id 合并
const visibleLogs = computed(() => Object.values(logs).flat().filter(log => (!logSource.value || log.source_code === logSource.value)
&& (levels.value.includes(log.level) || (log.level === "CRITICAL" && levels.value.includes("ERROR")))).sort((a, b) => a.id - b.id).slice(-MAX_LOGS))
watch(() => visibleLogs.value.at(-1)?.id, async () => {
if (!autoScroll.value)
return
await nextTick()
logBox.value?.scrollTo({ top: logBox.value.scrollHeight })
})
function levelClass(level: string) {
if (level === "WARNING")
return "bg-amber-500/20 text-amber-300"
if (level === "ERROR" || level === "CRITICAL")
return "bg-rose-500/20 text-rose-300"
return "bg-sky-500/20 text-sky-300"
}
function appendLog(log: CrawlLog) {
const list = (logs[log.source_code] ??= [])
// SSE 可能早于历史日志到达,按 id 去重
if (list.length && list.at(-1)!.id >= log.id)
return
list.push(log)
if (list.length > MAX_LOGS)
list.splice(0, list.length - MAX_LOGS)
if (log.message.startsWith("子进程已启动"))
restarting.delete(log.source_code)
}
const { connected } = useEventSource<CrawlLog>("/api/crawler/logs/stream", "log", appendLog)
async function loadHistory(code: string) {
const history = await listLogs(code, 200)
const live = logs[code] ?? []
const firstLiveId = live[0]?.id ?? Infinity
logs[code] = [...history.filter(log => log.id < firstLiveId), ...live]
}
async function restart(source: string) {
const label = source === "all" ? "全部来源" : info.value?.sources.find(s => s.code === source)?.name
await ElMessageBox.confirm(`确定重启 ${label} 的采集进程?`, "重启确认", { type: "warning" })
sessionScope.assertCurrent()
const codes = source === "all" ? info.value!.sources.map(s => s.code) : [source]
codes.forEach(code => restarting.add(code))
try {
await restartCrawler(source)
await restartCrawlerApi(source)
} catch (error) {
codes.forEach(code => restarting.delete(code))
throw error
}
setTimeout(() => {
if (!sessionScope.isCurrent()) return
const stuck = codes.filter(code => restarting.has(code))
if (stuck.length) {
stuck.forEach(code => restarting.delete(code))
@@ -92,14 +79,27 @@ function formatBytes(bytes: number) {
return bytes > 1024 ** 3 ? `${(bytes / 1024 ** 3).toFixed(2)} GB` : `${(bytes / 1024 ** 2).toFixed(1)} MB`
}
const timer = setInterval(refresh, 3000)
const timer = setInterval(() => refresh().catch(logRequestError), 3000)
onBeforeUnmount(() => clearInterval(timer))
refresh().then(() => Promise.all(info.value!.sources.map(s => loadHistory(s.code))))
onMounted(async () => {
await refresh()
await Promise.all(info.value!.sources.map(s => loadHistory(s.code)))
})
</script>
<template>
<div v-if="info" class="flex flex-col gap-4">
<div v-if="!info" v-loading="loading" class="min-h-40">
<el-result v-if="failed" icon="error" title="采集状态读取失败">
<template #extra>
<el-button @click="refresh">
重试
</el-button>
</template>
</el-result>
</div>
<div v-else class="flex flex-col gap-4">
<el-alert v-if="failed" type="error" :closable="false" title="状态刷新失败,以下为上次读取的数据" />
<el-card shadow="never" body-class="flex flex-wrap items-center gap-x-6 gap-y-2">
<div class="flex items-center gap-2 text-sm text-slate-900 font-semibold">
<el-icon><Monitor /></el-icon>
@@ -123,24 +123,112 @@ refresh().then(() => Promise.all(info.value!.sources.map(s => loadHistory(s.code
</div>
<div class="flex-1" />
<el-button type="danger" plain :icon="RefreshRight" :loading="restarting.size > 0" @click="restart('all')">
重启全部采集
重启全部新闻采集
</el-button>
</el-card>
<div class="text-sm text-slate-900 font-semibold">
采集来源({{ info.sources.length }})
</div>
<div class="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4">
<SourcePanel
v-for="source in info.sources" :key="source.code"
:source="source"
:logs="logs[source.code] ?? []"
:restarting="restarting.has(source.code)"
:alert="source.consecutive_failures >= FAILURE_ALERT"
@restart="restart(source.code)"
@changed="refresh"
/>
<div class="flex flex-wrap items-center gap-3">
<el-select v-model="collector" class="w-40">
<el-option value="all" label="全部采集类型" /><el-option value="news" label="新闻" /><el-option value="calendar" label="经济日历" />
</el-select>
<el-select v-model="region" class="w-36">
<el-option v-for="(name, code) in regions" :key="code" :value="code" :label="name" />
</el-select>
</div>
<el-card v-if="collector !== 'calendar'" shadow="never" body-class="p-0">
<template #header>
新闻来源 · {{ newsSources.length }}
</template>
<el-table :data="newsSources" size="small" @row-click="row => openDetail(row.code, 'news')">
<el-table-column prop="name" label="来源" min-width="170" />
<el-table-column label="地域" width="95">
<template #default="{ row }">
{{ regions[row.region_code as keyof typeof regions] ?? row.region_code }}
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag size="small" :type="row.enabled && row.process?.state === 'running' ? 'success' : 'info'">
{{ !row.enabled ? '已停用' : row.process?.state === 'running' ? '运行中' : '未运行' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="频率" width="85">
<template #default="{ row }">
{{ row.interval_seconds }} 秒
</template>
</el-table-column>
<el-table-column label="今日新增 / 修订" width="130">
<template #default="{ row }">
{{ row.today_new }} / {{ row.today_revised }}
</template>
</el-table-column>
<el-table-column label="最近成功" width="155">
<template #default="{ row }">
{{ row.last_success_at ? formatTime(row.last_success_at) : '—' }}
</template>
</el-table-column>
<el-table-column label="异常 / 回补" min-width="230">
<template #default="{ row }">
<span :class="row.consecutive_failures >= FAILURE_ALERT ? 'text-red-600' : ''">{{ row.last_error || (row.history_done ? '历史回补完成' : row.history_position ? `回补至 ${row.history_position}` : '—') }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click.stop="openDetail(row.code, 'news')">
详情
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card v-if="collector !== 'news'" shadow="never" body-class="p-0">
<template #header>
经济日历 · {{ calendarSources.length }} <span class="text-xs text-slate-500 font-normal">(只读)</span>
</template>
<el-table :data="calendarSources" size="small" @row-click="row => openDetail(row.code, 'calendar')">
<el-table-column prop="name" label="来源" min-width="170" />
<el-table-column label="地域" width="95">
<template #default="{ row }">
{{ regions[row.region_code as keyof typeof regions] }}
</template>
</el-table-column>
<el-table-column label="采集进程" width="110">
<template #default="{ row }">
<el-tag :type="row.worker_alive ? 'success' : 'danger'" size="small">
{{ row.worker_alive ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="日程更新" min-width="155">
<template #default="{ row }">
{{ row.schedule_success_at ? formatTime(row.schedule_success_at) : '—' }}
</template>
</el-table-column>
<el-table-column label="下个事件" min-width="155">
<template #default="{ row }">
{{ row.next_event_at ? formatTime(row.next_event_at) : '—' }}
</template>
</el-table-column>
<el-table-column prop="overdue_count" label="待取结果" width="90" />
<el-table-column label="异常" min-width="230">
<template #default="{ row }">
<span class="text-red-600">{{ row.errors.join(';') || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click.stop="openDetail(row.code, 'calendar')">
详情
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer v-model="detailVisible" :title="newsDetail?.name ?? calendarDetail?.name ?? '来源详情'" size="min(640px, 100vw)">
<SourcePanel v-if="newsDetail" :key="newsDetail.code" :source="newsDetail" :logs="logs[newsDetail.code] ?? []" :restarting="restarting.has(newsDetail.code)" :alert="newsDetail.consecutive_failures >= FAILURE_ALERT" @restart="restart(newsDetail.code)" @changed="refresh" />
<CalendarSourcePanel v-if="calendarDetail" :source="calendarDetail" />
</el-drawer>
<el-card shadow="never">
<template #header>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { useUserStore } from "@/pinia/stores/user"
import { safeRedirect } from "@/router/guards"
const user = useUserStore()
const route = useRoute()
const router = useRouter()
const status = computed(() => Number(route.meta.status))
async function logout() {
await user.logout()
await router.replace({ name: "login" })
}
</script>
<template>
<div class="h-full flex-center flex-col gap-4">
<h1>{{ status }}</h1>
<p>{{ status === 403 ? '当前账号没有访问权限' : status === 404 ? '页面不存在' : '暂时无法恢复身份,请重试' }}</p>
<el-button @click="router.replace(status === 503 ? safeRedirect(route.query.redirect) : '/news')">
{{ status === 503 ? '重试' : '返回首页' }}
</el-button>
<el-button v-if="user.token" link @click="logout">
退出登录
</el-button>
</div>
</template>
+93 -8
View File
@@ -1,19 +1,90 @@
<script lang="ts" setup>
import type { FormInstance, FormRules } from "element-plus"
import { getCaptchaApi, loginApi } from "@@/apis/auth"
import { TrendCharts } from "@element-plus/icons-vue"
import { useUserStore } from "@/pinia/user"
import axios, { CanceledError } from "axios"
import { useUserStore } from "@/pinia/stores/user"
import { safeRedirect } from "@/router/guards"
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const form = reactive({ username: "", password: "" })
const form = reactive({ username: "", password: "", captcha_id: "", code: "" })
const formRef = ref<FormInstance>()
const rules: FormRules = {
username: [{ required: true, whitespace: true, message: "请输入用户名", trigger: "blur" }],
password: [{ required: true, message: "请输入密码", trigger: "blur" }],
code: [{ validator: (_rule, value, callback) => {
callback(captchaEnabled.value && !value?.trim() ? new Error("请输入验证码") : undefined)
}, trigger: "blur" }]
}
const captchaError = ref("")
const captchaReady = ref(false)
const image = ref("")
const captchaEnabled = ref(false)
const refreshing = ref(false)
let challengeVersion = 0
let disposed = false
async function refreshCaptcha() {
const version = ++challengeVersion
refreshing.value = true
captchaReady.value = false
captchaError.value = ""
form.captcha_id = ""
form.code = ""
try {
const result = await getCaptchaApi()
if (!disposed && version === challengeVersion) {
captchaReady.value = true
captchaEnabled.value = result.enabled
if (result.enabled) {
form.captcha_id = result.captcha_id
image.value = result.image
} else {
image.value = ""
}
}
} catch (error) {
if (!disposed && version === challengeVersion)
captchaError.value = error instanceof Error ? error.message : "无法读取登录验证配置"
throw error
} finally {
if (version === challengeVersion) refreshing.value = false
}
}
onMounted(refreshCaptcha)
onBeforeUnmount(() => {
disposed = true
challengeVersion++
})
const loading = ref(false)
async function submit() {
if (loading.value || refreshing.value)
return
if (!captchaReady.value) return
if (captchaEnabled.value && !form.captcha_id) return
loading.value = true
let attempted = false
const version = userStore.sessionVersion
try {
await userStore.login(form.username, form.password)
router.replace((route.query.redirect as string) || "/")
await formRef.value!.validate()
if (disposed || version !== userStore.sessionVersion) throw new CanceledError("页面或会话已切换")
attempted = true
const result = await loginApi({
username: form.username,
password: form.password,
...(captchaEnabled.value ? { captcha_id: form.captcha_id, code: form.code } : {})
})
if (disposed || version !== userStore.sessionVersion) throw new CanceledError("页面或会话已切换")
userStore.setToken(result.token)
await router.replace(safeRedirect(route.query.redirect))
} catch (error) {
if (attempted && captchaEnabled.value && !disposed && !axios.isCancel(error))
await Promise.allSettled([refreshCaptcha()])
throw error
} finally {
loading.value = false
}
@@ -34,14 +105,28 @@ async function submit() {
加密货币交易研究工具
</div>
</div>
<el-form :model="form" @submit.prevent="submit">
<el-form-item>
<el-alert v-if="captchaError" type="error" :closable="false" class="mb-4" :title="captchaError">
<el-button link type="primary" :loading="refreshing" @click="refreshCaptcha">
重试
</el-button>
</el-alert>
<el-form ref="formRef" :model="form" :rules="rules" :disabled="loading" @submit.prevent="submit">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" autocomplete="username" />
</el-form-item>
<el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" show-password autocomplete="current-password" />
</el-form-item>
<el-button type="primary" native-type="submit" size="large" :loading="loading" class="mt-2 w-full">
<el-form-item v-if="captchaEnabled" prop="code">
<div class="w-full flex gap-2">
<el-input v-model="form.code" placeholder="验证码" autocomplete="off" maxlength="5" :disabled="loading" />
<button type="button" class="shrink-0 cursor-pointer" :disabled="loading || refreshing" title="刷新验证码" @click="refreshCaptcha">
<img v-if="image" :src="image" alt="登录验证码,点击刷新" class="h-10 w-30">
<span v-else>刷新验证码</span>
</button>
</div>
</el-form-item>
<el-button type="primary" native-type="submit" size="large" :loading="loading || refreshing" :disabled="!captchaReady" class="mt-2 w-full">
登录
</el-button>
</el-form>
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { Market, TradableSymbol } from "@/apis/market"
import { addSymbol, listTradable } from "@/apis/market"
import type { Market, TradableSymbol } from "@@/apis/market/type"
import { addSymbolApi, listTradableApi } from "@@/apis/market"
const emit = defineEmits<{ added: [market: Market, symbol: string] }>()
const visible = defineModel<boolean>({ required: true })
@@ -19,7 +19,7 @@ watch([visible, market], async ([open]) => {
symbol.value = ""
loading.value = true
try {
tradable.value = await listTradable(market.value)
tradable.value = await listTradableApi(market.value)
} finally {
loading.value = false
}
@@ -28,7 +28,7 @@ watch([visible, market], async ([open]) => {
async function submit() {
submitting.value = true
try {
await addSymbol(market.value, symbol.value)
await addSymbolApi(market.value, symbol.value)
ElMessage.success(`已添加 ${symbol.value},后台开始补齐全历史数据`)
emit("added", market.value, symbol.value)
visible.value = false
@@ -1,10 +1,14 @@
<script lang="ts" setup>
import type { MarketSymbol } from "@/apis/market"
import { deleteSymbol } from "@/apis/market"
import { INTERVAL_LABELS } from "@/pages/market/chart"
import type { MarketSymbol } from "@@/apis/market/type"
import { deleteSymbolApi } from "@@/apis/market"
import { useSessionScope } from "@@/composables/useSessionScope"
import { INTERVAL_LABELS } from "../chart"
defineProps<{ symbols: MarketSymbol[] }>()
const emit = defineEmits<{ changed: [] }>()
const sessionScope = useSessionScope()
const visible = defineModel<boolean>({ required: true })
const STATUS = {
@@ -22,7 +26,8 @@ function overall(s: MarketSymbol) {
async function remove(s: MarketSymbol) {
await ElMessageBox.confirm(`删除 ${s.symbol}(${s.market === "spot" ? "现货" : "永续"})及其全部 K 线?之后可重新添加并补齐。`, "删除确认", { type: "warning" })
await deleteSymbol(s.id)
sessionScope.assertCurrent()
await deleteSymbolApi(s.id)
ElMessage.success("已删除")
emit("changed")
}
@@ -0,0 +1,196 @@
import type { KlineMessage, KlineRow, Market, MarketSymbol, TickerMessage } from "@@/apis/market/type"
import type { CandleType, Chart, DataLoader, KLineData } from "klinecharts"
import { getTickerApi, listKlinesApi, listSymbolsApi } from "@@/apis/market"
import { usePersisted } from "@@/utils/storage"
import { dispose, init } from "klinecharts"
import { logRequestError } from "@/http/axios"
import { BINANCE_STYLES, DEFAULT_FAVORITE_INTERVALS, DOWN, INTERVAL_LABELS, MAIN_INDICATORS, SUB_INDICATORS, TIME_LINE, toPeriod, UP } from "../chart"
import { useMarketSocket } from "./useMarketSocket"
export function useMarketChart() {
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: "um", 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 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)
const chart = shallowRef<Chart | null>(null)
let barCallback: ((bar: KLineData) => void) | undefined
// 切换交易对或周期后,丢弃旧请求的返回
let loadVersion = 0
let tickerVersion = 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 listKlinesApi({
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 version = ++tickerVersion
const { market, symbol } = selection.value
const t = await getTickerApi(market, symbol)
if (version !== tickerVersion)
return
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.value || !s)
return
loadVersion++
chart.value.setStyles({ candle: { type: interval.value === TIME_LINE ? "area" : candleType.value } })
chart.value.setSymbol({ ticker: s.symbol, pricePrecision: s.price_precision, volumePrecision: s.volume_precision })
chart.value.setPeriod(toPeriod(dataInterval.value))
subscribe({ market: s.market, symbol: s.symbol, interval: dataInterval.value })
}
function applyIndicators() {
if (!chart.value)
return
MAIN_INDICATORS.forEach(name => chart.value!.removeIndicator({ paneId: CANDLE_PANE, name }))
SUB_INDICATORS.forEach(name => chart.value!.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.value!.createIndicator({ name, paneId: CANDLE_PANE, ...calcParams(name) }, true))
subIndicators.value.forEach(name => chart.value!.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 listSymbolsApi()
}
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 disposed = false
let resizeObserver: ResizeObserver | undefined
const refreshTimer = setInterval(() => refreshSymbols().catch(logRequestError), SYMBOLS_REFRESH_MS)
onMounted(async () => {
await refreshSymbols()
if (disposed) return
// 之前选中的交易对已被删除时,优先回到 BTCUSDT
const fallback = symbols.value.find(s => s.symbol === "BTCUSDT") ?? symbols.value[0]
if (!current.value && fallback)
selection.value = { market: fallback.market, symbol: fallback.symbol }
chart.value = init(chartEl.value!, { locale: "zh-CN", timezone: "Asia/Shanghai", styles: "dark" })
chart.value!.setStyles(BINANCE_STYLES)
chart.value!.setDataLoader(dataLoader)
applyChartState()
applyIndicators()
await loadTicker()
if (disposed) return
resizeObserver = new ResizeObserver(() => chart.value?.resize())
resizeObserver.observe(chartEl.value!)
})
onBeforeUnmount(() => {
disposed = true
++loadVersion
++tickerVersion
clearInterval(refreshTimer)
resizeObserver?.disconnect()
if (chartEl.value)
dispose(chartEl.value)
})
return { symbols, selection, interval, favorites, mainIndicators, subIndicators, indicatorParams, candleType, chartEl, chart, current, dataInterval, intervals, backfillProgress, ticker, priceColor, connected, loadTicker, selectSymbol, toggleFavorite, refreshSymbols, onAdded, formatNumber, formatBig }
}
@@ -0,0 +1,95 @@
import type { KlineMessage, MarketMessage, Subscription, TickerMessage } from "@@/apis/market/type"
import { createWebSocketTicketApi } from "@@/apis/auth"
import { createMarketSocketApi } from "@@/apis/market"
import axios from "axios"
import { ElMessage } from "element-plus"
import { ApiError, rejectAuthentication } from "@/http/axios"
import { useUserStore } from "@/pinia/stores/user"
const RETRY_MAX_MS = 30_000
/** 行情 WebSocket:断线指数退避重连,重连后自动恢复当前订阅;切换订阅后丢弃旧订阅的残留消息 */
export function useMarketSocket(onKline: (k: KlineMessage) => void, onTicker: (t: TickerMessage) => void) {
const connected = ref(false)
const user = useUserStore()
const version = user.sessionVersion
let socket: WebSocket | undefined
let current: Subscription | undefined
let retryMs = 1000
let closed = false
let retryTimer: ReturnType<typeof setTimeout> | undefined
function retry() {
if (closed || version !== user.sessionVersion)
return
retryTimer = setTimeout(connect, retryMs)
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS)
}
async function connect() {
if (closed || version !== user.sessionVersion)
return
try {
const { ticket } = await createWebSocketTicketApi()
if (closed || version !== user.sessionVersion)
return
const ws = createMarketSocketApi(ticket)
socket = ws
const active = () => !closed && version === user.sessionVersion && socket === ws
ws.onopen = () => {
if (!active()) {
ws.close()
return
}
connected.value = true
retryMs = 1000
if (current) ws.send(JSON.stringify(current))
}
ws.onmessage = (event) => {
if (!active()) return
const message: MarketMessage = JSON.parse(event.data)
if (message.type === "error") {
ElMessage.error(message.message)
return
}
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)
}
ws.onclose = (event) => {
if (!active()) return
connected.value = false
if (event.code === 4401) {
rejectAuthentication(version)
return
}
retry()
}
} catch (error) {
if (axios.isCancel(error) || (error instanceof ApiError && [401, 403].includes(error.code)))
return
retry()
}
}
function subscribe(subscription: Subscription) {
current = subscription
if (socket?.readyState === WebSocket.OPEN)
socket.send(JSON.stringify(subscription))
}
void connect()
function close() {
closed = true
clearTimeout(retryTimer)
socket?.close()
connected.value = false
}
watch(() => user.sessionVersion, close, { flush: "sync" })
onBeforeUnmount(close)
return { connected, subscribe }
}
+6 -188
View File
@@ -1,199 +1,17 @@
<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"
<script setup lang="ts">
import { DRAW_TOOLS, INTERVAL_LABELS, MAIN_INDICATORS, SUB_INDICATORS } 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: "um", 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")
import { useMarketChart } from "./composables/useMarketChart"
const { symbols, selection, interval, favorites, mainIndicators, subIndicators, indicatorParams, candleType, chartEl, chart, current, dataInterval, intervals, backfillProgress, ticker, priceColor, connected, selectSymbol, toggleFavorite, refreshSymbols, onAdded, formatNumber, formatBig } = useMarketChart()
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
let tickerVersion = 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 version = ++tickerVersion
const { market, symbol } = selection.value
const t = await getTicker(market, symbol)
if (version !== tickerVersion)
return
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()
// 之前选中的交易对已被删除时,优先回到 BTCUSDT
const fallback = symbols.value.find(s => s.symbol === "BTCUSDT") ?? symbols.value[0]
if (!current.value && fallback)
selection.value = { market: fallback.market, symbol: fallback.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-lg bg-[#161a1e] text-[#eaecef]">
<div class="market h-full min-h-120 flex flex-col overflow-hidden rounded-lg 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
@@ -246,7 +64,7 @@ onBeforeUnmount(() => {
</div>
<!-- 周期与指标工具栏 -->
<div class="flex items-center gap-1 border-b border-b-solid border-[#2b3139] px-3 py-1 text-sm">
<div class="flex flex-wrap 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"
-83
View File
@@ -1,83 +0,0 @@
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
let retryTimer: ReturnType<typeof setTimeout> | undefined
function connect() {
// 组件已卸载时不再建立连接
if (closed)
return
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
retryTimer = 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
clearTimeout(retryTimer)
socket?.close()
})
return { connected, subscribe }
}
+15 -8
View File
@@ -1,9 +1,9 @@
<script lang="ts" setup>
import type { NewsDetail, Revision } from "@/apis/news"
import type { NewsDetail, Revision } from "@@/apis/news/type"
import { getNewsApi, listRevisionsApi } from "@@/apis/news"
import { useSourceNames } from "@@/composables/useSourceNames"
import { diffLines, formatTime, htmlToLines } from "@@/utils/text"
import { Link } from "@element-plus/icons-vue"
import { getNews, listRevisions } from "@/apis/news"
import { useSourceNames } from "@/composables/useSourceNames"
import { diffLines, formatTime, htmlToLines } from "@/utils/text"
const props = defineProps<{ newsId: number | null }>()
@@ -11,6 +11,8 @@ const sourceNames = useSourceNames()
const detail = ref<NewsDetail>()
const revisions = ref<Revision[]>([])
const activeTab = ref("content")
const summaryOnly = computed(() => detail.value?.extra.content_scope === "feed_summary"
|| detail.value?.extra.content_scope === "public_summary")
// 当前查看的修订序号,与上一版对比
const selectedRevision = ref<number>()
// 加载失败(错误提示已由 http 拦截器弹出):停止转圈,显示重试
@@ -22,7 +24,7 @@ async function load(id: number) {
const seq = ++requestSeq
failed.value = false
try {
const [news, revs] = await Promise.all([getNews(id), listRevisions(id)])
const [news, revs] = await Promise.all([getNewsApi(id), listRevisionsApi(id)])
if (seq !== requestSeq)
return
detail.value = news
@@ -42,8 +44,8 @@ watch(() => props.newsId, (id) => {
if (id == null)
requestSeq++
else
load(id)
})
return load(id)
}, { immediate: true })
// 第三方正文放进无脚本权限的 sandbox iframe,避免其中的脚本访问本站
const srcdoc = computed(() => detail.value && `<!doctype html><html><head><meta charset="utf-8">
@@ -102,8 +104,13 @@ const diffStats = computed(() => ({
</el-link>
</div>
</div>
<el-alert
v-if="summaryOnly"
title="此条新闻仅采集公开摘要,完整内容请查看原文链接。"
type="info" :closable="false" show-icon
/>
<el-tabs v-model="activeTab" class="min-h-0 flex flex-1 flex-col">
<el-tab-pane label="正文" name="content" class="h-full">
<el-tab-pane :label="summaryOnly ? '摘要' : '正文'" 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">
@@ -0,0 +1,78 @@
import type { NewsBrief, NewsEvent } from "@@/apis/news/type"
import { listNewsApi, subscribeNewsApi } from "@@/apis/news"
import { useSourceNames } from "@@/composables/useSourceNames"
import { usePersisted } from "@@/utils/storage"
import { useEventSource } from "@/http/useEventSource"
export function useNewsFeed() {
const sourceNames = useSourceNames()
const filters = reactive({
source: [] as string[],
q: "",
range: null as [Date, Date] | null
})
const items = ref<NewsBrief[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = usePersisted("news-page-size", 30)
const loading = ref(false)
const failed = ref(false)
const pendingCount = ref(0)
const openedId = ref<number | null>(null)
const listBox = ref<HTMLElement>()
// 多次快速翻页时只采用最后一次请求的结果
let requestSeq = 0
async function load() {
const seq = ++requestSeq
loading.value = true
try {
const result = await listNewsApi({
source: filters.source.length ? filters.source : undefined,
q: filters.q || undefined,
start: filters.range?.[0].toISOString(),
end: filters.range?.[1].toISOString(),
page: page.value,
limit: pageSize.value
})
if (seq !== requestSeq)
return
failed.value = false
items.value = result.items
total.value = result.total
if (page.value === 1)
pendingCount.value = 0
listBox.value?.scrollTo({ top: 0 })
} catch (error) {
if (seq === requestSeq) failed.value = true
throw error
} finally {
if (seq === requestSeq)
loading.value = false
}
}
function search() {
if (page.value !== 1) page.value = 1
else return load()
}
watch([page, pageSize], ([, size], [, oldSize]) => {
if (size !== oldSize && page.value !== 1) {
page.value = 1
return
}
return load()
})
// 只提示符合当前来源筛选的新条目;关键字和时间范围由刷新后的查询决定
const { connected } = useEventSource<NewsEvent>(subscribeNewsApi, (event) => {
if (event.kind === "new" && (filters.source.length === 0 || filters.source.includes(event.source_code)))
pendingCount.value++
})
onMounted(load)
return { sourceNames, filters, items, total, page, pageSize, loading, failed, pendingCount, openedId, listBox, connected, load, search }
}
+42 -80
View File
@@ -1,73 +1,19 @@
<script lang="ts" setup>
import type { NewsBrief, NewsEvent } from "@/apis/news"
<script setup lang="ts">
import { formatTime } from "@@/utils/text"
import { Refresh, Search } from "@element-plus/icons-vue"
import { listNews } from "@/apis/news"
import { useEventSource } from "@/composables/useEventSource"
import { useSourceNames } from "@/composables/useSourceNames"
import NewsDetail from "@/pages/news/components/NewsDetail.vue"
import { usePersisted } from "@/utils/storage"
import { formatTime } from "@/utils/text"
import NewsDetail from "./components/NewsDetail.vue"
import { useNewsFeed } from "./composables/useNewsFeed"
const sourceNames = useSourceNames()
const filters = reactive({
source: [] as string[],
q: "",
range: null as [Date, Date] | null
const { sourceNames, filters, items, total, page, pageSize, loading, failed, pendingCount, openedId, listBox, connected, load, search } = useNewsFeed()
const mobile = ref(false)
let media: MediaQueryList
const updateMobile = () => mobile.value = media.matches
onMounted(() => {
media = window.matchMedia("(max-width: 760px)")
updateMobile()
media.addEventListener("change", updateMobile)
})
const items = ref<NewsBrief[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = usePersisted("news-page-size", 30)
const loading = ref(false)
const pendingCount = ref(0)
const openedId = ref<number | null>(null)
const listBox = ref<HTMLElement>()
// 多次快速翻页时只采用最后一次请求的结果
let requestSeq = 0
async function load() {
const seq = ++requestSeq
loading.value = true
try {
const result = await listNews({
source: filters.source.length ? filters.source : undefined,
q: filters.q || undefined,
start: filters.range?.[0].toISOString(),
end: filters.range?.[1].toISOString(),
page: page.value,
limit: pageSize.value
})
if (seq !== requestSeq)
return
items.value = result.items
total.value = result.total
if (page.value === 1)
pendingCount.value = 0
listBox.value?.scrollTo({ top: 0 })
} finally {
if (seq === requestSeq)
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function changePageSize() {
page.value = 1
load()
}
// 只提示符合当前来源筛选的新条目;关键字和时间范围由刷新后的查询决定
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()
onBeforeUnmount(() => media?.removeEventListener("change", updateMobile))
</script>
<template>
@@ -89,11 +35,9 @@ load()
<el-card shadow="never" body-class="flex flex-wrap items-center gap-3">
<span class="text-xs text-slate-500">来源</span>
<el-checkbox-group v-model="filters.source" @change="search">
<el-checkbox v-for="(name, code) in sourceNames" :key="code" :value="code" border class="mr-2!">
{{ name }}
</el-checkbox>
</el-checkbox-group>
<el-select v-model="filters.source" multiple collapse-tags collapse-tags-tooltip filterable clearable class="w-64" placeholder="全部来源" @change="search">
<el-option v-for="(name, code) in sourceNames" :key="code" :value="code" :label="name" />
</el-select>
<el-input v-model="filters.q" :prefix-icon="Search" placeholder="关键字" clearable class="w-60" @keyup.enter="search" @clear="search" />
<el-date-picker
v-model="filters.range" type="datetimerange" start-placeholder="开始" end-placeholder="结束"
@@ -104,18 +48,18 @@ load()
</el-button>
</el-card>
<div class="min-h-0 flex flex-1 gap-4">
<el-card shadow="never" class="w-2/5 flex shrink-0 flex-col" body-class="min-h-0 flex-1 flex flex-col p-0">
<div class="news-workspace min-h-0 flex flex-1 gap-3">
<el-card shadow="never" class="news-list min-w-0 flex flex-col" body-class="min-h-0 flex-1 flex flex-col p-0">
<template #header>
<div class="flex items-center justify-between">
消息流
<span class="num text-xs text-slate-500 font-normal">共 {{ total.toLocaleString() }} 条</span>
</div>
</template>
<div ref="listBox" v-loading="loading" class="min-h-0 flex flex-1 flex-col gap-2 overflow-auto p-3">
<div ref="listBox" v-loading="loading" class="min-h-0 flex flex-1 flex-col overflow-auto">
<div
v-for="item in items" :key="item.id"
class="cursor-pointer border rounded-lg border-solid p-3 transition-colors"
class="cursor-pointer border-b border-solid px-3 py-2 transition-colors"
:class="item.id === openedId ? 'border-[var(--el-color-primary)] bg-sky-50/50' : 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'"
@click="openedId = item.id"
>
@@ -135,22 +79,40 @@ load()
<div v-if="item.title" class="mb-1 text-slate-900 font-semibold">
{{ item.title }}
</div>
<div class="line-clamp-3 text-sm text-slate-600 leading-relaxed">
<div class="line-clamp-2 text-sm text-slate-600 leading-relaxed">
{{ item.summary || item.excerpt }}
</div>
</div>
<el-empty v-if="!loading && items.length === 0" description="没有符合条件的新闻" />
<el-alert v-if="failed" type="error" :closable="false" title="新闻读取失败;已有内容保留">
<el-button link @click="load">
重试
</el-button>
</el-alert>
<el-empty v-if="!loading && !failed && items.length === 0" description="没有符合条件的新闻" />
</div>
<div class="border-t border-t-slate-200 border-t-solid px-3 py-2">
<el-pagination
v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[20, 30, 50, 100]" :pager-count="5"
layout="sizes, prev, pager, next, jumper" size="small" background
@current-change="load" @size-change="changePageSize"
/>
</div>
</el-card>
<NewsDetail :news-id="openedId" class="min-w-0 flex-1" />
<NewsDetail v-if="!mobile" :news-id="openedId" class="min-w-0 flex-1" />
</div>
<el-drawer :model-value="mobile && openedId !== null" title="新闻详情" size="100%" @close="openedId = null">
<NewsDetail v-if="mobile" :news-id="openedId" class="h-full" />
</el-drawer>
</div>
</template>
<style scoped>
.news-list {
flex: 0 0 40%;
}
@media (max-width: 760px) {
.news-list {
flex: 1;
}
}
</style>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import type { Exchange, ExchangeName } from "@@/apis/settings/type"
import type { FormInstance, FormRules } from "element-plus"
import { deleteExchangeApi, getExchangeApi, putExchangeApi, testExchangeApi } from "@@/apis/settings"
import { useSessionScope } from "@@/composables/useSessionScope"
import { formatTime } from "@@/utils/text"
const sessionScope = useSessionScope()
// ---------- 模拟盘 ----------
const exchange = ref<Exchange>()
const exchangeDialogVisible = ref(false)
const exchangeForm = ref({ api_key: "", api_secret: "" })
const exchangeSaving = ref(false)
const exchangeTesting = ref(false)
const exchangeFormRef = ref<FormInstance>()
const rules: FormRules = {
api_key: [{ required: true, whitespace: true, message: "请输入 API Key", trigger: "blur" }],
api_secret: [{ required: true, whitespace: true, message: "请输入 Secret Key", trigger: "blur" }]
}
const failed = ref(false)
const loading = ref(false)
async function loadExchange() {
loading.value = true
try {
exchange.value = await getExchangeApi()
failed.value = false
} catch (error) {
failed.value = true
throw error
} finally {
loading.value = false
}
}
async function changeExchange(value: ExchangeName) {
exchangeSaving.value = true
try {
exchange.value = await putExchangeApi({ exchange: value })
ElMessage.success("已切换模拟盘,另一家交易所的凭证和历史记录仍保留")
} finally {
exchangeSaving.value = false
}
}
function openExchangeEdit() {
exchangeForm.value = { api_key: "", api_secret: "" }
exchangeDialogVisible.value = true
}
async function saveExchange() {
if (exchangeSaving.value) return
exchangeSaving.value = true
try {
await exchangeFormRef.value!.validate()
exchange.value = await putExchangeApi({ exchange: exchange.value!.exchange, ...exchangeForm.value })
exchangeDialogVisible.value = false
ElMessage.success("已保存")
} finally {
exchangeSaving.value = false
}
}
async function removeExchange() {
await ElMessageBox.confirm("删除模拟盘 API key?", "删除确认", { type: "warning" })
sessionScope.assertCurrent()
exchange.value = await deleteExchangeApi()
ElMessage.success("已删除")
}
async function runExchangeTest() {
exchangeTesting.value = true
try {
const r = await testExchangeApi()
if (r.ok) {
const mode = r.dual_side_position ? "双向持仓(开始交易时会切换为单向)" : "单向持仓"
ElMessage.success({ message: `连接成功,用时 ${r.elapsed_ms}ms;可用 ${r.available_usdt} USDT,钱包余额 ${r.wallet_usdt} USDT;${mode}`, duration: 8000 })
} else {
ElMessage.error({ message: `连接失败(${r.elapsed_ms}ms):${r.error}`, duration: 8000 })
}
} finally {
exchangeTesting.value = false
}
}
onMounted(loadExchange)
</script>
<template>
<section class="min-w-0">
<el-alert v-if="failed" type="error" :closable="false" title="交易所配置读取失败">
<el-button link @click="loadExchange">
重试
</el-button>
</el-alert>
<el-card v-loading="loading" shadow="never">
<template #header>
<div class="flex items-center justify-between">
<div>
模拟盘交易所
<el-select :model-value="exchange?.exchange" class="ml-3 w-36" :disabled="!exchange || exchange.running || exchangeSaving || exchangeTesting" @update:model-value="changeExchange">
<el-option value="binance" label="币安模拟盘" />
<el-option value="bybit" label="Bybit 模拟盘" />
</el-select>
<div class="mt-2 text-xs text-slate-500 font-normal">
{{ exchange?.exchange === "bybit" ? "在 Bybit 主站切换到模拟交易后创建 API Key,并开启合约交易权限;实盘和 Testnet 的 Key 不可用。" : "在 demo.binance.com 的 API 管理创建 Key,并开启合约交易权限。" }}
两家凭证分别保存;切换不会撤销原交易所的挂单,旧挂单仍须在原平台处理。
</div>
</div>
<div>
<el-button :disabled="!exchange?.configured" :loading="exchangeTesting" @click="runExchangeTest">
测试连接
</el-button>
<el-button type="primary" :disabled="!exchange || exchange.running || exchangeSaving || exchangeTesting" @click="openExchangeEdit">
{{ exchange?.configured ? "修改 key" : "填写 key" }}
</el-button>
<el-button v-if="exchange?.configured" type="danger" plain :disabled="exchange.running" @click="removeExchange">
删除
</el-button>
</div>
</div>
</template>
<el-descriptions v-if="exchange" :column="3" border>
<el-descriptions-item label="API Key">
{{ exchange.configured ? `****${exchange.api_key_hint}` : "未配置" }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag v-if="exchange.running" type="success" size="small">
有币种在交易(全部停止后才能修改 key)
</el-tag>
<span v-else>未运行</span>
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ exchange.updated_at ? formatTime(exchange.updated_at, "MM-DD HH:mm") : "-" }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-dialog v-model="exchangeDialogVisible" :title="`${exchange?.exchange === 'bybit' ? 'Bybit' : '币安'}模拟盘 API Key`" width="min(560px, 94vw)" :close-on-click-modal="!exchangeSaving" :close-on-press-escape="!exchangeSaving" :show-close="!exchangeSaving">
<el-form ref="exchangeFormRef" :model="exchangeForm" :rules="rules" :disabled="exchangeSaving" label-width="100px">
<el-form-item prop="api_key" label="API Key" required>
<el-input v-model="exchangeForm.api_key" type="password" show-password autocomplete="new-password" />
</el-form-item>
<el-form-item prop="api_secret" label="Secret Key" required>
<el-input v-model="exchangeForm.api_secret" type="password" show-password autocomplete="new-password" />
<div class="text-xs text-[var(--el-text-color-secondary)]">
两项都要填;加密保存,之后不再显示
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="exchangeSaving" @click="exchangeDialogVisible = false">
取消
</el-button>
<el-button type="primary" :loading="exchangeSaving" :disabled="!exchangeForm.api_key.trim() || !exchangeForm.api_secret.trim()" @click="saveExchange">
保存
</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -0,0 +1,256 @@
<script setup lang="ts">
import type { Provider, ProviderForm } from "@@/apis/settings/type"
import type { FormInstance, FormRules } from "element-plus"
import { createProviderApi, deleteProviderApi, listProvidersApi, patchProviderApi, testProviderApi } from "@@/apis/settings"
import { useSessionScope } from "@@/composables/useSessionScope"
import { EFFORTS } from "@@/constants/ai"
import { formatTime } from "@@/utils/text"
import { Plus } from "@element-plus/icons-vue"
const sessionScope = useSessionScope()
const PROTOCOL_LABELS = { openai: "OpenAI 格式(/v1/chat/completions)", anthropic: "Claude 格式(/v1/messages)" } as const
const providers = ref<Provider[]>([])
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const saving = ref(false)
const testing = ref<number | null>(null)
function emptyForm(): ProviderForm {
return { name: "", protocol: "anthropic", base_url: "", api_key: "", model: "", max_tokens: 8192, context_window: 200000, reasoning_effort: null, is_default: false }
}
const form = ref<ProviderForm>(emptyForm())
const formRef = ref<FormInstance>()
const rules: FormRules = {
name: [{ required: true, whitespace: true, message: "请输入名称", trigger: "blur" }],
base_url: [{ validator: (_rule, value, callback) => {
const message = "请输入 HTTP 或 HTTPS 地址"
let url: URL
try {
url = new URL(value)
} catch {
callback(new Error(message))
return
}
callback(["http:", "https:"].includes(url.protocol) ? undefined : new Error(message))
}, trigger: "blur" }],
api_key: [{ validator: (_rule, value, callback) => callback(editingId.value === null && !value?.trim() ? new Error("请输入 API Key") : undefined), trigger: "blur" }],
model: [{ required: true, whitespace: true, message: "请输入模型名称", trigger: "blur" }],
max_tokens: [{ required: true, type: "integer", min: 256, max: 128000, message: "最大输出须为 256–128000 的整数", trigger: "blur" }],
context_window: [{ required: true, type: "integer", min: 4096, max: 2000000, message: "上下文上限须为 4096–2000000 的整数", trigger: "blur" }]
}
const loading = ref(false)
const failed = ref(false)
async function load() {
loading.value = true
try {
providers.value = await listProvidersApi()
failed.value = false
} catch (error) {
failed.value = true
throw error
} finally {
loading.value = false
}
}
function openCreate() {
editingId.value = null
form.value = emptyForm()
formRef.value?.clearValidate()
dialogVisible.value = true
}
function openEdit(p: Provider) {
editingId.value = p.id
// key 不回显,留空表示不修改
form.value = {
name: p.name,
protocol: p.protocol,
base_url: p.base_url,
api_key: "",
model: p.model,
max_tokens: p.max_tokens,
context_window: p.context_window,
reasoning_effort: p.reasoning_effort,
is_default: p.is_default
}
formRef.value?.clearValidate()
dialogVisible.value = true
}
async function save() {
if (saving.value) return
saving.value = true
try {
await formRef.value!.validate()
if (editingId.value === null) {
await createProviderApi(form.value)
} else {
const { api_key, ...rest } = form.value
await patchProviderApi(editingId.value, api_key.trim() ? form.value : rest)
}
ElMessage.success("已保存")
dialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
async function setDefault(p: Provider) {
await patchProviderApi(p.id, { is_default: true })
await load()
}
async function remove(p: Provider) {
await ElMessageBox.confirm(`删除模型配置「${p.name}」?`, "删除确认", { type: "warning" })
sessionScope.assertCurrent()
await deleteProviderApi(p.id)
ElMessage.success("已删除")
await load()
}
async function test(p: Provider) {
testing.value = p.id
try {
const result = await testProviderApi(p.id)
if (result.ok)
ElMessage.success(`连接成功,用时 ${result.elapsed_ms}ms,回复:${result.reply}`)
else
ElMessage.error({ message: `连接失败(${result.elapsed_ms}ms):${result.error}`, duration: 8000 })
} finally {
testing.value = null
}
}
onMounted(load)
</script>
<template>
<section class="min-w-0">
<el-card shadow="never" body-class="p-0">
<template #header>
<div class="flex items-center justify-between">
<div>
模型配置
<span class="ml-3 text-xs text-slate-500 font-normal">通过 Sub2API 等中转接入;API key 加密存储,页面只显示末 4 位</span>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">
新增配置
</el-button>
</div>
</template>
<el-alert v-if="failed" type="error" :closable="false" title="模型配置读取失败">
<el-button link @click="load">
重试
</el-button>
</el-alert>
<el-table v-loading="loading" :data="providers" :empty-text="failed ? '读取失败' : '暂无模型配置'">
<el-table-column label="名称" min-width="120">
<template #default="{ row }">
{{ row.name }}
<el-tag v-if="row.is_default" size="small" type="success" class="ml-1">
默认
</el-tag>
</template>
</el-table-column>
<el-table-column label="协议" min-width="200">
<template #default="{ row }">
{{ PROTOCOL_LABELS[row.protocol as keyof typeof PROTOCOL_LABELS] }}
</template>
</el-table-column>
<el-table-column prop="base_url" label="Base URL" min-width="200" />
<el-table-column prop="model" label="模型" min-width="150" />
<el-table-column label="思考强度" width="90">
<template #default="{ row }">
{{ (row as Provider).reasoning_effort ?? "-" }}
</template>
</el-table-column>
<el-table-column label="API Key" width="100">
<template #default="{ row }">
****{{ row.api_key_hint }}
</template>
</el-table-column>
<el-table-column label="更新时间" width="130">
<template #default="{ row }">
{{ formatTime(row.updated_at, "MM-DD HH:mm") }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :disabled="testing !== null || saving" :loading="testing === row.id" @click="test(row as Provider)">
测试连接
</el-button>
<el-button link @click="openEdit(row as Provider)">
编辑
</el-button>
<el-button v-if="!row.is_default" link @click="setDefault(row as Provider)">
设为默认
</el-button>
<el-button link type="danger" @click="remove(row as Provider)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="editingId === null ? '新增模型配置' : '编辑模型配置'" width="min(560px, 94vw)" :close-on-click-modal="!saving" :close-on-press-escape="!saving" :show-close="!saving">
<el-form ref="formRef" :model="form" :rules="rules" :disabled="saving" label-width="100px">
<el-form-item prop="name" label="名称" required>
<el-input v-model="form.name" placeholder="如 Sub2API-Claude" />
</el-form-item>
<el-form-item label="协议" required>
<el-radio-group v-model="form.protocol">
<el-radio value="anthropic">
Claude 格式
</el-radio>
<el-radio value="openai">
OpenAI 格式
</el-radio>
</el-radio-group>
<div class="text-xs text-[var(--el-text-color-secondary)]">
选 key 所在分组的原生协议;Sub2API 的协议转换会丢失部分信息
</div>
</el-form-item>
<el-form-item prop="base_url" label="Base URL" required>
<el-input v-model="form.base_url" placeholder="https://your-sub2api.example.com(带不带 /v1 都可以)" />
</el-form-item>
<el-form-item prop="api_key" label="API Key" :required="editingId === null">
<el-input v-model="form.api_key" type="password" show-password autocomplete="new-password" :placeholder="editingId === null ? '' : '留空表示不修改'" />
</el-form-item>
<el-form-item prop="model" label="模型" required>
<el-input v-model="form.model" placeholder="如 claude-sonnet-4-5" />
</el-form-item>
<el-form-item prop="max_tokens" label="最大输出">
<el-input-number v-model="form.max_tokens" :min="256" :max="128000" :step="1024" controls-position="right" />
<span class="ml-2 text-xs">token</span>
</el-form-item>
<el-form-item prop="context_window" label="上下文上限">
<el-input-number v-model="form.context_window" :min="4096" :max="2000000" :step="10000" controls-position="right" />
<span class="ml-2 text-xs">token,超过时拒绝发送</span>
</el-form-item>
<el-form-item label="思考强度">
<el-select v-model="form.reasoning_effort" clearable placeholder="不设置" class="w-40" :value-on-clear="null">
<el-option v-for="e in EFFORTS" :key="e" :value="e" :label="e" />
</el-select>
<span class="ml-2 text-xs">Chat 默认值,可在 Chat 页按轮调整;留空则不传,由模型决定。越高越慢、越贵</span>
</el-form-item>
<el-form-item label="设为默认">
<el-switch v-model="form.is_default" />
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="saving" @click="dialogVisible = false">
取消
</el-button>
<el-button type="primary" :loading="saving" @click="save">
保存
</el-button>
</template>
</el-dialog>
</section>
</template>
+5 -321
View File
@@ -1,327 +1,11 @@
<script lang="ts" setup>
import type { Exchange, ExchangeName, Provider, ProviderForm } from "@/apis/settings"
import { Plus } from "@element-plus/icons-vue"
import { createProvider, deleteExchange, deleteProvider, EFFORTS, getExchange, listProviders, patchProvider, putExchange, testExchange, testProvider } from "@/apis/settings"
import { formatTime } from "@/utils/text"
const PROTOCOL_LABELS = { openai: "OpenAI 格式(/v1/chat/completions)", anthropic: "Claude 格式(/v1/messages)" } as const
const providers = ref<Provider[]>([])
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const saving = ref(false)
const testing = ref<number | null>(null)
function emptyForm(): ProviderForm {
return { name: "", protocol: "anthropic", base_url: "", api_key: "", model: "", max_tokens: 8192, context_window: 200000, reasoning_effort: null, is_default: false }
}
const form = ref<ProviderForm>(emptyForm())
async function load() {
providers.value = await listProviders()
}
function openCreate() {
editingId.value = null
form.value = emptyForm()
dialogVisible.value = true
}
function openEdit(p: Provider) {
editingId.value = p.id
// key 不回显,留空表示不修改
form.value = { ...p, api_key: "" }
dialogVisible.value = true
}
async function save() {
saving.value = true
try {
if (editingId.value === null) {
await createProvider(form.value)
} else {
const { api_key, ...rest } = form.value
await patchProvider(editingId.value, api_key.trim() ? form.value : rest)
}
ElMessage.success("已保存")
dialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
async function setDefault(p: Provider) {
await patchProvider(p.id, { is_default: true })
await load()
}
async function remove(p: Provider) {
await ElMessageBox.confirm(`删除模型配置「${p.name}」?`, "删除确认", { type: "warning" })
await deleteProvider(p.id)
ElMessage.success("已删除")
await load()
}
async function test(p: Provider) {
testing.value = p.id
try {
const result = await testProvider(p.id)
if (result.ok)
ElMessage.success(`连接成功,用时 ${result.elapsed_ms}ms,回复:${result.reply}`)
else
ElMessage.error({ message: `连接失败(${result.elapsed_ms}ms):${result.error}`, duration: 8000 })
} finally {
testing.value = null
}
}
load()
// ---------- 模拟盘 ----------
const exchange = ref<Exchange>()
const exchangeDialogVisible = ref(false)
const exchangeForm = ref({ api_key: "", api_secret: "" })
const exchangeSaving = ref(false)
const exchangeTesting = ref(false)
async function loadExchange() {
exchange.value = await getExchange()
}
async function changeExchange(value: ExchangeName) {
exchangeSaving.value = true
try {
exchange.value = await putExchange({ exchange: value })
ElMessage.success("已切换模拟盘,另一家交易所的凭证和历史记录仍保留")
} finally {
exchangeSaving.value = false
}
}
function openExchangeEdit() {
exchangeForm.value = { api_key: "", api_secret: "" }
exchangeDialogVisible.value = true
}
async function saveExchange() {
exchangeSaving.value = true
try {
exchange.value = await putExchange({ exchange: exchange.value?.exchange, ...exchangeForm.value })
exchangeDialogVisible.value = false
ElMessage.success("已保存")
} finally {
exchangeSaving.value = false
}
}
async function removeExchange() {
await ElMessageBox.confirm("删除模拟盘 API key?", "删除确认", { type: "warning" })
exchange.value = await deleteExchange()
ElMessage.success("已删除")
}
async function runExchangeTest() {
exchangeTesting.value = true
try {
const r = await testExchange()
if (r.ok) {
const mode = r.dual_side_position ? "双向持仓(开始交易时会切换为单向)" : "单向持仓"
ElMessage.success({ message: `连接成功,用时 ${r.elapsed_ms}ms;可用 ${r.available_usdt} USDT,钱包余额 ${r.wallet_usdt} USDT;${mode}`, duration: 8000 })
} else {
ElMessage.error({ message: `连接失败(${r.elapsed_ms}ms):${r.error}`, duration: 8000 })
}
} finally {
exchangeTesting.value = false
}
}
loadExchange()
<script setup lang="ts">
import ExchangeSettings from "./components/ExchangeSettings.vue"
import ProviderSettings from "./components/ProviderSettings.vue"
</script>
<template>
<div class="flex flex-col gap-4">
<el-card shadow="never" body-class="p-0">
<template #header>
<div class="flex items-center justify-between">
<div>
模型配置
<span class="ml-3 text-xs text-slate-500 font-normal">通过 Sub2API 等中转接入;API key 加密存储,页面只显示末 4 位</span>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">
新增配置
</el-button>
</div>
</template>
<el-table :data="providers">
<el-table-column label="名称" min-width="120">
<template #default="{ row }">
{{ row.name }}
<el-tag v-if="row.is_default" size="small" type="success" class="ml-1">
默认
</el-tag>
</template>
</el-table-column>
<el-table-column label="协议" min-width="200">
<template #default="{ row }">
{{ PROTOCOL_LABELS[row.protocol as keyof typeof PROTOCOL_LABELS] }}
</template>
</el-table-column>
<el-table-column prop="base_url" label="Base URL" min-width="200" />
<el-table-column prop="model" label="模型" min-width="150" />
<el-table-column label="思考强度" width="90">
<template #default="{ row }">
{{ (row as Provider).reasoning_effort ?? "-" }}
</template>
</el-table-column>
<el-table-column label="API Key" width="100">
<template #default="{ row }">
****{{ row.api_key_hint }}
</template>
</el-table-column>
<el-table-column label="更新时间" width="130">
<template #default="{ row }">
{{ formatTime(row.updated_at, "MM-DD HH:mm") }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :loading="testing === row.id" @click="test(row as Provider)">
测试连接
</el-button>
<el-button link @click="openEdit(row as Provider)">
编辑
</el-button>
<el-button v-if="!row.is_default" link @click="setDefault(row as Provider)">
设为默认
</el-button>
<el-button link type="danger" @click="remove(row as Provider)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="editingId === null ? '新增模型配置' : '编辑模型配置'" width="560px">
<el-form :model="form" label-width="100px">
<el-form-item label="名称" required>
<el-input v-model="form.name" placeholder="如 Sub2API-Claude" />
</el-form-item>
<el-form-item label="协议" required>
<el-radio-group v-model="form.protocol">
<el-radio value="anthropic">
Claude 格式
</el-radio>
<el-radio value="openai">
OpenAI 格式
</el-radio>
</el-radio-group>
<div class="text-xs text-[var(--el-text-color-secondary)]">
选 key 所在分组的原生协议;Sub2API 的协议转换会丢失部分信息
</div>
</el-form-item>
<el-form-item label="Base URL" required>
<el-input v-model="form.base_url" placeholder="https://your-sub2api.example.com(带不带 /v1 都可以)" />
</el-form-item>
<el-form-item label="API Key" :required="editingId === null">
<el-input v-model="form.api_key" type="password" show-password autocomplete="new-password" :placeholder="editingId === null ? '' : '留空表示不修改'" />
</el-form-item>
<el-form-item label="模型" required>
<el-input v-model="form.model" placeholder="如 claude-sonnet-4-5" />
</el-form-item>
<el-form-item label="最大输出">
<el-input-number v-model="form.max_tokens" :min="256" :max="128000" :step="1024" controls-position="right" />
<span class="ml-2 text-xs">token</span>
</el-form-item>
<el-form-item label="上下文上限">
<el-input-number v-model="form.context_window" :min="4096" :max="2000000" :step="10000" controls-position="right" />
<span class="ml-2 text-xs">token,超过时拒绝发送</span>
</el-form-item>
<el-form-item label="思考强度">
<el-select v-model="form.reasoning_effort" clearable placeholder="不设置" class="w-40" :value-on-clear="null">
<el-option v-for="e in EFFORTS" :key="e" :value="e" :label="e" />
</el-select>
<span class="ml-2 text-xs">Chat 默认值,可在 Chat 页按轮调整;留空则不传,由模型决定。越高越慢、越贵</span>
</el-form-item>
<el-form-item label="设为默认">
<el-switch v-model="form.is_default" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">
取消
</el-button>
<el-button type="primary" :loading="saving" @click="save">
保存
</el-button>
</template>
</el-dialog>
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<div>
模拟盘交易所
<el-select :model-value="exchange?.exchange" class="ml-3 w-36" :disabled="exchange?.running || exchangeSaving" @update:model-value="changeExchange">
<el-option value="binance" label="币安模拟盘" />
<el-option value="bybit" label="Bybit 模拟盘" />
</el-select>
<div class="mt-2 text-xs text-slate-500 font-normal">
{{ exchange?.exchange === "bybit" ? "在 Bybit 主站切换到模拟交易后创建 API Key,并开启合约交易权限;实盘和 Testnet 的 Key 不可用。" : "在 demo.binance.com 的 API 管理创建 Key,并开启合约交易权限。" }}
两家凭证分别保存;切换不会撤销原交易所的挂单,旧挂单仍须在原平台处理。
</div>
</div>
<div>
<el-button :disabled="!exchange?.configured" :loading="exchangeTesting" @click="runExchangeTest">
测试连接
</el-button>
<el-button type="primary" :disabled="exchange?.running" @click="openExchangeEdit">
{{ exchange?.configured ? "修改 key" : "填写 key" }}
</el-button>
<el-button v-if="exchange?.configured" type="danger" plain :disabled="exchange.running" @click="removeExchange">
删除
</el-button>
</div>
</div>
</template>
<el-descriptions v-if="exchange" :column="3" border>
<el-descriptions-item label="API Key">
{{ exchange.configured ? `****${exchange.api_key_hint}` : "未配置" }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag v-if="exchange.running" type="success" size="small">
有币种在交易(全部停止后才能修改 key)
</el-tag>
<span v-else>未运行</span>
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ exchange.updated_at ? formatTime(exchange.updated_at, "MM-DD HH:mm") : "-" }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-dialog v-model="exchangeDialogVisible" :title="`${exchange?.exchange === 'bybit' ? 'Bybit' : '币安'}模拟盘 API Key`" width="560px">
<el-form :model="exchangeForm" label-width="100px">
<el-form-item label="API Key" required>
<el-input v-model="exchangeForm.api_key" type="password" show-password autocomplete="new-password" />
</el-form-item>
<el-form-item label="Secret Key" required>
<el-input v-model="exchangeForm.api_secret" type="password" show-password autocomplete="new-password" />
<div class="text-xs text-[var(--el-text-color-secondary)]">
两项都要填;加密保存,之后不再显示
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="exchangeDialogVisible = false">
取消
</el-button>
<el-button type="primary" :loading="exchangeSaving" :disabled="!exchangeForm.api_key.trim() || !exchangeForm.api_secret.trim()" @click="saveExchange">
保存
</el-button>
</template>
</el-dialog>
<ProviderSettings />
<ExchangeSettings />
</div>
</template>
+23 -16
View File
@@ -1,9 +1,12 @@
<script lang="ts" setup>
import type { SkillNode, SkillVersion } from "@/apis/skills"
import { listSkillTree, listSkillVersions, patchSkillNode, publishSkillVersion, saveSkillDraft } from "@/apis/skills"
import SkillTree from "@/components/SkillTree.vue"
import SkillVersionDrawer from "@/components/SkillVersionDrawer.vue"
import { renderMarkdown } from "@/utils/markdown"
import type { SkillNode, SkillVersion } from "@@/apis/skills/type"
import { listSkillTreeApi, listSkillVersionsApi, patchSkillNodeApi, publishSkillVersionApi, saveSkillDraftApi } from "@@/apis/skills"
import SkillTree from "@@/components/SkillTree/index.vue"
import SkillVersionDrawer from "@@/components/SkillVersionDrawer/index.vue"
import { useSessionScope } from "@@/composables/useSessionScope"
import { renderMarkdown } from "@@/utils/markdown"
const sessionScope = useSessionScope()
const nodes = ref<SkillNode[]>([])
const selectedId = ref<number | null>(null)
@@ -20,7 +23,7 @@ const historyVisible = ref(false)
const preview = computed(() => renderMarkdown(content.value))
async function loadTree() {
nodes.value = await listSkillTree()
nodes.value = await listSkillTreeApi()
if (selectedId.value !== null && !selected.value)
selectedId.value = null
}
@@ -36,7 +39,7 @@ async function loadContent() {
}
loadingContent.value = true
try {
const versions = await listSkillVersions(skill.id)
const versions = await listSkillVersionsApi(skill.id)
if (version !== contentVersion)
return
latest.value = versions[0] ?? null
@@ -52,6 +55,7 @@ async function select(node: SkillNode) {
return
if (dirty.value)
await ElMessageBox.confirm("当前 skill 有未保存的修改,切换后会丢失。继续?", "未保存", { type: "warning" })
sessionScope.assertCurrent()
selectedId.value = node.id
await loadContent()
}
@@ -65,7 +69,7 @@ async function saveDraft(): Promise<SkillVersion | null> {
const version = contentVersion
saving.value = true
try {
const saved = await saveSkillDraft(id, content.value)
const saved = await saveSkillDraftApi(id, content.value)
if (version !== contentVersion)
return null
latest.value = saved
@@ -87,9 +91,10 @@ async function publishLatest() {
if (!version || selectedId.value !== id || contentRequest !== contentVersion)
return
await ElMessageBox.confirm(`发布 v${version.version_no}?发布后 Chat 下一轮起按此版本判断。`, "发布确认", { type: "warning" })
sessionScope.assertCurrent()
if (selectedId.value !== id || contentRequest !== contentVersion)
return
await publishSkillVersion(id, version.id)
await publishSkillVersionApi(id, version.id)
ElMessage.success(`已发布 v${version.version_no}`)
await loadTree()
}
@@ -97,7 +102,7 @@ async function publishLatest() {
async function updateMeta(body: { priority?: number, enabled?: boolean }) {
if (!selected.value)
return
await patchSkillNode(selected.value.id, body)
await patchSkillNodeApi(selected.value.id, body)
await loadTree()
}
@@ -114,16 +119,18 @@ function beforeUnload(e: BeforeUnloadEvent) {
onMounted(() => window.addEventListener("beforeunload", beforeUnload))
onBeforeUnmount(() => window.removeEventListener("beforeunload", beforeUnload))
onBeforeRouteLeave(async () => {
if (!sessionScope.isCurrent()) return true
if (dirty.value)
await ElMessageBox.confirm("当前 skill 有未保存的修改,离开后会丢失。继续?", "未保存", { type: "warning" })
sessionScope.assertCurrent()
})
loadTree()
onMounted(loadTree)
</script>
<template>
<div class="h-full flex gap-4">
<el-card class="w-80 flex shrink-0 flex-col" body-class="min-h-0 flex-1" shadow="never">
<div class="skills-workspace h-full flex flex-col gap-3 lg:flex-row">
<el-card class="skills-tree max-h-56 w-full flex shrink-0 flex-col lg:max-h-none lg:w-64" body-class="min-h-0 flex-1" shadow="never">
<template #header>
Skills
</template>
@@ -173,11 +180,11 @@ loadTree()
优先级越高越先读取,冲突时以优先级高的为准。只有「已发布」的版本会进入 Chat 的判断;保存只生成草稿。
</div>
<div class="min-h-0 flex flex-1 gap-3">
<div class="skills-editor min-h-0 flex flex-1 flex-col gap-3 lg:flex-row">
<textarea
v-model="content" placeholder="用 Markdown 写下投资经验、判断流程或输出规范"
:disabled="loadingContent || saving"
class="box-border flex-1 resize-none border border-slate-300 rounded-lg border-solid p-3 text-sm text-slate-800 leading-6 font-mono outline-none focus:border-[var(--el-color-primary)]"
class="box-border min-w-0 flex-1 resize-none border border-slate-300 rounded-lg border-solid p-3 text-sm text-slate-800 leading-6 font-mono outline-none focus:border-[var(--el-color-primary)]"
/>
<el-scrollbar class="flex-1 border border-slate-200 rounded-lg border-solid bg-slate-50/50">
<!-- markdown-it 已关闭 html,内容中的标签按文本显示 -->
@@ -185,7 +192,7 @@ loadTree()
</el-scrollbar>
</div>
<div class="flex gap-2">
<div class="flex flex-wrap gap-2">
<el-button :disabled="loadingContent || !dirty" :loading="saving" @click="saveDraft">
保存草稿
</el-button>
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { SymbolOption } from "@/apis/trading"
import { addSymbol, listSymbolOptions } from "@/apis/trading"
import type { SymbolOption } from "@@/apis/trading/type"
import { addSymbolApi, listSymbolOptionsApi } from "@@/apis/trading"
const emit = defineEmits<{ added: [symbol: string] }>()
const visible = defineModel<boolean>({ required: true })
@@ -18,7 +18,7 @@ watch(visible, async (open) => {
symbol.value = ""
loading.value = true
try {
symbolOptions.value = await listSymbolOptions()
symbolOptions.value = await listSymbolOptionsApi()
} finally {
loading.value = false
}
@@ -27,7 +27,7 @@ watch(visible, async (open) => {
async function submit() {
submitting.value = true
try {
await addSymbol(symbol.value)
await addSymbolApi(symbol.value)
ElMessage.success(`已加入 ${symbol.value}`)
emit("added", symbol.value)
visible.value = false
@@ -1,8 +1,8 @@
<script lang="ts" setup>
import type { AnalysisDetail } from "@/apis/trading"
import { getAnalysis } from "@/apis/trading"
import { renderMarkdown } from "@/utils/markdown"
import { formatTime } from "@/utils/text"
import type { AnalysisDetail } from "@@/apis/trading/type"
import { getAnalysisApi } from "@@/apis/trading"
import { renderMarkdown } from "@@/utils/markdown"
import { formatTime } from "@@/utils/text"
const props = defineProps<{ analysisId: number | null }>()
const visible = defineModel<boolean>({ required: true })
@@ -12,7 +12,7 @@ const detail = ref<AnalysisDetail>()
watch(() => [visible.value, props.analysisId] as const, async ([open, id]) => {
detail.value = undefined
if (open && id)
detail.value = await getAnalysis(id)
detail.value = await getAnalysisApi(id)
})
const toolResults = computed(() => {
@@ -1,8 +1,8 @@
<script lang="ts" setup>
import type { NewsSignalItem, NewsSignals } from "@/apis/trading"
import { getNewsSignals } from "@/apis/trading"
import { useSourceNames } from "@/composables/useSourceNames"
import { formatTime } from "@/utils/text"
import type { NewsSignalItem, NewsSignals } from "@@/apis/trading/type"
import { getNewsSignalsApi } from "@@/apis/trading"
import { useSourceNames } from "@@/composables/useSourceNames"
import { formatTime } from "@@/utils/text"
const visible = defineModel<boolean>({ required: true })
@@ -19,7 +19,7 @@ watch(visible, async (open) => {
return
loading.value = true
try {
data.value = await getNewsSignals()
data.value = await getNewsSignalsApi()
} finally {
loading.value = false
}
@@ -1,8 +1,8 @@
<script lang="ts" setup>
import type { Analysis, OrderRow, PlanCondition, TradingSymbol } from "@/apis/trading"
import type { Analysis, OrderRow, PlanCondition, TradingSymbol } from "@@/apis/trading/type"
import { formatTime } from "@@/utils/text"
import { Document, Edit, Lightning, SwitchButton, VideoPlay } from "@element-plus/icons-vue"
import { CLOSE_REASON_LABELS, PLAN_STATUS_LABELS, TAKE_PROFIT_STEPS } from "@/apis/trading"
import { formatTime } from "@/utils/text"
import { CLOSE_REASON_LABELS, PLAN_STATUS_LABELS, TAKE_PROFIT_STEPS } from "../plan-display"
/** analysis:执行中的计划,没有时为最近一次分析;latest:最近一次分析(与 analysis 不同时另行提示) */
const props = defineProps<{ symbol: TradingSymbol, analysis: Analysis | null, latest: Analysis | null, orders: OrderRow[], requesting: boolean, toggling: boolean }>()
@@ -1,8 +1,9 @@
<script lang="ts" setup>
import type { PositionHistory, PositionHistoryPage } from "@/apis/trading"
import type { PositionHistory, PositionHistoryPage } from "@@/apis/trading/type"
import { listPositionHistoryApi, listPositionHistorySymbolsApi } from "@@/apis/trading"
import { formatTime } from "@@/utils/text"
import dayjs from "dayjs"
import { CLOSE_REASON_LABELS, listPositionHistory, listPositionHistorySymbols } from "@/apis/trading"
import { formatTime } from "@/utils/text"
import { CLOSE_REASON_LABELS } from "../plan-display"
const emit = defineEmits<{ detail: [id: number] }>()
const visible = defineModel<boolean>({ required: true })
@@ -31,7 +32,7 @@ async function load() {
const seq = ++requestSeq
loading.value = true
try {
const data = await listPositionHistory({
const data = await listPositionHistoryApi({
symbol: filters.symbol,
// 日期范围按自然日:结束日当天整天都算
start: filters.range ? dayjs(filters.range[0]).startOf("day").toISOString() : undefined,
@@ -58,7 +59,7 @@ watch(visible, async (open) => {
if (!open)
return
search()
symbols.value = await listPositionHistorySymbols()
symbols.value = await listPositionHistorySymbolsApi()
})
const winRate = computed(() => {
@@ -0,0 +1,86 @@
<script setup lang="ts">
import type { Effort, Provider } from "@@/apis/settings/type"
import type { PatchConfigBodyRequestData, TradingStatus } from "@@/apis/trading/type"
import type { FormInstance, FormRules } from "element-plus"
import { patchConfigApi } from "@@/apis/trading"
import { EFFORTS } from "@@/constants/ai"
const { status, providers } = defineProps<{ status: TradingStatus, providers: Provider[] }>()
const emit = defineEmits<{ saved: [status: TradingStatus] }>()
const formRef = ref<FormInstance>()
const saving = ref(false)
const form = reactive({ provider_id: null as number | null, reasoning_effort: null as Effort | null, daily_analysis_limit: 1 })
const baseline = ref({ ...form })
const dirty = computed(() => form.provider_id !== baseline.value.provider_id || form.reasoning_effort !== baseline.value.reasoning_effort || form.daily_analysis_limit !== baseline.value.daily_analysis_limit)
const rules: FormRules = {
daily_analysis_limit: [{ required: true, type: "integer", min: 1, max: 2147483647, message: "请输入 1–2147483647 之间的整数", trigger: "blur" }]
}
function reset() {
Object.assign(form, { provider_id: status.provider_id, reasoning_effort: status.reasoning_effort, daily_analysis_limit: status.today.limit })
baseline.value = { ...form }
formRef.value?.clearValidate()
}
// 轮询更新账户状态时,保留正在编辑或保存的草稿。
watch(() => status, () => {
if (!dirty.value && !saving.value) reset()
}, { immediate: true })
async function save() {
if (saving.value || !dirty.value) return
saving.value = true
try {
await formRef.value!.validate()
const body: PatchConfigBodyRequestData = {}
if (form.provider_id !== baseline.value.provider_id) body.provider_id = form.provider_id
if (form.reasoning_effort !== baseline.value.reasoning_effort) body.reasoning_effort = form.reasoning_effort
if (form.daily_analysis_limit !== baseline.value.daily_analysis_limit) body.daily_analysis_limit = form.daily_analysis_limit
const result = await patchConfigApi(body)
Object.assign(form, { provider_id: result.provider_id, reasoning_effort: result.reasoning_effort, daily_analysis_limit: result.today.limit })
baseline.value = { ...form }
emit("saved", result)
ElMessage.success("交易配置已保存,即时生效")
} finally {
saving.value = false
}
}
</script>
<template>
<el-card shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" :disabled="saving" inline class="trading-config" @submit.prevent="save">
<el-form-item label="模型">
<el-select v-model="form.provider_id" class="w-56" clearable :value-on-clear="null" placeholder="跟随默认配置">
<el-option v-for="provider in providers" :key="provider.id" :value="provider.id" :label="`${provider.name}(${provider.model})`" />
</el-select>
</el-form-item>
<el-form-item label="思考强度">
<el-select v-model="form.reasoning_effort" class="w-36" clearable :value-on-clear="null" placeholder="跟随配置">
<el-option v-for="effort in EFFORTS" :key="effort" :value="effort" :label="effort" />
</el-select>
</el-form-item>
<el-form-item label="每日分析上限" prop="daily_analysis_limit">
<el-input-number v-model="form.daily_analysis_limit" :min="1" :max="2147483647" :precision="0" controls-position="right" />
</el-form-item>
<el-form-item>
<el-button type="primary" native-type="submit" :disabled="!dirty" :loading="saving">
保存配置
</el-button>
<el-button :disabled="!dirty" @click="reset">
取消修改
</el-button>
<span v-if="dirty" class="ml-2 text-xs text-amber-600">未保存</span>
</el-form-item>
</el-form>
<p class="m-0 text-xs text-slate-500">
所有币种和交易所共用每日额度。手动分析计数但不受上限限制;降低上限不会取消正在进行的分析或持仓管理。
</p>
</el-card>
</template>
<style scoped>
.trading-config :deep(.el-form-item) {
margin-bottom: 12px;
margin-right: 16px;
}
</style>
@@ -0,0 +1,107 @@
import type { Analysis, OrderRow, TradingEvent, TradingStatus, TradingSymbol } from "@@/apis/trading/type"
import { getStatusApi, getSymbolPlanApi, listAnalysesApi, listEventsApi, listSymbolsApi } from "@@/apis/trading"
import { usePersisted } from "@@/utils/storage"
import { logRequestError } from "@/http/axios"
export function useTradingData() {
const EVENTS_PAGE = 50
const status = ref<TradingStatus>()
const symbols = ref<TradingSymbol[]>([])
const selected = usePersisted("trading.symbol", "")
const events = ref<TradingEvent[]>([])
const eventsExhausted = ref(false)
const latestAnalysis = ref<Analysis | null>(null)
const livePlan = ref<Analysis | null>(null)
const planOrders = ref<OrderRow[]>([])
const current = computed(() => symbols.value.find(s => s.symbol === selected.value))
const loading = ref(false)
const failed = ref(false)
let refreshVersion = 0
let analysisVersion = 0
async function refresh() {
const previous = `${status.value?.exchange}:${selected.value}`
const version = ++refreshVersion
loading.value = status.value === undefined
try {
const [nextStatus, nextSymbols] = await Promise.all([getStatusApi(), listSymbolsApi()])
if (version !== refreshVersion) return
status.value = nextStatus
symbols.value = nextSymbols
failed.value = false
} catch (error) {
if (version === refreshVersion) failed.value = true
throw error
} finally {
if (version === refreshVersion) loading.value = false
}
if (!current.value && symbols.value.length)
selected.value = symbols.value[0].symbol
if (previous === `${status.value?.exchange}:${selected.value}`) await refreshAnalysis()
}
async function refreshAnalysis() {
const version = ++analysisVersion
const exchange = status.value?.exchange
const symbol = selected.value
if (!symbol)
return
const [latest, { plan, orders }] = await Promise.all([listAnalysesApi({ symbol, limit: 1 }), getSymbolPlanApi(symbol)])
if (version !== analysisVersion || symbol !== selected.value || exchange !== status.value?.exchange)
return
latestAnalysis.value = latest[0] ?? null
livePlan.value = plan
planOrders.value = orders
}
async function refreshEvents() {
const exchange = status.value?.exchange
const symbol = selected.value
const latest = await listEventsApi({ symbol: symbol || undefined, limit: EVENTS_PAGE })
// 请求期间切换了币种:丢弃旧币种的结果
if (symbol !== selected.value || exchange !== status.value?.exchange)
return
const newestId = events.value[0]?.id ?? 0
const fresh = latest.filter(e => e.id > newestId)
if (!events.value.length)
eventsExhausted.value = latest.length < EVENTS_PAGE
events.value = [...fresh, ...events.value]
}
async function loadMoreEvents() {
const exchange = status.value?.exchange
const symbol = selected.value
const older = await listEventsApi({ symbol: symbol || undefined, before: events.value.at(-1)?.id, limit: EVENTS_PAGE })
if (symbol !== selected.value || exchange !== status.value?.exchange)
return
eventsExhausted.value = older.length < EVENTS_PAGE
events.value = [...events.value, ...older]
}
watch([selected, () => status.value?.exchange], () => {
events.value = []
latestAnalysis.value = null
livePlan.value = null
planOrders.value = []
void Promise.all([refreshEvents(), refreshAnalysis()]).catch(logRequestError)
})
onMounted(refresh)
let stopped = false
let timer: ReturnType<typeof setTimeout> | undefined
async function poll() {
try {
await Promise.all([refresh(), refreshEvents()])
} catch (error) {
logRequestError(error)
} finally {
if (!stopped) timer = setTimeout(poll, 3000)
}
}
timer = setTimeout(poll, 3000)
onBeforeUnmount(() => {
stopped = true
++refreshVersion
++analysisVersion
clearTimeout(timer)
})
return { status, symbols, selected, events, eventsExhausted, latestAnalysis, livePlan, planOrders, current, loading, failed, refresh, refreshAnalysis, refreshEvents, loadMoreEvents }
}
+84 -134
View File
@@ -1,32 +1,29 @@
<script lang="ts" setup>
import type { Effort, Provider } from "@/apis/settings"
import type { Analysis, OrderRow, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading"
import type { Provider } from "@@/apis/settings/type"
import type { SnapshotPosition, TradingStatus, TradingSymbol } from "@@/apis/trading/type"
import { listProvidersApi } from "@@/apis/settings"
import { deleteSymbolApi, getStatusApi, manualActionApi, patchSymbolApi, requestAnalysisApi, startTradingApi, stopTradingApi } from "@@/apis/trading"
import { useSessionScope } from "@@/composables/useSessionScope"
import { formatTime } from "@@/utils/text"
import { Clock, Plus, Reading } from "@element-plus/icons-vue"
import { EFFORTS, listProviders } from "@/apis/settings"
import { deleteSymbol, getStatus, getSymbolPlan, isOwnOrder, listAnalyses, listEvents, listSymbols, manualAction, patchConfig, patchSymbol, requestAnalysis, startTrading, stopTrading } from "@/apis/trading"
import { usePersisted } from "@/utils/storage"
import { formatTime } from "@/utils/text"
import { CanceledError } from "axios"
import AddSymbolDialog from "./components/AddSymbolDialog.vue"
import AnalysisDrawer from "./components/AnalysisDrawer.vue"
import NewsSignalDrawer from "./components/NewsSignalDrawer.vue"
import PlanCard from "./components/PlanCard.vue"
import PositionHistoryDrawer from "./components/PositionHistoryDrawer.vue"
import TradingConfig from "./components/TradingConfig.vue"
import { useTradingData } from "./composables/useTradingData"
import { isOwnOrder } from "./plan-display"
const sessionScope = useSessionScope()
const { status, symbols, selected, events, eventsExhausted, latestAnalysis, livePlan, planOrders, current, loading, failed, refresh, refreshEvents, loadMoreEvents } = useTradingData()
const STATUS_REFRESH_MS = 3000
const EVENTS_PAGE = 50
const LEVEL_TYPES = { info: "primary", warn: "warning", error: "danger" } as const
const status = ref<TradingStatus>()
const symbols = ref<TradingSymbol[]>([])
const providers = ref<Provider[]>([])
const selected = usePersisted("trading.symbol", "")
const events = ref<TradingEvent[]>([])
const eventsExhausted = ref(false)
const addVisible = ref(false)
const toggling = ref(false)
const latestAnalysis = ref<Analysis | null>(null)
const livePlan = ref<Analysis | null>(null)
const planOrders = ref<OrderRow[]>([])
const requesting = ref(false)
const drawerVisible = ref(false)
const drawerAnalysisId = ref<number | null>(null)
@@ -37,7 +34,6 @@ const exchangeName = computed(() => status.value?.exchange === "bybit" ? "Bybit"
const snapshot = computed(() => status.value?.snapshot ?? null)
const account = computed(() => snapshot.value?.account ?? null)
const positions = computed(() => snapshot.value?.positions ?? [])
const current = computed(() => symbols.value.find(s => s.symbol === selected.value))
const runningCount = computed(() => symbols.value.filter(s => s.running).length)
const engineText = computed(() => snapshot.value ? `${exchangeName.value} · 在线 · ${runningCount.value ? `${runningCount.value} 个币交易中` : "全部停止"}` : `${exchangeName.value} · 离线`)
/** 各币交易资金合计,不超过账户权益 */
@@ -110,26 +106,6 @@ function symbolState(s: TradingSymbol) {
return { label: "空仓", type: "info" } as const
}
async function refresh() {
[status.value, symbols.value] = await Promise.all([getStatus(), listSymbols()])
if (!current.value && symbols.value.length)
selected.value = symbols.value[0].symbol
await refreshAnalysis()
}
async function refreshAnalysis() {
const exchange = status.value?.exchange
const symbol = selected.value
if (!symbol)
return
const [latest, { plan, orders }] = await Promise.all([listAnalyses({ symbol, limit: 1 }), getSymbolPlan(symbol)])
if (symbol !== selected.value || exchange !== status.value?.exchange)
return
latestAnalysis.value = latest[0] ?? null
livePlan.value = plan
planOrders.value = orders
}
/** 持仓的平仓倒计时:取该币执行中计划的最晚平仓时间 */
function closeCountdown(symbol: string) {
const deadline = symbols.value.find(s => s.symbol === symbol)?.close_deadline
@@ -142,7 +118,7 @@ function closeCountdown(symbol: string) {
async function analyze() {
requesting.value = true
try {
await requestAnalysis(selected.value)
await requestAnalysisApi(selected.value)
ElMessage.success(current.value?.running ? "已排队分析" : "已排队试运行分析:计划只记录,不下单")
await refresh()
} finally {
@@ -156,29 +132,6 @@ function openAnalysis(id: number) {
}
/** 拉取最新一页,与已加载的合并(保留已翻页加载的旧事件) */
async function refreshEvents() {
const exchange = status.value?.exchange
const symbol = selected.value
const latest = await listEvents({ symbol: symbol || undefined, limit: EVENTS_PAGE })
// 请求期间切换了币种:丢弃旧币种的结果
if (symbol !== selected.value || exchange !== status.value?.exchange)
return
const newestId = events.value[0]?.id ?? 0
const fresh = latest.filter(e => e.id > newestId)
if (!events.value.length)
eventsExhausted.value = latest.length < EVENTS_PAGE
events.value = [...fresh, ...events.value]
}
async function loadMoreEvents() {
const exchange = status.value?.exchange
const symbol = selected.value
const older = await listEvents({ symbol: symbol || undefined, before: events.value.at(-1)?.id, limit: EVENTS_PAGE })
if (symbol !== selected.value || exchange !== status.value?.exchange)
return
eventsExhausted.value = older.length < EVENTS_PAGE
events.value = [...events.value, ...older]
}
watch([selected, () => status.value?.exchange], () => {
historyVisible.value = false
@@ -187,27 +140,37 @@ watch([selected, () => status.value?.exchange], () => {
latestAnalysis.value = null
livePlan.value = null
planOrders.value = []
refreshEvents()
refreshAnalysis()
})
async function assertActionExchange(exchange: TradingStatus["exchange"]) {
sessionScope.assertCurrent()
const currentStatus = await getStatusApi()
if (currentStatus.exchange !== exchange) {
ElMessage.warning("交易所已切换,请重新确认操作")
throw new CanceledError("交易所已切换")
}
}
async function toggleRunning(s: TradingSymbol) {
if (s.running) {
const exchange = status.value!.exchange
const { symbol, running } = s
if (running) {
await ElMessageBox.confirm(
`停止后 ${s.symbol} 不再做新的分析,并撤销未成交的限价单。已有持仓保留,止损、止盈、到期平仓等程序规则继续执行。`,
`停止 ${s.symbol} 行情交易`,
`停止后 ${symbol} 不再做新的分析,并撤销未成交的限价单。已有持仓保留,止损、止盈、到期平仓等程序规则继续执行。`,
`停止 ${symbol} 行情交易`,
{ type: "warning", confirmButtonText: "停止" }
)
} else {
await ElMessageBox.confirm(
`开始后,引擎会按触发条件和例行节奏自动分析 ${s.symbol},并按它的交易资金在 ${exchangeName.value} 模拟盘下单。`,
`开始 ${s.symbol} 行情交易`,
`开始后,引擎会按触发条件和例行节奏自动分析 ${symbol},并按它的交易资金在 ${exchangeName.value} 模拟盘下单。`,
`开始 ${symbol} 行情交易`,
{ type: "info", confirmButtonText: "开始" }
)
}
await assertActionExchange(exchange)
toggling.value = true
try {
await (s.running ? stopTrading(s.symbol) : startTrading(s.symbol))
await (running ? stopTradingApi(symbol) : startTradingApi(symbol))
await refresh()
await refreshEvents()
} finally {
@@ -216,10 +179,12 @@ async function toggleRunning(s: TradingSymbol) {
}
async function editCapital(s: TradingSymbol) {
const exchange = status.value!.exchange
const symbol = s.symbol
const others = allocated.value - Number(s.capital_usdt ?? 0)
const { value } = await ElMessageBox.prompt(
`${s.symbol} 的仓位按它的交易资金计算:单笔风险 1%(置信度低时 0.5%),名义仓位不超过 3 倍交易资金。各币交易资金合计不能超过账户权益 ${money(account.value?.margin_balance)} USDT(其他币已分配 ${others.toFixed(2)});账户权益低于它时按权益计算。`,
`设置 ${s.symbol} 交易资金(USDT)`,
`${symbol} 的仓位按它的交易资金计算:单笔风险 1%(置信度低时 0.5%),名义仓位不超过 3 倍交易资金。各币交易资金合计不能超过账户权益 ${money(account.value?.margin_balance)} USDT(其他币已分配 ${others.toFixed(2)});账户权益低于它时按权益计算。`,
`设置 ${symbol} 交易资金(USDT)`,
{
inputValue: s.capital_usdt ? num(s.capital_usdt) : "",
inputPattern: /^(?:[1-9]\d*|0)(?:\.\d{1,2})?$/,
@@ -227,59 +192,45 @@ async function editCapital(s: TradingSymbol) {
confirmButtonText: "保存"
}
)
await patchSymbol(s.symbol, { capital_usdt: value })
await assertActionExchange(exchange)
await patchSymbolApi(symbol, { capital_usdt: value })
ElMessage.success("已保存")
await refresh()
await refreshEvents()
}
async function changeProvider(providerId: number | null) {
status.value = await patchConfig({ provider_id: providerId })
}
async function changeEffort(effort: Effort | null) {
status.value = await patchConfig({ reasoning_effort: effort })
}
async function editDailyLimit() {
const { value } = await ElMessageBox.prompt(
"所有币种和交易所共用,每日零点重新计数。手动分析计入今日次数,但不受自动分析上限限制。降低至已用次数时暂停新的自动分析,进行中的分析和持仓管理继续。",
"设置每日分析上限",
{
inputValue: String(status.value!.today.limit),
inputValidator: value => /^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= 2147483647,
inputErrorMessage: "请输入 1–2147483647 之间的整数",
confirmButtonText: "保存"
}
)
status.value = await patchConfig({ daily_analysis_limit: Number(value) })
ElMessage.success("每日分析上限已保存,即时生效")
}
async function closePosition(symbol: string) {
const exchange = status.value!.exchange
await ElMessageBox.confirm(
`撤销 ${symbol} 的全部挂单(含止损、止盈和手动挂的单),市价平掉持仓,并停止 ${symbol} 的行情交易(其他币不受影响)。`,
"一键平仓",
{ type: "warning", confirmButtonText: "平仓" }
)
await manualAction(symbol, "close")
await assertActionExchange(exchange)
await manualActionApi(symbol, "close")
ElMessage.success("已提交,几秒内执行,结果见时间线")
await refresh()
}
async function clearOrders() {
const exchange = status.value!.exchange
const symbol = selected.value
await ElMessageBox.confirm(
`撤销 ${selected.value} 的全部挂单,包括止损、止盈和手动挂的单。有持仓时会一并市价平仓,程序不保留没有止损的仓位。行情交易不停止。`,
`撤销 ${symbol} 的全部挂单,包括止损、止盈和手动挂的单。有持仓时会一并市价平仓,程序不保留没有止损的仓位。行情交易不停止。`,
"清除挂单",
{ type: "warning", confirmButtonText: "清除" }
)
await manualAction(selected.value, "cancel")
await assertActionExchange(exchange)
await manualActionApi(symbol, "cancel")
ElMessage.success("已提交,几秒内执行,结果见时间线")
}
async function remove(s: TradingSymbol) {
await ElMessageBox.confirm(`把 ${s.symbol} 移出交易列表?历史分析和时间线会保留。`, "删除确认", { type: "warning" })
await deleteSymbol(s.symbol)
const exchange = status.value!.exchange
const symbol = s.symbol
await ElMessageBox.confirm(`把 ${symbol} 移出交易列表?历史分析和时间线会保留。`, "删除确认", { type: "warning" })
await assertActionExchange(exchange)
await deleteSymbolApi(symbol)
ElMessage.success("已移出")
await refresh()
}
@@ -289,18 +240,21 @@ async function onAdded(symbol: string) {
selected.value = symbol
}
refresh()
refreshEvents()
listProviders().then(list => providers.value = list)
const timer = setInterval(() => {
refresh()
refreshEvents()
}, STATUS_REFRESH_MS)
onBeforeUnmount(() => clearInterval(timer))
onMounted(() => listProvidersApi().then(list => providers.value = list))
</script>
<template>
<div v-if="status" class="flex flex-col gap-4">
<div v-if="!status" v-loading="loading" class="min-h-40">
<el-result v-if="failed" icon="error" title="无法读取模拟盘状态">
<template #extra>
<el-button @click="refresh">
重试
</el-button>
</template>
</el-result>
</div>
<div v-else class="flex flex-col gap-4">
<el-alert v-if="failed" type="error" :closable="false" title="状态刷新失败,以下为上次读取的数据" />
<!-- 顶部:引擎状态、启停、账户 -->
<el-card shadow="never" body-class="flex flex-wrap items-center gap-4">
<div class="flex flex-col gap-2">
@@ -361,9 +315,7 @@ onBeforeUnmount(() => clearInterval(timer))
<div class="flex items-center justify-between text-xs text-slate-500">
<span class="flex items-center gap-1">
今日分析
<el-button link type="primary" size="small" @click="editDailyLimit">
调整上限
</el-button>
</span>
<span class="num text-sm text-slate-900 font-semibold">{{ status.today.analyses }} / {{ status.today.limit }}</span>
</div>
@@ -374,29 +326,10 @@ onBeforeUnmount(() => clearInterval(timer))
</div>
<div class="flex-1" />
<div class="flex flex-col gap-2 text-xs text-slate-500">
<div class="flex items-center gap-2">
<span class="w-14">模型</span>
<el-select
:model-value="status.provider_id" size="small" class="w-44" clearable :value-on-clear="null" placeholder="跟随默认配置"
@update:model-value="changeProvider"
>
<el-option v-for="p in providers" :key="p.id" :value="p.id" :label="`${p.name}(${p.model})`" />
</el-select>
</div>
<div class="flex items-center gap-2">
<span class="w-14">思考强度</span>
<el-select
:model-value="status.reasoning_effort" size="small" class="w-44" clearable :value-on-clear="null" placeholder="跟随配置"
@update:model-value="changeEffort"
>
<el-option v-for="e in EFFORTS" :key="e" :value="e" :label="e" />
</el-select>
</div>
</div>
</el-card>
<TradingConfig :status="status" :providers="providers" @saved="status = $event" />
<el-alert v-if="!status.configured" type="warning" :closable="false" show-icon>
还没有填写 {{ exchangeName }} 模拟盘 key,请先到「环境变量」页填写。
</el-alert>
@@ -502,7 +435,7 @@ onBeforeUnmount(() => clearInterval(timer))
</el-table>
</el-card>
<div class="grid grid-cols-[260px_minmax(0,1fr)_340px] items-start gap-4">
<div class="trading-workspace grid items-start gap-3">
<!-- 左:交易币种 -->
<el-card shadow="never" body-class="flex flex-col gap-2">
<template #header>
@@ -630,6 +563,23 @@ onBeforeUnmount(() => clearInterval(timer))
</template>
<style scoped>
.trading-workspace {
grid-template-columns: 220px minmax(0, 1fr) 320px;
}
@media (max-width: 1280px) {
.trading-workspace {
grid-template-columns: 200px minmax(0, 1fr);
}
.trading-workspace > :last-child {
grid-column: 1 / -1;
}
}
@media (max-width: 700px) {
.trading-workspace {
grid-template-columns: minmax(0, 1fr);
}
}
.metric {
min-width: 120px;
padding: 8px 12px;
+42
View File
@@ -0,0 +1,42 @@
import type { PlanStatus } from "@@/apis/trading/type"
/** 本程序下的单;其他是用户手动下的 */
export function isOwnOrder(clientId: string) {
return /^ap\d+$/.test(clientId)
}
export const PLAN_STATUS_LABELS: Record<PlanStatus, string> = {
proposed: "待采纳",
pending: "挂单中",
open: "持仓中",
closing: "平仓中",
closed: "已结束",
canceled: "已撤销",
replaced: "已被替换",
stale: "已作废",
applied: "已执行"
}
export const CLOSE_REASON_LABELS: Record<string, string> = {
stop: "止损",
take_profit: "止盈",
time: "到达持仓时限",
exit: "模型决定结束",
external: "外部平仓",
stop_failed: "止损挂单失败",
close: "市价平仓",
manual_close: "一键平仓",
manual_cancel: "清除挂单",
// 计划 3.1 之前的旧原因,历史仓位里还会出现
invalidation: "短线失效条件触发",
replaced: "被新计划替换",
expired: "限价单到期未成交",
stopped: "已停止交易",
entry_failed: "入场单被拒"
}
/** 止盈阶梯(与后端 plan.TAKE_PROFIT_STEPS 一致):[距成交均价的涨跌幅, 平仓比例] */
export const TAKE_PROFIT_STEPS: Record<"long" | "short", [number, number][]> = {
long: [[0.005, 0.6], [0.0075, 0.3], [0.01, 0.1]],
short: [[0.005, 0.8], [0.0075, 0.2]]
}