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

+4 -1
View File
@@ -1,9 +1,12 @@
<script lang="ts" setup>
import zhCn from "element-plus/es/locale/lang/zh-cn"
import { useUserStore } from "@/pinia/stores/user"
const user = useUserStore()
</script>
<template>
<el-config-provider :locale="zhCn">
<router-view />
<router-view :key="user.sessionVersion" />
</el-config-provider>
</template>
-52
View File
@@ -1,52 +0,0 @@
import { http } from "@/http"
export interface ToolCall {
id: string
name: string
arguments: Record<string, unknown>
}
export interface ChatMessage {
id: number
role: "user" | "assistant" | "tool"
/** user: {text};assistant: {text, tool_calls, stopped?, error?};tool: {tool_call_id, name, result} */
content: Record<string, any>
provider_id: number | null
usage: { input_tokens?: number, output_tokens?: number } | null
created_at: string
}
export interface ChatSession {
id: number
created_at: string
messages: ChatMessage[]
estimated_tokens: number
generating: boolean
}
export interface ChatContext {
id: number | null
content: string
source: "user" | "chat" | "reset" | null
created_at: string | null
}
export function getChatSession(): Promise<ChatSession> {
return http.get("/chat/session")
}
export function resetChat(clearContext: boolean): Promise<void> {
return http.post("/chat/reset", { clear_context: clearContext })
}
export function getChatContext(): Promise<ChatContext> {
return http.get("/chat/context")
}
export function saveChatContext(content: string): Promise<ChatContext> {
return http.put("/chat/context", { content })
}
export function listChatContextVersions(): Promise<ChatContext[]> {
return http.get("/chat/context/versions")
}
-66
View File
@@ -1,66 +0,0 @@
import { http } from "@/http"
export type Market = "spot" | "um"
export interface IntervalProgress {
done: boolean
percent: number
}
export interface MarketSymbol {
id: number
market: Market
symbol: string
base: string
quote: string
price_precision: number
volume_precision: number
status: "pending" | "backfilling" | "ready" | "error" | "delisted"
is_default: boolean
added_at: string
last_error: string | null
progress: Record<string, IntervalProgress>
}
export interface TradableSymbol {
symbol: string
base: string
quote: string
}
/** 币安 24hr ticker 原始字段(只列用到的) */
export interface Ticker24h {
lastPrice: string
priceChangePercent: string
highPrice: string
lowPrice: string
volume: string
quoteVolume: string
}
/** [open_time, open, high, low, close, volume, quote_volume] */
export type KlineRow = [number, number, number, number, number, number, number]
export function listSymbols(): Promise<MarketSymbol[]> {
return http.get("/market/symbols")
}
export function addSymbol(market: Market, symbol: string): Promise<void> {
return http.post("/market/symbols", { market, symbol })
}
export function deleteSymbol(id: number): Promise<void> {
return http.delete(`/market/symbols/${id}`)
}
export function listTradable(market: Market): Promise<TradableSymbol[]> {
return http.get("/market/exchange-info", { params: { market } })
}
export function listKlines(params: { market: Market, symbol: string, interval: string, end?: number, limit?: number }): Promise<KlineRow[]> {
return http.get("/market/klines", { params })
}
export function getTicker(market: Market, symbol: string): Promise<Ticker24h> {
return http.get("/market/ticker", { params: { market, symbol } })
}
-104
View File
@@ -1,104 +0,0 @@
import { http } from "@/http"
export type Protocol = "openai" | "anthropic"
/** 思考强度,与后端 llm.Effort 一致 */
export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const
export type Effort = typeof EFFORTS[number]
export interface Provider {
id: number
name: string
protocol: Protocol
base_url: string
api_key_hint: string
model: string
max_tokens: number
context_window: number
/** 为空表示不传,由模型决定 */
reasoning_effort: Effort | null
is_default: boolean
updated_at: string
}
export interface ProviderForm {
name: string
protocol: Protocol
base_url: string
/** 编辑时为空表示不修改 */
api_key: string
model: string
max_tokens: number
context_window: number
/** 为空表示不传,由模型决定 */
reasoning_effort: Effort | null
is_default: boolean
}
export interface TestResult {
ok: boolean
elapsed_ms: number
reply: string | null
error: string | null
}
export function listProviders(): Promise<Provider[]> {
return http.get("/settings/providers")
}
export function createProvider(body: ProviderForm): Promise<Provider> {
return http.post("/settings/providers", body)
}
export function patchProvider(id: number, body: Partial<ProviderForm>): Promise<Provider> {
return http.patch(`/settings/providers/${id}`, body)
}
export function deleteProvider(id: number): Promise<void> {
return http.delete(`/settings/providers/${id}`)
}
export function testProvider(id: number): Promise<TestResult> {
// 模型接口读超时 600s 且 SDK 会重试 2 次,前端放宽等待时间
return http.post(`/settings/providers/${id}/test`, null, { timeout: 1900000 })
}
export type ExchangeName = "binance" | "bybit"
export interface Exchange {
exchange: ExchangeName
configured_exchanges: ExchangeName[]
configured: boolean
api_key_hint: string | null
/** 是否有币种在交易:交易中不能修改 key */
running: boolean
updated_at: string | null
}
export interface ExchangeTestResult {
ok: boolean
elapsed_ms: number
available_usdt: string | null
wallet_usdt: string | null
/** 双向持仓模式;引擎使用单向持仓,启动时会切换 */
dual_side_position: boolean | null
error: string | null
}
export function getExchange(): Promise<Exchange> {
return http.get("/settings/exchange")
}
export function putExchange(body: { exchange?: ExchangeName, api_key?: string, api_secret?: string }): Promise<Exchange> {
return http.put("/settings/exchange", body)
}
export function deleteExchange(): Promise<Exchange> {
return http.delete("/settings/exchange")
}
export function testExchange(): Promise<ExchangeTestResult> {
// 后端串行请求两个接口,单次超时 10s
return http.post("/settings/exchange/test", null, { timeout: 30000 })
}
-59
View File
@@ -1,59 +0,0 @@
import { http } from "@/http"
export type NodeKind = "folder" | "skill"
export interface SkillNode {
id: number
parent_id: number | null
kind: NodeKind
name: string
path: string
sort_order: number
priority: number
enabled: boolean
published_version_no: number | null
latest_version_no: number | null
has_draft: boolean
updated_at: string
}
export interface SkillVersion {
id: number
version_no: number
content: string
source: "user" | "chat"
note: string
created_at: string
}
export function listSkillTree(): Promise<SkillNode[]> {
return http.get("/skills/tree")
}
export function createSkillNode(body: { parent_id: number | null, kind: NodeKind, name: string, priority?: number, content?: string }): Promise<SkillNode> {
return http.post("/skills/nodes", body)
}
export function patchSkillNode(id: number, body: { name?: string, priority?: number, enabled?: boolean }): Promise<SkillNode> {
return http.patch(`/skills/nodes/${id}`, body)
}
export function moveSkillNode(id: number, parentId: number | null, index: number): Promise<void> {
return http.post(`/skills/nodes/${id}/move`, { parent_id: parentId, index })
}
export function deleteSkillNode(id: number): Promise<void> {
return http.delete(`/skills/nodes/${id}`)
}
export function listSkillVersions(id: number): Promise<SkillVersion[]> {
return http.get(`/skills/${id}/versions`)
}
export function saveSkillDraft(id: number, content: string, note = ""): Promise<SkillVersion> {
return http.post(`/skills/${id}/versions`, { content, note })
}
export function publishSkillVersion(id: number, versionId: number): Promise<SkillNode> {
return http.post(`/skills/${id}/publish`, { version_id: versionId })
}
+46
View File
@@ -0,0 +1,46 @@
import type * as Auth from "./type"
import { request } from "@/http/axios"
/** 获取当前身份 */
export function getIdentityApi() {
return request<Auth.GetIdentityResponseData>({
url: "/auth/me",
method: "get"
})
}
/** 撤销当前会话 */
export function revokeSessionApi() {
return request<Auth.RevokeSessionResponseData>({
url: "/auth/logout",
method: "post"
})
}
/** 获取登录验证码模式及挑战 */
export function getCaptchaApi() {
return request<Auth.GetCaptchaResponseData>({
url: "/auth/captcha",
method: "get",
anonymous: true,
notify: false
})
}
/** 提交登录凭据 */
export function loginApi(body: Auth.LoginForm) {
return request<Auth.LoginResponseData>({
url: "/auth/login",
method: "post",
data: body,
anonymous: true
})
}
/** 一次性短票据,只用于行情 WebSocket 握手。 */
export function createWebSocketTicketApi() {
return request<Auth.WebSocketTicket>({
url: "/auth/ws-ticket",
method: "post"
})
}
+37
View File
@@ -0,0 +1,37 @@
export interface Identity {
username: string
is_admin: boolean
}
export type GetIdentityResponseData = Identity
export type RevokeSessionResponseData = null
export type Captcha = { enabled: false } | {
enabled: true
captcha_id: string
image: string
expires_in: number
}
export interface LoginForm {
username: string
password: string
captcha_id?: string
code?: string
}
export interface LoginResult {
token: string
token_type: "bearer"
expires_at: string
}
export type GetCaptchaResponseData = Captcha
export type LoginResponseData = LoginResult
export interface WebSocketTicket {
ticket: string
expires_in: number
}
+37
View File
@@ -0,0 +1,37 @@
import type * as Calendar from "./type"
import { request } from "@/http/axios"
/** 按日期范围查询经济日历 */
export function listCalendarApi(start: string, days: number) {
return request<Calendar.ListCalendarResponseData>({
url: "/calendar",
method: "get",
params: { start, days }
})
}
/** 读取本期事件修订记录 */
export function calendarRevisionsApi(id: number) {
return request<Calendar.CalendarRevisionsResponseData>({
url: `/calendar/${id}/revisions`,
method: "get"
})
}
/** 读取同指标历次公布值 */
export function calendarHistoryApi(id: number, indicator: string) {
return request<Calendar.CalendarHistoryResponseData>({
url: `/calendar/${id}/history`,
method: "get",
params: { indicator }
})
}
/** 读取当前官方历史序列 */
export function calendarSeriesApi(id: number, indicator: string) {
return request<Calendar.CalendarSeriesResponseData>({
url: `/calendar/${id}/series`,
method: "get",
params: { indicator }
})
}
+73
View File
@@ -0,0 +1,73 @@
export interface CalendarItem {
release_id: number
indicator: string
name: string
country_code: string
country_name: string
event_name: string
title: string
period: string | null
stage: string | null
source: string
unit: string
importance: number
scheduled_at: string
status: "scheduled" | "waiting" | "delayed" | "released" | "withdrawn" | "error" | "historical" | "unavailable"
actual: string | null
previous: string | null
forecast: string | null
previous_basis: string | null
impact: "pending" | "bullish" | "bearish" | "neutral" | "uncertain"
impact_basis: "forecast" | "previous" | null
explanation: string
release_url: string | null
schedule_url: string
first_received_at: string | null
last_checked_at: string | null
error: string | null
revision_no: number
}
export interface CalendarData {
items: CalendarItem[]
countries: { code: string, name: string }[]
worker: { at: string, sources: Record<string, { source: string, checked_at: string, error: string | null }> } | null
server_time: string
}
export interface CalendarRevision {
revision_no: number
captured_at: string
payload: { title: string, scheduled_at: string, schedule_status: string, values: Record<string, { actual: string | null, previous: string | null }> }
}
export interface CalendarHistory {
release_id: number
scheduled_at: string
title: string
period: string | null
stage: string | null
actual: string
previous: string | null
forecast: string | null
first_recorded_actual: string | null
first_received_at: string
release_url: string | null
revised: boolean
}
export type ListCalendarResponseData = CalendarData
export type CalendarRevisionsResponseData = CalendarRevision[]
export type CalendarHistoryResponseData = CalendarHistory[]
export interface CalendarSeriesValue {
period: string
stage: string
value: string
captured_at: string
source_url: string
}
export type CalendarSeriesResponseData = CalendarSeriesValue[]
+59
View File
@@ -0,0 +1,59 @@
import type * as Chat from "./type"
import { request } from "@/http/axios"
import { consumeStream } from "@/http/stream"
/** 读取当前对话及生成状态 */
export function getChatSessionApi() {
return request<Chat.GetChatSessionResponseData>({
url: "/chat/session",
method: "get"
})
}
/** 重置当前对话,可按选择清空上下文 */
export function resetChatApi(clearContext: boolean) {
return request<Chat.ResetChatResponseData>({
url: "/chat/reset",
method: "post",
data: { clear_context: clearContext }
})
}
/** 读取 Chat 上下文 */
export function getChatContextApi() {
return request<Chat.GetChatContextResponseData>({
url: "/chat/context",
method: "get"
})
}
/** 保存 Chat 上下文版本 */
export function saveChatContextApi(content: string) {
return request<Chat.SaveChatContextResponseData>({
url: "/chat/context",
method: "put",
data: { content }
})
}
/** 读取 Chat 上下文历史 */
export function listChatContextVersionsApi() {
return request<Chat.ListChatContextVersionsResponseData>({
url: "/chat/context/versions",
method: "get"
})
}
/** POST 只发送一次;这里声明 SSE 事件协议,生命周期由 Chat 管理。 */
export function sendChatApi(body: Chat.SendChatRequestData, signal: AbortSignal, version: number, onEvent: Chat.ChatStreamHandler) {
return consumeStream<Chat.ChatStreamEvent["data"]>("/api/chat/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal
}, version, () => {}, (name, data) => {
// SSE 将事件名与 JSON 分开发送;在协议边界恢复同一事件的关联类型。
const event = { name, data } as Chat.ChatStreamEvent
onEvent(event)
})
}
+65
View File
@@ -0,0 +1,65 @@
import type { Effort } from "../settings/type"
export interface ToolCall {
id: string
name: string
arguments: Record<string, unknown>
}
interface MessageMetadata {
id: number
provider_id: number | null
usage: { input_tokens?: number, output_tokens?: number } | null
created_at: string
}
export type ChatMessage = MessageMetadata & (
| { role: "user", content: { text: string } }
| { role: "assistant", content: { text: string, tool_calls: ToolCall[], stopped?: boolean, error?: string, thinking_blocks?: unknown[] } }
| { role: "tool", content: { tool_call_id: string, name: string, result: string } }
)
export interface ChatSession {
id: number
created_at: string
messages: ChatMessage[]
estimated_tokens: number
generating: boolean
}
export interface ChatContext {
id: number | null
content: string
source: "user" | "chat" | "reset" | null
created_at: string | null
}
export type GetChatSessionResponseData = ChatSession
export type ResetChatResponseData = null
export type GetChatContextResponseData = ChatContext
export type SaveChatContextResponseData = ChatContext
export type ListChatContextVersionsResponseData = ChatContext[]
export interface SendChatRequestData {
text: string
provider_id: number
reasoning_effort: Effort | null
}
export type ChatStreamEvent
= | { name: "prompt", data: { estimated_tokens: number, context_window: number, provider: string } }
| { name: "thinking", data: Record<string, never> }
| { name: "done", data: Record<string, never> }
| { name: "context_updated", data: { id: number } }
| { name: "delta", data: { text: string } }
| { name: "tool_call", data: ToolCall }
| { name: "tool_result", data: { id: string, name: string, preview: string } }
| { name: "skill_draft", data: { skill_id: number, path: string, version: number } }
| { name: "usage", data: { input_tokens: number, output_tokens: number } }
| { name: "error", data: { message: string } }
export type ChatStreamHandler = (event: ChatStreamEvent) => void
+45
View File
@@ -0,0 +1,45 @@
import type * as Crawler from "./type"
import { request } from "@/http/axios"
import { consumeStream } from "@/http/stream"
/** 读取新闻与日历来源状态 */
export function listSourcesApi() {
return request<Crawler.ListSourcesResponseData>({
url: "/crawler/sources",
method: "get"
})
}
/** 修改新闻来源设置 */
export function patchSourceApi(code: string, body: Crawler.SourcePatch) {
return request<Crawler.PatchSourceResponseData>({
url: `/crawler/sources/${code}`,
method: "patch",
data: body
})
}
/** 读取来源历史日志 */
export function listLogsApi(source: string, limit = 200) {
return request<Crawler.ListLogsResponseData>({
url: "/crawler/logs",
method: "get",
params: { source, limit }
})
}
/** 请求重启新闻采集 */
export function restartCrawlerApi(source: string) {
return request<Crawler.RestartCrawlerResponseData>({
url: "/crawler/restart",
method: "post",
data: { source }
})
}
/** 只读订阅,重连由订阅生命周期负责。 */
export function subscribeCrawlerApi(signal: AbortSignal, version: number, onOpen: () => void, onMessage: (data: Crawler.CrawlLog) => void) {
return consumeStream<Crawler.CrawlLog>("/api/crawler/logs/stream", { signal }, version, onOpen, (name, data) => {
if (name === "log") onMessage(data)
})
}
@@ -1,5 +1,3 @@
import { http } from "@/http"
export interface ProcessStatus {
state: "running" | "stopped" | "restarting"
pid: number | null
@@ -10,6 +8,7 @@ export interface ProcessStatus {
export interface SourceInfo {
code: string
name: string
region_code: string | null
list_url: string
enabled: boolean
interval_seconds: number
@@ -32,8 +31,27 @@ export interface SourcesInfo {
manager_alive: boolean
db_size_bytes: number
sources: SourceInfo[]
calendar_sources: CalendarSourceInfo[]
}
export interface CalendarSourceInfo {
code: string
name: string
region_code: string
worker_alive: boolean
heartbeat_at: string | null
schedule_checked_at: string | null
schedule_success_at: string | null
latest_result_at: string | null
next_event_at: string | null
overdue_count: number
history_start: string | null
history_end: string | null
errors: string[]
}
export type ListSourcesResponseData = SourcesInfo
export interface CrawlLog {
id: number
source_code: string
@@ -43,10 +61,6 @@ export interface CrawlLog {
message: string
}
export function listSources(): Promise<SourcesInfo> {
return http.get("/crawler/sources")
}
export interface SourcePatch {
enabled?: boolean
interval_seconds?: number
@@ -54,14 +68,8 @@ export interface SourcePatch {
history_target?: string | null
}
export function patchSource(code: string, body: SourcePatch): Promise<void> {
return http.patch(`/crawler/sources/${code}`, body)
}
export type PatchSourceResponseData = null
export function listLogs(source: string, limit = 200): Promise<CrawlLog[]> {
return http.get("/crawler/logs", { params: { source, limit } })
}
export type ListLogsResponseData = CrawlLog[]
export function restartCrawler(source: string): Promise<void> {
return http.post("/crawler/restart", { source })
}
export type RestartCrawlerResponseData = null
+59
View File
@@ -0,0 +1,59 @@
import type * as Market from "./type"
import { request } from "@/http/axios"
/** 读取研究行情交易对 */
export function listSymbolsApi() {
return request<Market.ListSymbolsResponseData>({
url: "/market/symbols",
method: "get"
})
}
/** 新增研究行情交易对 */
export function addSymbolApi(market: Market.Market, symbol: string) {
return request<Market.AddSymbolResponseData>({
url: "/market/symbols",
method: "post",
data: { market, symbol }
})
}
/** 移出研究行情交易对 */
export function deleteSymbolApi(id: number) {
return request<Market.DeleteSymbolResponseData>({
url: `/market/symbols/${id}`,
method: "delete"
})
}
/** 查询交易所可用交易对 */
export function listTradableApi(market: Market.Market) {
return request<Market.ListTradableResponseData>({
url: "/market/exchange-info",
method: "get",
params: { market }
})
}
/** 读取币安原生 K 线 */
export function listKlinesApi(params: Market.ListKlinesParamsRequestData) {
return request<Market.ListKlinesResponseData>({
url: "/market/klines",
method: "get",
params
})
}
/** 读取币安 24 小时行情 */
export function getTickerApi(market: Market.Market, symbol: string) {
return request<Market.GetTickerResponseData>({
url: "/market/ticker",
method: "get",
params: { market, symbol }
})
}
/** 使用一次性票据连接行情 WebSocket */
export function createMarketSocketApi(ticket: string) {
return new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/api/market/ws`, ["alphapulse.market.v1", `ticket.${ticket}`])
}
+86
View File
@@ -0,0 +1,86 @@
export type Market = "spot" | "um"
export interface IntervalProgress {
done: boolean
percent: number
}
export interface MarketSymbol {
id: number
market: Market
symbol: string
base: string
quote: string
price_precision: number
volume_precision: number
status: "pending" | "backfilling" | "ready" | "error" | "delisted"
is_default: boolean
added_at: string
last_error: string | null
progress: Record<string, IntervalProgress>
}
export interface TradableSymbol {
symbol: string
base: string
quote: string
}
/** 币安 24hr ticker 原始字段(只列用到的) */
export interface Ticker24h {
lastPrice: string
priceChangePercent: string
highPrice: string
lowPrice: string
volume: string
quoteVolume: string
}
/** [open_time, open, high, low, close, volume, quote_volume] */
export type KlineRow = [number, number, number, number, number, number, number]
export type ListSymbolsResponseData = MarketSymbol[]
export type AddSymbolResponseData = null
export type DeleteSymbolResponseData = null
export type ListTradableResponseData = TradableSymbol[]
export type ListKlinesResponseData = KlineRow[]
export interface ListKlinesParamsRequestData { market: Market, symbol: string, interval: string, end?: number, limit?: number }
export type GetTickerResponseData = Ticker24h
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
}
export type MarketMessage
= | { type: "kline", market: Market, symbol: string, interval: string, data: KlineMessage }
| { type: "ticker", market: Market, symbol: string, interval: null, data: TickerMessage }
| { type: "error", message: string }
+36
View File
@@ -0,0 +1,36 @@
import type * as News from "./type"
import { request } from "@/http/axios"
import { consumeStream } from "@/http/stream"
/** 分页查询新闻 */
export function listNewsApi(params: News.NewsQuery) {
return request<News.ListNewsResponseData>({
url: "/news",
method: "get",
params,
paramsSerializer: { indexes: null }
})
}
/** 读取新闻详情 */
export function getNewsApi(id: number) {
return request<News.GetNewsResponseData>({
url: `/news/${id}`,
method: "get"
})
}
/** 读取新闻修订记录 */
export function listRevisionsApi(id: number) {
return request<News.ListRevisionsResponseData>({
url: `/news/${id}/revisions`,
method: "get"
})
}
/** 只读订阅,重连由订阅生命周期负责。 */
export function subscribeNewsApi(signal: AbortSignal, version: number, onOpen: () => void, onMessage: (data: News.NewsEvent) => void) {
return consumeStream<News.NewsEvent>("/api/news/stream", { signal }, version, onOpen, (name, data) => {
if (name === "news") onMessage(data)
})
}
@@ -1,5 +1,3 @@
import { http } from "@/http"
export interface NewsBrief {
id: number
source_code: string
@@ -15,6 +13,18 @@ export interface NewsBrief {
revision_no: number
}
export interface NewsQuery {
source?: string[]
q?: string
start?: string
end?: string
/** 从 1 开始 */
page?: number
limit?: number
}
export interface ListNewsResponseData { items: NewsBrief[], total: number }
export interface NewsDetail extends NewsBrief {
content_html: string
source_updated_at: string | null
@@ -30,31 +40,12 @@ export interface Revision {
captured_at: string
}
export interface NewsQuery {
source?: string[]
q?: string
start?: string
end?: string
/** 从 1 开始 */
page?: number
limit?: number
}
export interface NewsEvent {
id: number
source_code: string
kind: "new" | "revised"
}
export function listNews(params: NewsQuery): Promise<{ items: NewsBrief[], total: number }> {
// source 需要序列化为重复参数 source=a&source=b
return http.get("/news", { params, paramsSerializer: { indexes: null } })
}
export type GetNewsResponseData = NewsDetail
export function getNews(id: number): Promise<NewsDetail> {
return http.get(`/news/${id}`)
}
export function listRevisions(id: number): Promise<Revision[]> {
return http.get(`/news/${id}/revisions`)
}
export type ListRevisionsResponseData = Revision[]
+81
View File
@@ -0,0 +1,81 @@
import type * as Settings from "./type"
import { request } from "@/http/axios"
/** 读取模型配置(不返回密钥) */
export function listProvidersApi() {
return request<Settings.ListProvidersResponseData>({
url: "/settings/providers",
method: "get"
})
}
/** 新增模型配置 */
export function createProviderApi(body: Settings.ProviderForm) {
return request<Settings.CreateProviderResponseData>({
url: "/settings/providers",
method: "post",
data: body
})
}
/** 修改模型配置;空密钥保持原值 */
export function patchProviderApi(id: number, body: Partial<Settings.ProviderForm>) {
return request<Settings.PatchProviderResponseData>({
url: `/settings/providers/${id}`,
method: "patch",
data: body
})
}
/** 删除模型配置 */
export function deleteProviderApi(id: number) {
return request<Settings.DeleteProviderResponseData>({
url: `/settings/providers/${id}`,
method: "delete"
})
}
/** 测试模型连接;按后端 SDK 重试上限等待 */
export function testProviderApi(id: number) {
return request<Settings.TestProviderResponseData>({
url: `/settings/providers/${id}/test`,
method: "post",
data: null,
timeout: 1900000
})
}
/** 读取当前模拟盘交易所设置 */
export function getExchangeApi() {
return request<Settings.GetExchangeResponseData>({
url: "/settings/exchange",
method: "get"
})
}
/** 切换交易所或保存其凭证 */
export function putExchangeApi(body: Settings.PutExchangeBodyRequestData) {
return request<Settings.PutExchangeResponseData>({
url: "/settings/exchange",
method: "put",
data: body
})
}
/** 删除当前交易所凭证 */
export function deleteExchangeApi() {
return request<Settings.DeleteExchangeResponseData>({
url: "/settings/exchange",
method: "delete"
})
}
/** 测试当前模拟盘连接 */
export function testExchangeApi() {
return request<Settings.TestExchangeResponseData>({
url: "/settings/exchange/test",
method: "post",
data: null,
timeout: 30000
})
}
+81
View File
@@ -0,0 +1,81 @@
export type Protocol = "openai" | "anthropic"
export type Effort = "low" | "medium" | "high" | "xhigh" | "max"
export interface Provider {
id: number
name: string
protocol: Protocol
base_url: string
api_key_hint: string
model: string
max_tokens: number
context_window: number
/** 为空表示不传,由模型决定 */
reasoning_effort: Effort | null
is_default: boolean
updated_at: string
}
export type ListProvidersResponseData = Provider[]
export interface ProviderForm {
name: string
protocol: Protocol
base_url: string
/** 编辑时为空表示不修改 */
api_key: string
model: string
max_tokens: number
context_window: number
/** 为空表示不传,由模型决定 */
reasoning_effort: Effort | null
is_default: boolean
}
export interface TestResult {
ok: boolean
elapsed_ms: number
reply: string | null
error: string | null
}
export type CreateProviderResponseData = Provider
export type PatchProviderResponseData = Provider
export type DeleteProviderResponseData = null
export type TestProviderResponseData = TestResult
export type ExchangeName = "binance" | "bybit"
export interface Exchange {
exchange: ExchangeName
configured_exchanges: ExchangeName[]
configured: boolean
api_key_hint: string | null
/** 是否有币种在交易:交易中不能修改 key */
running: boolean
updated_at: string | null
}
export interface ExchangeTestResult {
ok: boolean
elapsed_ms: number
available_usdt: string | null
wallet_usdt: string | null
/** 双向持仓模式;引擎使用单向持仓,启动时会切换 */
dual_side_position: boolean | null
error: string | null
}
export type GetExchangeResponseData = Exchange
export type PutExchangeResponseData = Exchange
export interface PutExchangeBodyRequestData { exchange?: ExchangeName, api_key?: string, api_secret?: string }
export type DeleteExchangeResponseData = Exchange
export type TestExchangeResponseData = ExchangeTestResult
+71
View File
@@ -0,0 +1,71 @@
import type * as Skills from "./type"
import { request } from "@/http/axios"
/** 读取 Skills 树 */
export function listSkillTreeApi() {
return request<Skills.ListSkillTreeResponseData>({
url: "/skills/tree",
method: "get"
})
}
/** 新增文件夹或 Skill */
export function createSkillNodeApi(body: Skills.CreateSkillNodeBodyRequestData) {
return request<Skills.CreateSkillNodeResponseData>({
url: "/skills/nodes",
method: "post",
data: body
})
}
/** 修改 Skill 元信息 */
export function patchSkillNodeApi(id: number, body: Skills.PatchSkillNodeBodyRequestData) {
return request<Skills.PatchSkillNodeResponseData>({
url: `/skills/nodes/${id}`,
method: "patch",
data: body
})
}
/** 移动 Skill 节点 */
export function moveSkillNodeApi(id: number, parentId: number | null, index: number) {
return request<Skills.MoveSkillNodeResponseData>({
url: `/skills/nodes/${id}/move`,
method: "post",
data: { parent_id: parentId, index }
})
}
/** 删除 Skill 节点 */
export function deleteSkillNodeApi(id: number) {
return request<Skills.DeleteSkillNodeResponseData>({
url: `/skills/nodes/${id}`,
method: "delete"
})
}
/** 读取 Skill 历史版本 */
export function listSkillVersionsApi(id: number) {
return request<Skills.ListSkillVersionsResponseData>({
url: `/skills/${id}/versions`,
method: "get"
})
}
/** 发布指定 Skill 版本 */
export function publishSkillVersionApi(id: number, versionId: number) {
return request<Skills.PublishSkillVersionResponseData>({
url: `/skills/${id}/publish`,
method: "post",
data: { version_id: versionId }
})
}
/** 保存 Skill 草稿,不自动发布 */
export function saveSkillDraftApi(id: number, content: string, note = "") {
return request<Skills.SaveSkillDraftResponseData>({
url: `/skills/${id}/versions`,
method: "post",
data: { content, note }
})
}
+45
View File
@@ -0,0 +1,45 @@
export type NodeKind = "folder" | "skill"
export interface SkillNode {
id: number
parent_id: number | null
kind: NodeKind
name: string
path: string
sort_order: number
priority: number
enabled: boolean
published_version_no: number | null
latest_version_no: number | null
has_draft: boolean
updated_at: string
}
export interface SkillVersion {
id: number
version_no: number
content: string
source: "user" | "chat"
note: string
created_at: string
}
export type ListSkillTreeResponseData = SkillNode[]
export type CreateSkillNodeResponseData = SkillNode
export interface CreateSkillNodeBodyRequestData { parent_id: number | null, kind: NodeKind, name: string, priority?: number, content?: string }
export type PatchSkillNodeResponseData = SkillNode
export interface PatchSkillNodeBodyRequestData { name?: string, priority?: number, enabled?: boolean }
export type MoveSkillNodeResponseData = null
export type DeleteSkillNodeResponseData = null
export type ListSkillVersionsResponseData = SkillVersion[]
export type PublishSkillVersionResponseData = SkillNode
export type SaveSkillDraftResponseData = SkillVersion
+152
View File
@@ -0,0 +1,152 @@
import type * as Trading from "./type"
import { request } from "@/http/axios"
/** 读取模拟盘账户与全局分析状态 */
export function getStatusApi() {
return request<Trading.GetStatusResponseData>({
url: "/trading/status",
method: "get"
})
}
/** 开始指定币种的自动交易 */
export function startTradingApi(symbol: string) {
return request<Trading.StartTradingResponseData>({
url: `/trading/symbols/${symbol}/start`,
method: "post"
})
}
/** 停止指定币种的新分析并撤销入场单 */
export function stopTradingApi(symbol: string) {
return request<Trading.StopTradingResponseData>({
url: `/trading/symbols/${symbol}/stop`,
method: "post"
})
}
/** 设置该币的交易资金:各币合计不超过账户权益 */
export function patchSymbolApi(symbol: string, body: Trading.PatchSymbolBodyRequestData) {
return request<Trading.PatchSymbolResponseData>({
url: `/trading/symbols/${symbol}`,
method: "patch",
data: body
})
}
/** 更新指定的全局交易配置字段 */
export function patchConfigApi(body: Trading.PatchConfigBodyRequestData) {
return request<Trading.PatchConfigResponseData>({
url: "/trading/config",
method: "patch",
data: body
})
}
/** 查询模拟盘可交易币种 */
export function listSymbolOptionsApi() {
return request<Trading.ListSymbolOptionsResponseData>({
url: "/trading/symbol-options",
method: "get"
})
}
/** 读取交易币种列表 */
export function listSymbolsApi() {
return request<Trading.ListSymbolsResponseData>({
url: "/trading/symbols",
method: "get"
})
}
/** 新增交易币种 */
export function addSymbolApi(symbol: string) {
return request<Trading.AddSymbolResponseData>({
url: "/trading/symbols",
method: "post",
data: { symbol }
})
}
/** 移出交易币种,保留历史 */
export function deleteSymbolApi(symbol: string) {
return request<Trading.DeleteSymbolResponseData>({
url: `/trading/symbols/${symbol}`,
method: "delete"
})
}
/** 分页读取交易时间线 */
export function listEventsApi(params: Trading.ListEventsParamsRequestData) {
return request<Trading.ListEventsResponseData>({
url: "/trading/events",
method: "get",
params
})
}
/** 筛选已平仓历史及汇总 */
export function listPositionHistoryApi(params: Trading.PositionHistoryQuery) {
return request<Trading.ListPositionHistoryResponseData>({
url: "/trading/positions/history",
method: "get",
params
})
}
/** 读取历史仓位币种 */
export function listPositionHistorySymbolsApi() {
return request<Trading.ListPositionHistorySymbolsResponseData>({
url: "/trading/positions/history/symbols",
method: "get"
})
}
/** 读取新闻信号前向统计 */
export function getNewsSignalsApi() {
return request<Trading.GetNewsSignalsResponseData>({
url: "/trading/news-signals",
method: "get"
})
}
/** 读取当前执行计划与订单流水 */
export function getSymbolPlanApi(symbol: string) {
return request<Trading.GetSymbolPlanResponseData>({
url: `/trading/symbols/${symbol}/plan`,
method: "get"
})
}
/** 排队手动分析;202 仅表示已受理 */
export function requestAnalysisApi(symbol: string) {
return request<Trading.RequestAnalysisResponseData>({
url: `/trading/symbols/${symbol}/analyze`,
method: "post"
})
}
/** 一键平仓(同时停止该币的行情交易)、清除挂单:由执行器在几秒内执行,结果写入时间线 */
export function manualActionApi(symbol: string, action: "close" | "cancel") {
return request<Trading.ManualActionResponseData>({
url: `/trading/symbols/${symbol}/manual/${action}`,
method: "post"
})
}
/** 读取分析记录列表 */
export function listAnalysesApi(params: Trading.ListAnalysesParamsRequestData) {
return request<Trading.ListAnalysesResponseData>({
url: "/trading/analyses",
method: "get",
params
})
}
/** 读取分析详情与工具调用过程 */
export function getAnalysisApi(id: number) {
return request<Trading.GetAnalysisResponseData>({
url: `/trading/analyses/${id}`,
method: "get"
})
}
@@ -1,5 +1,4 @@
import type { Effort } from "@/apis/settings"
import { http } from "@/http"
import type { Effort } from "../settings/type"
/** 以下数值字段都是交易所返回的十进制字符串,避免浮点误差 */
export interface SnapshotPosition {
@@ -97,51 +96,31 @@ export interface TradingEvent {
data: Record<string, unknown> | null
}
/** 本程序下的单;其他是用户手动下的 */
export function isOwnOrder(clientId: string) {
return /^ap\d+$/.test(clientId)
}
export type GetStatusResponseData = TradingStatus
export function getStatus(): Promise<TradingStatus> {
return http.get("/trading/status")
}
export type StartTradingResponseData = null
export function startTrading(symbol: string): Promise<void> {
return http.post(`/trading/symbols/${symbol}/start`)
}
export type StopTradingResponseData = null
export function stopTrading(symbol: string): Promise<void> {
return http.post(`/trading/symbols/${symbol}/stop`)
}
export type PatchSymbolResponseData = null
/** 设置该币的交易资金:各币合计不超过账户权益 */
export function patchSymbol(symbol: string, body: { capital_usdt: string | null }): Promise<void> {
return http.patch(`/trading/symbols/${symbol}`, body)
}
export interface PatchSymbolBodyRequestData { capital_usdt: string | null }
export function patchConfig(body: { provider_id?: number | null, reasoning_effort?: Effort | null, daily_analysis_limit?: number }): Promise<TradingStatus> {
return http.patch("/trading/config", body)
}
export type PatchConfigResponseData = TradingStatus
export function listSymbolOptions(): Promise<SymbolOption[]> {
return http.get("/trading/symbol-options")
}
export interface PatchConfigBodyRequestData { provider_id?: number | null, reasoning_effort?: Effort | null, daily_analysis_limit?: number }
export function listSymbols(): Promise<TradingSymbol[]> {
return http.get("/trading/symbols")
}
export type ListSymbolOptionsResponseData = SymbolOption[]
export function addSymbol(symbol: string): Promise<void> {
return http.post("/trading/symbols", { symbol })
}
export type ListSymbolsResponseData = TradingSymbol[]
export function deleteSymbol(symbol: string): Promise<void> {
return http.delete(`/trading/symbols/${symbol}`)
}
export type AddSymbolResponseData = null
export function listEvents(params: { symbol?: string, before?: number, limit?: number }): Promise<TradingEvent[]> {
return http.get("/trading/events", { params })
}
export type DeleteSymbolResponseData = null
export type ListEventsResponseData = TradingEvent[]
export interface ListEventsParamsRequestData { symbol?: string, before?: number, limit?: number }
export interface PlanCondition {
direction: "above" | "below"
@@ -224,18 +203,6 @@ export interface AnalysisDetail extends Analysis {
export type PlanStatus = "proposed" | "pending" | "open" | "closing" | "closed" | "canceled" | "replaced" | "stale" | "applied"
export const PLAN_STATUS_LABELS: Record<PlanStatus, string> = {
proposed: "待采纳",
pending: "挂单中",
open: "持仓中",
closing: "平仓中",
closed: "已结束",
canceled: "已撤销",
replaced: "已被替换",
stale: "已作废",
applied: "已执行"
}
/** 执行器的订单流水 */
export interface OrderRow {
id: number
@@ -253,30 +220,6 @@ export interface OrderRow {
created_at: string
}
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]]
}
/** 由当前交易所成交记录还原的已平仓仓位(含程序外的手动仓位);数值为十进制字符串 */
export interface PositionHistory {
symbol: string
@@ -313,13 +256,9 @@ export interface PositionHistoryPage {
items: PositionHistory[]
}
export function listPositionHistory(params: PositionHistoryQuery): Promise<PositionHistoryPage> {
return http.get("/trading/positions/history", { params })
}
export type ListPositionHistoryResponseData = PositionHistoryPage
export function listPositionHistorySymbols(): Promise<string[]> {
return http.get("/trading/positions/history/symbols")
}
export type ListPositionHistorySymbolsResponseData = string[]
export type SignalHorizon = "1h" | "4h"
@@ -356,27 +295,16 @@ export interface NewsSignals {
recent: NewsSignalItem[]
}
export function getNewsSignals(): Promise<NewsSignals> {
return http.get("/trading/news-signals")
}
export type GetNewsSignalsResponseData = NewsSignals
export function getSymbolPlan(symbol: string): Promise<{ plan: Analysis | null, orders: OrderRow[] }> {
return http.get(`/trading/symbols/${symbol}/plan`)
}
export interface GetSymbolPlanResponseData { plan: Analysis | null, orders: OrderRow[] }
export function requestAnalysis(symbol: string): Promise<void> {
return http.post(`/trading/symbols/${symbol}/analyze`)
}
export type RequestAnalysisResponseData = null
/** 一键平仓(同时停止该币的行情交易)、清除挂单:由执行器在几秒内执行,结果写入时间线 */
export function manualAction(symbol: string, action: "close" | "cancel"): Promise<void> {
return http.post(`/trading/symbols/${symbol}/manual/${action}`)
}
export type ManualActionResponseData = null
export function listAnalyses(params: { symbol?: string, limit?: number }): Promise<Analysis[]> {
return http.get("/trading/analyses", { params })
}
export type ListAnalysesResponseData = Analysis[]
export function getAnalysis(id: number): Promise<AnalysisDetail> {
return http.get(`/trading/analyses/${id}`)
}
export interface ListAnalysesParamsRequestData { symbol?: string, limit?: number }
export type GetAnalysisResponseData = AnalysisDetail
File renamed without changes.
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { SwitchButton, TrendCharts } from "@element-plus/icons-vue"
import { useUserStore } from "@/pinia/user"
import { menuRoutes } from "@/router"
import { useUserStore } from "@/pinia/stores/user"
import { menuRoutes } from "@/router/routes"
const router = useRouter()
const userStore = useUserStore()
@@ -14,20 +14,19 @@ async function logout() {
<template>
<div class="h-full flex flex-col">
<header class="h-14 flex shrink-0 items-center gap-10 border-b border-b-slate-200 border-b-solid bg-white px-6 shadow-sm">
<div class="flex items-center gap-2 text-lg text-slate-900 font-bold">
<header class="app-header h-auto flex shrink-0 flex-wrap items-center gap-x-3 gap-y-0 border-b border-b-slate-200 border-b-solid bg-white px-3 pt-2 shadow-sm lg:h-14 lg:flex-nowrap lg:gap-6 lg:px-6 lg:pt-0">
<div class="brand flex flex-1 shrink-0 items-center gap-2 text-base lg:flex-none lg:text-lg text-slate-900 font-bold">
<el-icon :size="22" color="var(--el-color-primary)">
<TrendCharts />
</el-icon>
AlphaPulse
</div>
<nav class="h-full flex gap-7 text-sm">
<nav class="app-nav order-4 h-10 min-w-0 flex flex-1 basis-full gap-5 overflow-x-auto text-sm lg:order-none lg:h-full lg:basis-auto lg:gap-6">
<RouterLink v-for="item in menuRoutes" :key="item.path" :to="item.path" class="nav-link" active-class="active">
{{ item.meta.title }}
</RouterLink>
</nav>
<div class="flex-1" />
<div class="flex items-center gap-2 text-sm text-slate-700">
<div class="user-label shrink-0 hidden items-center lg:flex gap-2 text-sm text-slate-700">
<span class="h-8 w-8 flex-center border border-sky-200 rounded-full border-solid bg-sky-50 text-xs text-[var(--el-color-primary)] font-bold">
{{ userStore.username?.slice(0, 2).toUpperCase() }}
</span>
@@ -37,13 +36,21 @@ async function logout() {
退出登录
</el-button>
</header>
<main class="min-h-0 flex-1 overflow-auto p-4">
<main class="app-main min-h-0 min-w-0 flex-1 overflow-auto p-2 lg:p-3">
<router-view />
</main>
</div>
</template>
<style scoped>
.app-nav {
scrollbar-width: none;
}
.nav-link {
white-space: nowrap;
flex-shrink: 0;
}
.nav-link {
display: flex;
align-items: center;
@@ -1,10 +1,10 @@
<script lang="ts" setup>
import type { NodeKind, SkillNode } from "@@/apis/skills/type"
import type Node from "element-plus/es/components/tree/src/model/node"
import type { AllowDropType, NodeDropType } from "element-plus/es/components/tree/src/tree.type"
import type { NodeKind, SkillNode } from "@/apis/skills"
import { createSkillNode, deleteSkillNode, moveSkillNode, patchSkillNode } from "@/apis/skills"
import { createSkillNodeApi, deleteSkillNodeApi, moveSkillNodeApi, patchSkillNodeApi } from "@@/apis/skills"
import { useSessionScope } from "@@/composables/useSessionScope"
/** skills 树:Skills 页可编辑(拖拽、增删改),Chat 侧边栏只读 */
const props = defineProps<{
nodes: SkillNode[]
editable?: boolean
@@ -12,11 +12,15 @@ const props = defineProps<{
/** 需要高亮的节点(如 Chat 刚生成草稿的 skill) */
highlightIds?: number[]
}>()
const emit = defineEmits<{
select: [node: SkillNode]
changed: []
}>()
/** skills 树:Skills 页可编辑(拖拽、增删改),Chat 侧边栏只读 */
const sessionScope = useSessionScope()
interface TreeItem {
id: number
label: string
@@ -61,7 +65,7 @@ async function onDrop(dragging: Node, drop: Node, type: NodeDropType) {
const siblings = props.nodes.filter(n => n.parent_id === parentId && n.id !== dragged.id)
const index = type === "inner" ? siblings.length : siblings.findIndex(n => n.id === target.id) + (type === "after" ? 1 : 0)
try {
await moveSkillNode(dragged.id, parentId, index)
await moveSkillNodeApi(dragged.id, parentId, index)
} finally {
// 失败时也刷新,让树回到服务端的真实顺序
emit("changed")
@@ -74,7 +78,8 @@ async function create(parentId: number | null, kind: NodeKind) {
const { value } = await ElMessageBox.prompt(kind === "folder" ? "文件夹名称" : "skill 名称", kind === "folder" ? "新建文件夹" : "新建 skill", {
inputValidator: NAME_RULE
})
const node = await createSkillNode({ parent_id: parentId, kind, name: value.trim() })
sessionScope.assertCurrent()
const node = await createSkillNodeApi({ parent_id: parentId, kind, name: value.trim() })
emit("changed")
if (kind === "skill")
emit("select", node)
@@ -82,14 +87,16 @@ async function create(parentId: number | null, kind: NodeKind) {
async function rename(node: SkillNode) {
const { value } = await ElMessageBox.prompt("新名称", "重命名", { inputValue: node.name, inputValidator: NAME_RULE })
await patchSkillNode(node.id, { name: value.trim() })
sessionScope.assertCurrent()
await patchSkillNodeApi(node.id, { name: value.trim() })
emit("changed")
}
async function remove(node: SkillNode) {
const message = node.kind === "folder" ? `删除文件夹「${node.path}」?(必须为空)` : `删除 skill「${node.path}」及其全部版本?此操作不可恢复。`
await ElMessageBox.confirm(message, "删除确认", { type: "warning" })
await deleteSkillNode(node.id)
sessionScope.assertCurrent()
await deleteSkillNodeApi(node.id)
ElMessage.success("已删除")
emit("changed")
}
@@ -1,11 +1,16 @@
<script lang="ts" setup>
import type { SkillNode, SkillVersion } from "@/apis/skills"
import { listSkillVersions, publishSkillVersion } from "@/apis/skills"
import { diffLines, formatTime } from "@/utils/text"
import type { SkillNode, SkillVersion } from "@@/apis/skills/type"
import { listSkillVersionsApi, publishSkillVersionApi } from "@@/apis/skills"
import { useSessionScope } from "@@/composables/useSessionScope"
import { diffLines, formatTime } from "@@/utils/text"
const props = defineProps<{ skill: SkillNode | null }>()
const emit = defineEmits<{ published: [] }>()
/** 版本历史:与已发布版本逐行对比,可发布任意版本(发布旧版本即回退) */
const props = defineProps<{ skill: SkillNode | null }>()
const emit = defineEmits<{ published: [] }>()
const sessionScope = useSessionScope()
const visible = defineModel<boolean>({ required: true })
const versions = ref<SkillVersion[]>([])
@@ -19,7 +24,7 @@ const diff = computed(() => selected.value ? diffLines((published.value?.content
async function load() {
if (!props.skill)
return
versions.value = await listSkillVersions(props.skill.id)
versions.value = await listSkillVersionsApi(props.skill.id)
// 默认选中最新版本,直接看到草稿相对已发布版本的改动
selectedId.value = versions.value[0]?.id ?? null
}
@@ -33,9 +38,10 @@ async function publish(version: SkillVersion) {
const current = props.skill?.published_version_no
const action = current !== null && current !== undefined && version.version_no < current ? "回退到" : "发布"
await ElMessageBox.confirm(`${action} v${version.version_no}?发布后 Chat 下一轮起按此版本判断。`, "发布确认", { type: "warning" })
sessionScope.assertCurrent()
publishing.value = true
try {
await publishSkillVersion(props.skill!.id, version.id)
await publishSkillVersionApi(props.skill!.id, version.id)
ElMessage.success(`已${action} v${version.version_no}`)
emit("published")
} finally {
@@ -0,0 +1,18 @@
import { CanceledError } from "axios"
import { onBeforeUnmount } from "vue"
import { useUserStore } from "@/pinia/stores/user"
/** 确认框等异步交互结束后,不得用新账号执行旧页面的操作。 */
export function useSessionScope() {
const user = useUserStore()
const version = user.sessionVersion
let disposed = false
onBeforeUnmount(() => {
disposed = true
})
const isCurrent = () => !disposed && version === user.sessionVersion
function assertCurrent() {
if (!isCurrent()) throw new CanceledError("页面或会话已切换")
}
return { isCurrent, assertCurrent }
}
@@ -0,0 +1,8 @@
import { storeToRefs } from "pinia"
import { useSourcesStore } from "@/pinia/stores/sources"
export function useSourceNames() {
const store = useSourcesStore()
onMounted(() => store.load())
return storeToRefs(store).names
}
+2
View File
@@ -0,0 +1,2 @@
/** 思考强度,与后端 llm.Effort 一致 */
export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const
File renamed without changes.
File renamed without changes.
File renamed without changes.
-66
View File
@@ -1,66 +0,0 @@
import { router } from "@/router"
/** POST 方式的 SSE(EventSource 只支持 GET):逐个事件回调;stop() 断开连接,服务端随之停止生成 */
export function useChatStream() {
const streaming = ref(false)
let controller: AbortController | null = null
async function send(body: unknown, onEvent: (name: string, data: any) => void) {
controller = new AbortController()
streaming.value = true
try {
const response = await fetch("/api/chat/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal
})
if (response.status === 401) {
router.replace({ name: "login", query: { redirect: router.currentRoute.value.fullPath } })
return
}
if (!response.ok || !response.body) {
const detail = await response.json().catch(() => null)
onEvent("error", { message: detail?.detail ?? `请求失败:HTTP ${response.status}` })
return
}
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ""
while (true) {
const { value, done } = await reader.read()
if (done)
break
buffer += value
// 事件之间以空行分隔;保留最后一段不完整的
const blocks = buffer.split("\n\n")
buffer = blocks.pop() ?? ""
for (const block of blocks) {
let name = "message"
let data = ""
for (const line of block.split("\n")) {
if (line.startsWith("event:"))
name = line.slice(6).trim()
else if (line.startsWith("data:"))
data += line.slice(5).trim()
}
// ": ping" 之类的注释行没有 data
if (data)
onEvent(name, JSON.parse(data))
}
}
} catch (e) {
if ((e as Error).name !== "AbortError")
onEvent("error", { message: `连接中断:${(e as Error).message}` })
} finally {
streaming.value = false
controller = null
}
}
function stop() {
controller?.abort()
}
onBeforeUnmount(stop)
return { streaming, send, stop }
}
-10
View File
@@ -1,10 +0,0 @@
/** 订阅 SSE 事件;组件卸载时关闭。断线由浏览器自动重连 */
export function useEventSource<T>(url: string, event: string, onMessage: (data: T) => void) {
const connected = ref(false)
const source = new EventSource(url)
source.addEventListener("open", () => connected.value = true)
source.addEventListener("error", () => connected.value = false)
source.addEventListener(event, e => onMessage(JSON.parse((e as MessageEvent).data)))
onBeforeUnmount(() => source.close())
return { connected }
}
-13
View File
@@ -1,13 +0,0 @@
import { listSources } from "@/apis/crawler"
const names = ref<Record<string, string>>({})
/** 来源 code → 中文名,全局只请求一次 */
export function useSourceNames() {
if (Object.keys(names.value).length === 0) {
listSources().then((info) => {
names.value = Object.fromEntries(info.sources.map(s => [s.code, s.name]))
})
}
return names
}
+95
View File
@@ -0,0 +1,95 @@
import type { AxiosRequestConfig } from "axios"
import axios, { CanceledError } from "axios"
import { ElMessage } from "element-plus"
import { useUserStore } from "@/pinia/stores/user"
import { router } from "@/router"
declare module "axios" {
interface AxiosRequestConfig {
anonymous?: boolean
sessionVersion?: number
notify?: boolean
/** 明确声明未包装的 JSON,例如 OpenAPI;不自动探测响应格式。 */
rawResponse?: boolean
}
}
export interface ApiEnvelope<T> {
code: number
data: T
message: string
}
export class ApiError extends Error {
constructor(public code: number, message: string) {
super(message)
}
}
export function readEnvelope<T>(value: unknown): ApiEnvelope<T> {
if (!value || typeof value !== "object" || !("code" in value) || typeof value.code !== "number"
|| !("message" in value) || typeof value.message !== "string" || !("data" in value)) {
throw new ApiError(502, "服务器响应格式无效")
}
return value as ApiEnvelope<T>
}
/** 无 Vue 生命周期接管的轮询回调:请求层已提示,保留真实错误供排查。 */
export function logRequestError(error: unknown) {
if (!axios.isCancel(error)) console.error(error)
}
export function rejectAuthentication(version: number, redirect = true) {
const user = useUserStore()
if (version !== user.sessionVersion)
return
user.setToken("")
const route = router.currentRoute.value
if (redirect && route.name !== "login")
void router.replace({ name: "login", query: { redirect: route.fullPath } })
}
const client = axios.create({ baseURL: "/api", timeout: 15000 })
client.interceptors.request.use((config) => {
const user = useUserStore()
if (config.sessionVersion !== user.sessionVersion || (!config.anonymous && !user.token))
throw new CanceledError("会话已切换")
if (!config.anonymous && user.token)
config.headers.set("Authorization", `Bearer ${user.token}`)
return config
})
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
const version = useUserStore().sessionVersion
config.sessionVersion = version
try {
const response = await client.request(config)
if (version !== useUserStore().sessionVersion)
throw new CanceledError("会话已切换")
if (config.rawResponse) return response.data
const body = readEnvelope<T>(response.data)
if (body.code !== 0)
throw new ApiError(body.code, body.message)
return body.data
} catch (error) {
if (version !== useUserStore().sessionVersion)
throw new CanceledError("会话已切换")
if (axios.isCancel(error))
throw error
let reason = error
if (axios.isAxiosError(error) && error.response) {
try {
const body = readEnvelope(error.response.data)
reason = new ApiError(error.response.status, body.message || `请求失败:HTTP ${error.response.status}`)
} catch (invalid) {
reason = new ApiError(error.response.status, invalid instanceof Error ? invalid.message : "服务器响应格式无效")
}
}
if (reason instanceof ApiError && reason.code === 401 && !config.anonymous) {
rejectAuthentication(version, config.url !== "/auth/me")
} else if (config.notify !== false) {
ElMessage.error(reason instanceof Error ? reason.message : "请求失败")
}
throw reason
}
}
-21
View File
@@ -1,21 +0,0 @@
import axios from "axios"
import { ElMessage } from "element-plus"
import { router } from "@/router"
// 鉴权靠 httpOnly cookie,同源请求自动携带
export const http = axios.create({ baseURL: "/api", timeout: 15000 })
http.interceptors.response.use(
response => response.data,
(error) => {
const url = error.config?.url
// /auth/me 的 401 由路由守卫处理;其余接口 401 说明登录过期
if (error.response?.status === 401 && url !== "/auth/login") {
if (url !== "/auth/me")
router.replace({ name: "login", query: { redirect: router.currentRoute.value.fullPath } })
} else {
ElMessage.error(error.response?.data?.detail ?? error.message)
}
return Promise.reject(error)
}
)
+54
View File
@@ -0,0 +1,54 @@
export interface StreamEvent {
event: string
data: string
}
/** 按 SSE 行语义解析,支持分块、CRLF、多行 data 和最后一个未换行块。 */
export async function* parseEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<StreamEvent> {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ""
let event = "message"
let data: string[] = []
function line(value: string): StreamEvent | undefined {
if (!value) {
const result = data.length ? { event, data: data.join("\n") } : undefined
event = "message"
data = []
return result
}
if (value.startsWith(":"))
return
const colon = value.indexOf(":")
const field = colon < 0 ? value : value.slice(0, colon)
let content = colon < 0 ? "" : value.slice(colon + 1)
if (content.startsWith(" ")) content = content.slice(1)
if (field === "event") event = content
if (field === "data") data.push(content)
}
try {
while (true) {
const { value, done } = await reader.read()
buffer += decoder.decode(value, { stream: !done })
while (true) {
const end = buffer.search(/[\r\n]/)
if (end < 0) break
if (!done && buffer[end] === "\r" && end === buffer.length - 1)
break
const width = buffer[end] === "\r" && buffer[end + 1] === "\n" ? 2 : 1
const result = line(buffer.slice(0, end))
buffer = buffer.slice(end + width)
if (result) yield result
}
if (done) {
if (buffer) line(buffer)
const result = line("")
if (result) yield result
return
}
}
} finally {
await reader.cancel()
reader.releaseLock()
}
}
+45
View File
@@ -0,0 +1,45 @@
import axios, { CanceledError } from "axios"
import { ApiError, readEnvelope, rejectAuthentication } from "@/http/axios"
import { useUserStore } from "@/pinia/stores/user"
import { parseEvents } from "./sse"
/** 只执行一次请求;重连只由只读订阅的调用方负责。 */
export async function consumeStream<T>(url: string, init: RequestInit, version: number, onOpen: () => void, onEvent: (event: string, data: T) => void) {
const user = useUserStore()
const assertCurrent = () => {
if (version !== user.sessionVersion || !user.token || init.signal?.aborted)
throw new CanceledError("会话或订阅已结束")
}
assertCurrent()
const response = await fetch(url, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${user.token}` }
})
assertCurrent()
if (!response.ok) {
const body = readEnvelope(await response.json())
assertCurrent()
if (response.status === 401)
rejectAuthentication(version)
throw new ApiError(response.status, body.message || `请求失败:HTTP ${response.status}`)
}
if (!response.body || !response.headers.get("content-type")?.startsWith("text/event-stream"))
throw new ApiError(502, "服务器未返回事件流")
onOpen()
for await (const event of parseEvents(response.body)) {
assertCurrent()
const data = JSON.parse(event.data)
if (event.event === "auth_error") {
rejectAuthentication(version)
throw new ApiError(401, data.message)
}
if (event.event === "error" && typeof data.code === "number")
throw new ApiError(data.code, data.message)
onEvent(event.event, data)
}
assertCurrent()
}
export function streamCanceled(error: unknown): boolean {
return axios.isCancel(error) || (error instanceof Error && error.name === "AbortError")
}
+55
View File
@@ -0,0 +1,55 @@
import { ElMessage } from "element-plus"
import { ApiError } from "@/http/axios"
import { useUserStore } from "@/pinia/stores/user"
import { streamCanceled } from "./stream"
/** Bearer SSE:只有只读订阅重连;身份失效和权限拒绝均停止。 */
export function useEventSource<T>(subscribe: (signal: AbortSignal, version: number, onOpen: () => void, onMessage: (data: T) => void) => Promise<void>, onMessage: (data: T) => void) {
const connected = ref(false)
const user = useUserStore()
const version = user.sessionVersion
let closed = false
let retryMs = 1000
let controller: AbortController | undefined
let timer: ReturnType<typeof setTimeout> | undefined
let reported = false
async function connect() {
if (closed || version !== user.sessionVersion)
return
controller = new AbortController()
try {
await subscribe(controller.signal, version, () => {
connected.value = true
retryMs = 1000
reported = false
}, onMessage)
} catch (error) {
if (streamCanceled(error)) return
if (error instanceof ApiError && [401, 403].includes(error.code)) {
closed = true
if (error.code === 403) ElMessage.error(error.message)
return
}
if (!reported) {
ElMessage.error(error instanceof Error ? error.message : "实时连接中断")
reported = true
}
} finally {
connected.value = false
}
if (!closed && version === user.sessionVersion) {
timer = setTimeout(connect, retryMs)
retryMs = Math.min(retryMs * 2, 30000)
}
}
function close() {
closed = true
connected.value = false
clearTimeout(timer)
controller?.abort()
}
watch(() => user.sessionVersion, close, { flush: "sync" })
void connect()
onBeforeUnmount(close)
return { connected }
}
+15 -2
View File
@@ -1,11 +1,24 @@
import { ElMessageBox } from "element-plus"
import { createPinia } from "pinia"
import App from "@/App.vue"
import { logRequestError } from "@/http/axios"
import { TOKEN_KEY, useUserStore } from "@/pinia/stores/user"
import { router } from "@/router"
import "element-plus/dist/index.css"
import "element-plus/theme-chalk/dark/css-vars.css"
import "@fontsource-variable/inter"
import "@fontsource-variable/jetbrains-mono"
import "@/styles/index.css"
import "@@/assets/styles/index.css"
import "virtual:uno.css"
createApp(App).use(createPinia()).use(router).mount("#app")
const app = createApp(App).use(createPinia())
const user = useUserStore()
watch(() => user.sessionVersion, () => ElMessageBox.close(), { flush: "sync" })
app.config.errorHandler = logRequestError
window.addEventListener("storage", (event) => {
if (event.storageArea === localStorage && (event.key === TOKEN_KEY || event.key === null)) {
user.setToken(localStorage.getItem(TOKEN_KEY) ?? "", false)
void router.replace({ name: "login", query: { redirect: router.currentRoute.value.fullPath } })
}
})
app.use(router).mount("#app")
@@ -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]]
}
+31
View File
@@ -0,0 +1,31 @@
import { listSourcesApi } from "@@/apis/crawler"
import { defineStore } from "pinia"
import { ref, watch } from "vue"
import { useUserStore } from "@/pinia/stores/user"
export const useSourcesStore = defineStore("sources", () => {
const names = ref<Record<string, string>>({})
const user = useUserStore()
let loading: Promise<void> | undefined
watch(() => user.sessionVersion, () => {
names.value = {}
loading = undefined
}, { flush: "sync" })
function load() {
if (Object.keys(names.value).length)
return Promise.resolve()
if (!loading) {
const version = user.sessionVersion
loading = listSourcesApi().then((info) => {
if (version === user.sessionVersion)
names.value = Object.fromEntries(info.sources.map(s => [s.code, s.name]))
}).finally(() => {
if (version === user.sessionVersion)
loading = undefined
})
}
return loading
}
return { names, load }
})
+53
View File
@@ -0,0 +1,53 @@
import type { Identity } from "@@/apis/auth/type"
import { getIdentityApi, revokeSessionApi } from "@@/apis/auth"
import { CanceledError } from "axios"
import { defineStore } from "pinia"
import { computed, ref } from "vue"
export const TOKEN_KEY = "alphapulse.auth.token"
export const useUserStore = defineStore("user", () => {
const token = ref(localStorage.getItem(TOKEN_KEY) ?? "")
const sessionVersion = ref(0)
const identity = ref<Identity>()
const username = computed(() => identity.value?.username)
let restoring: Promise<void> | undefined
function setToken(value: string, persist = true) {
sessionVersion.value++
token.value = value
identity.value = undefined
restoring = undefined
if (persist) {
if (value)
localStorage.setItem(TOKEN_KEY, value)
else
localStorage.removeItem(TOKEN_KEY)
}
}
async function fetchMe() {
if (!restoring) {
const version = sessionVersion.value
restoring = getIdentityApi().then((me) => {
if (version !== sessionVersion.value)
throw new CanceledError("会话已切换")
identity.value = me
}).finally(() => {
if (version === sessionVersion.value)
restoring = undefined
})
}
return restoring
}
async function logout() {
const version = sessionVersion.value
await revokeSessionApi()
if (version !== sessionVersion.value)
throw new CanceledError("会话已切换")
setToken("")
}
return { token, sessionVersion, identity, username, setToken, fetchMe, logout }
})
-22
View File
@@ -1,22 +0,0 @@
import { http } from "@/http"
export const useUserStore = defineStore("user", () => {
const username = ref<string>()
async function fetchMe() {
const me: { username: string } = await http.get("/auth/me")
username.value = me.username
}
async function login(name: string, password: string) {
const me: { username: string } = await http.post("/auth/login", { username: name, password })
username.value = me.username
}
async function logout() {
await http.post("/auth/logout")
username.value = undefined
}
return { username, fetchMe, login, logout }
})
+41
View File
@@ -0,0 +1,41 @@
import type { Router } from "vue-router"
import axios from "axios"
import { ApiError } from "@/http/axios"
import { useUserStore } from "@/pinia/stores/user"
export function safeRedirect(value: unknown): string {
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//")
&& !value.includes("\\") && !/\s/.test(value) && !value.startsWith("/login")
? value
: "/news"
}
export function installGuards(router: Router) {
router.beforeEach(async (to) => {
const user = useUserStore()
const version = user.sessionVersion
if (to.meta.public && to.name !== "login")
return true
if (!user.token)
return to.name === "login" ? true : { name: "login", query: { redirect: to.fullPath } }
try {
if (!user.identity)
await user.fetchMe()
} catch (error) {
if (error instanceof ApiError && error.code === 401 && !user.token && user.sessionVersion === version + 1)
return { name: "login", query: { redirect: to.fullPath } }
if (version !== user.sessionVersion || axios.isCancel(error))
return false
if (error instanceof ApiError && error.code === 401)
return { name: "login", query: { redirect: to.fullPath } }
return { name: "connection-error", query: { redirect: to.fullPath } }
}
if (version !== user.sessionVersion)
return false
if (to.name === "login")
return safeRedirect(to.query.redirect)
if (to.meta.admin && !user.identity?.is_admin)
return { name: "forbidden" }
return true
})
}
+8 -25
View File
@@ -1,36 +1,19 @@
import Layout from "@@/components/AppLayout/index.vue"
import { createRouter, createWebHistory } from "vue-router"
import Layout from "@/layouts/index.vue"
import { useUserStore } from "@/pinia/user"
import { installGuards } from "./guards"
export const menuRoutes = [
{ path: "/news", name: "news", component: () => import("@/pages/news/index.vue"), meta: { title: "新闻流" } },
{ path: "/crawler", name: "crawler", component: () => import("@/pages/crawler/index.vue"), meta: { title: "采集控制台" } },
{ path: "/market", name: "market", component: () => import("@/pages/market/index.vue"), meta: { title: "行情" } },
{ path: "/trading", name: "trading", component: () => import("@/pages/trading/index.vue"), meta: { title: "模拟盘" } },
{ path: "/chat", name: "chat", component: () => import("@/pages/chat/index.vue"), meta: { title: "Chat" } },
{ path: "/skills", name: "skills", component: () => import("@/pages/skills/index.vue"), meta: { title: "Skills" } },
{ path: "/settings", name: "settings", component: () => import("@/pages/settings/index.vue"), meta: { title: "环境变量" } }
]
import { menuRoutes } from "./routes"
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/login", name: "login", component: () => import("@/pages/login/index.vue") },
{ path: "/", component: Layout, redirect: "/news", children: menuRoutes },
{ path: "/:pathMatch(.*)*", redirect: "/" }
{ path: "/docs", name: "docs", component: () => import("@/pages/docs/index.vue"), meta: { admin: true } },
{ path: "/403", name: "forbidden", component: () => import("@/pages/error/index.vue"), meta: { status: 403 } },
{ path: "/connection-error", name: "connection-error", component: () => import("@/pages/error/index.vue"), meta: { public: true, status: 503 } },
{ path: "/:pathMatch(.*)*", name: "not-found", component: () => import("@/pages/error/index.vue"), meta: { status: 404 } }
]
})
router.beforeEach(async (to) => {
if (to.name === "login")
return true
const userStore = useUserStore()
if (!userStore.username) {
try {
await userStore.fetchMe()
} catch {
return { name: "login", query: { redirect: to.fullPath } }
}
}
return true
})
installGuards(router)
+10
View File
@@ -0,0 +1,10 @@
export const menuRoutes = [
{ path: "/news", name: "news", component: () => import("@/pages/news/index.vue"), meta: { admin: true, title: "新闻流" } },
{ path: "/calendar", name: "calendar", component: () => import("@/pages/calendar/index.vue"), meta: { admin: true, title: "日历" } },
{ path: "/crawler", name: "crawler", component: () => import("@/pages/crawler/index.vue"), meta: { admin: true, title: "采集控制台" } },
{ path: "/market", name: "market", component: () => import("@/pages/market/index.vue"), meta: { admin: true, title: "行情" } },
{ path: "/trading", name: "trading", component: () => import("@/pages/trading/index.vue"), meta: { admin: true, title: "模拟盘" } },
{ path: "/chat", name: "chat", component: () => import("@/pages/chat/index.vue"), meta: { admin: true, title: "Chat" } },
{ path: "/skills", name: "skills", component: () => import("@/pages/skills/index.vue"), meta: { admin: true, title: "Skills" } },
{ path: "/settings", name: "settings", component: () => import("@/pages/settings/index.vue"), meta: { admin: true, title: "环境变量" } }
]