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:
1 parent
77b33e5e77
commit
bc480fb806
102 files changed
+4716
-1707
No files matched your search
@@ -5,6 +5,8 @@ export default antfu(
|
||||
{
|
||||
// 使用外部格式化程序格式化 css、html、markdown 等文件
|
||||
formatters: true,
|
||||
// 使用 Node 内置测试,不引入 Vitest。
|
||||
test: { overrides: { "test/no-import-node-test": "off" } },
|
||||
// 启用样式规则
|
||||
stylistic: {
|
||||
// 缩进级别
|
||||
@@ -15,7 +17,7 @@ export default antfu(
|
||||
semi: false
|
||||
},
|
||||
// 忽略文件
|
||||
ignores: ["types/auto-imports.d.ts", "types/components.d.ts"]
|
||||
ignores: ["types/auto/**"]
|
||||
},
|
||||
{
|
||||
// 对所有文件都生效的规则
|
||||
|
||||
+4
-1
@@ -6,7 +6,10 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"lint": "eslint . --fix"
|
||||
"lint": "eslint . --fix",
|
||||
"lint:check": "eslint .",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test": "node --experimental-strip-types --test tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 16"><rect width="24" height="16"/><path fill="#d00" d="M0 5.33h24v5.34H0z"/><path fill="#ffce00" d="M0 10.67h24V16H0z"/></svg>
|
||||
|
After Width: | Height: | Size: 183 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 16"><rect width="24" height="16" fill="#003399"/><g fill="#fc0"><polygon points="12.000,1.950 12.200,2.525 12.808,2.537 12.323,2.905 12.500,3.488 12.000,3.140 11.500,3.488 11.677,2.905 11.192,2.537 11.800,2.525"/><polygon points="14.600,2.647 14.800,3.222 15.408,3.234 14.923,3.602 15.100,4.184 14.600,3.837 14.100,4.184 14.277,3.602 13.792,3.234 14.400,3.222"/><polygon points="16.503,4.550 16.703,5.125 17.312,5.137 16.827,5.505 17.003,6.088 16.503,5.740 16.004,6.088 16.180,5.505 15.695,5.137 16.303,5.125"/><polygon points="17.200,7.150 17.400,7.725 18.008,7.737 17.523,8.105 17.700,8.688 17.200,8.340 16.700,8.688 16.877,8.105 16.392,7.737 17.000,7.725"/><polygon points="16.503,9.750 16.703,10.325 17.312,10.337 16.827,10.705 17.003,11.288 16.503,10.940 16.004,11.288 16.180,10.705 15.695,10.337 16.303,10.325"/><polygon points="14.600,11.653 14.800,12.228 15.408,12.241 14.923,12.608 15.100,13.191 14.600,12.843 14.100,13.191 14.277,12.608 13.792,12.241 14.400,12.228"/><polygon points="12.000,12.350 12.200,12.925 12.808,12.937 12.323,13.305 12.500,13.888 12.000,13.540 11.500,13.888 11.677,13.305 11.192,12.937 11.800,12.925"/><polygon points="9.400,11.653 9.600,12.228 10.208,12.241 9.723,12.608 9.900,13.191 9.400,12.843 8.900,13.191 9.077,12.608 8.592,12.241 9.200,12.228"/><polygon points="7.497,9.750 7.697,10.325 8.305,10.337 7.820,10.705 7.996,11.288 7.497,10.940 6.997,11.288 7.173,10.705 6.688,10.337 7.297,10.325"/><polygon points="6.800,7.150 7.000,7.725 7.608,7.737 7.123,8.105 7.300,8.688 6.800,8.340 6.300,8.688 6.477,8.105 5.992,7.737 6.600,7.725"/><polygon points="7.497,4.550 7.697,5.125 8.305,5.137 7.820,5.505 7.996,6.088 7.497,5.740 6.997,6.088 7.173,5.505 6.688,5.137 7.297,5.125"/><polygon points="9.400,2.647 9.600,3.222 10.208,3.234 9.723,3.602 9.900,4.184 9.400,3.837 8.900,4.184 9.077,3.602 8.592,3.234 9.200,3.222"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 16"><path fill="#002395" d="M0 0h8v16H0z"/><path fill="#fff" d="M8 0h8v16H8z"/><path fill="#ed2939" d="M16 0h8v16h-8z"/></svg>
|
||||
|
After Width: | Height: | Size: 183 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 16"><rect width="24" height="16" fill="#012169"/><path stroke="#fff" stroke-width="3" d="m0 0 24 16M24 0 0 16"/><path stroke="#c8102e" stroke-width="1" d="m0 0 24 16M24 0 0 16"/><path stroke="#fff" stroke-width="5" d="M12 0v16M0 8h24"/><path stroke="#c8102e" stroke-width="3" d="M12 0v16M0 8h24"/></svg>
|
||||
|
After Width: | Height: | Size: 360 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 247 130">
|
||||
<defs>
|
||||
<path id="star" fill="#fff" d="M0-3.8.85-1.17H3.61L1.38.45l.85 2.63L0 1.45l-2.23 1.63.85-2.63-2.23-1.62h2.76Z"/>
|
||||
<g id="six"><use href="#star" x="8"/><use href="#star" x="24"/><use href="#star" x="40"/><use href="#star" x="56"/><use href="#star" x="72"/><use href="#star" x="88"/></g>
|
||||
<g id="five"><use href="#star" x="16"/><use href="#star" x="32"/><use href="#star" x="48"/><use href="#star" x="64"/><use href="#star" x="80"/></g>
|
||||
</defs>
|
||||
<path fill="#fff" d="M0 0h247v130H0z"/>
|
||||
<path stroke="#b22234" stroke-width="10" d="M0 5h247M0 25h247M0 45h247M0 65h247M0 85h247M0 105h247M0 125h247"/>
|
||||
<path fill="#3c3b6e" d="M0 0h99v70H0z"/>
|
||||
<g transform="translate(1.5)"><use href="#six" y="7"/><use href="#five" y="14"/><use href="#six" y="21"/><use href="#five" y="28"/><use href="#six" y="35"/><use href="#five" y="42"/><use href="#six" y="49"/><use href="#five" y="56"/><use href="#six" y="63"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 998 B |
+4
-1
@@ -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>
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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"
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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[]
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}`])
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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[]
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
+13
-7
@@ -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
|
||||
}
|
||||
@@ -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.
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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]]
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
@@ -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: "环境变量" } }
|
||||
]
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { setImmediate } from "node:timers/promises"
|
||||
import { moduleLoader, require } from "./module-loader.ts"
|
||||
|
||||
const axios = require("axios").default
|
||||
const pinia = require("pinia")
|
||||
const vue = require("vue")
|
||||
const { createRouter, createMemoryHistory } = require("vue-router")
|
||||
|
||||
const ok = (data: unknown) => ({ status: 200, body: { code: 0, data, message: "" } })
|
||||
const failure = (status: number) => ({ status, body: { code: status, data: null, message: `failure ${status}` } })
|
||||
|
||||
function environment(respond: (config: any) => any) {
|
||||
const messages: string[] = []
|
||||
const values = new Map<string, string>()
|
||||
const storage = { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value), removeItem: (key: string) => values.delete(key) }
|
||||
pinia.setActivePinia(pinia.createPinia())
|
||||
const routes = [
|
||||
{ path: "/login", name: "login" },
|
||||
{ path: "/news", name: "news", meta: { admin: true } },
|
||||
{ path: "/calendar", name: "calendar", meta: { admin: true } },
|
||||
{ path: "/403", name: "forbidden" },
|
||||
{ path: "/connection-error", name: "connection-error", meta: { public: true } }
|
||||
].map(route => ({ ...route, component: { render: () => null } }))
|
||||
const router = createRouter({ history: createMemoryHistory(), routes })
|
||||
const load = moduleLoader({
|
||||
"@/router": { router },
|
||||
"element-plus": { ElMessage: { error: (message: string) => messages.push(message) } },
|
||||
"axios": { __esModule: true, ...axios, default: { ...axios, create: (config: any) => axios.create({
|
||||
...config,
|
||||
adapter: async (config: any) => {
|
||||
const { status, body } = await respond(config)
|
||||
const response = { status, data: body, statusText: "", headers: {}, config }
|
||||
if (status >= 400) throw new axios.AxiosError(`HTTP ${status}`, "ERR_BAD_RESPONSE", config, undefined, response)
|
||||
return response
|
||||
}
|
||||
}) } }
|
||||
}, { localStorage: storage, ...vue })
|
||||
const { request, ApiError } = load("@/http/axios")
|
||||
const user = load("@/pinia/stores/user").useUserStore()
|
||||
const guards = load("@/router/guards")
|
||||
return { request, ApiError, user, guards, router, messages, load, storage }
|
||||
}
|
||||
|
||||
test("JSON 解包、明确拒绝错误、403 保留身份且只提示一次", async () => {
|
||||
let result = ok([1])
|
||||
const env = environment(() => result)
|
||||
env.user.setToken("current")
|
||||
assert.deepEqual(await env.request({ url: "/news", method: "get" }), [1])
|
||||
result = failure(403)
|
||||
await assert.rejects(env.request({ url: "/news", method: "get" }), (error: any) => error.code === 403)
|
||||
assert.equal(env.user.token, "current")
|
||||
assert.equal(env.messages.length, 1)
|
||||
result = { status: 200, body: { code: 400, data: null, message: "business failure" } }
|
||||
await assert.rejects(env.request({ url: "/news", method: "get" }), /business failure/)
|
||||
result = { status: 200, body: { items: [] } } as any
|
||||
await assert.rejects(env.request({ url: "/news", method: "get" }), /响应格式无效/)
|
||||
})
|
||||
|
||||
test("旧身份恢复响应不能写入新会话;同 Token 重新设置也隔离", async () => {
|
||||
const pending = Promise.withResolvers<any>()
|
||||
const env = environment(() => pending.promise)
|
||||
env.user.setToken("same-token")
|
||||
const restore = env.user.fetchMe()
|
||||
const rejected = assert.rejects(restore, (error: any) => axios.isCancel(error))
|
||||
await setImmediate()
|
||||
env.user.setToken("same-token")
|
||||
pending.resolve(ok({ username: "old", is_admin: true }))
|
||||
await rejected
|
||||
assert.equal(env.user.identity, undefined)
|
||||
assert.equal(env.user.token, "same-token")
|
||||
assert.equal(env.messages.length, 0)
|
||||
})
|
||||
|
||||
test("旧 401 与旧注销结果都不能注销新登录", async () => {
|
||||
for (const request of ["read", "logout"]) {
|
||||
const pending = Promise.withResolvers<any>()
|
||||
const env = environment(() => pending.promise)
|
||||
env.user.setToken("old")
|
||||
const operation = request === "logout" ? env.user.logout() : env.request({ url: "/news", method: "get" })
|
||||
const rejected = assert.rejects(operation, (error: any) => axios.isCancel(error))
|
||||
await setImmediate()
|
||||
env.user.setToken("new")
|
||||
pending.resolve(request === "logout" ? ok(null) : failure(401))
|
||||
await rejected
|
||||
assert.equal(env.user.token, "new")
|
||||
assert.equal(env.messages.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test("注销失败保留会话;成功才清除持久 Token", async () => {
|
||||
let result: ReturnType<typeof ok> = failure(503)
|
||||
const env = environment(() => result)
|
||||
env.user.setToken("current")
|
||||
await assert.rejects(env.user.logout())
|
||||
assert.equal(env.user.token, "current")
|
||||
result = ok(null)
|
||||
await env.user.logout()
|
||||
assert.equal(env.user.token, "")
|
||||
assert.equal(env.storage.getItem("alphapulse.auth.token"), null)
|
||||
})
|
||||
|
||||
test("匿名深链、登录回跳与已登录刷新使用真实身份", async () => {
|
||||
let count = 0
|
||||
const env = environment(() => {
|
||||
count++
|
||||
return ok({ username: "admin", is_admin: true })
|
||||
})
|
||||
env.guards.installGuards(env.router)
|
||||
await env.router.push("/calendar?date=2026-10-25")
|
||||
assert.equal(env.router.currentRoute.value.name, "login")
|
||||
assert.equal(env.router.currentRoute.value.query.redirect, "/calendar?date=2026-10-25")
|
||||
env.user.setToken("current")
|
||||
await env.router.push("/news")
|
||||
await env.router.push("/login?redirect=/calendar%3Fdate%3D2026-10-25")
|
||||
assert.equal(env.router.currentRoute.value.fullPath, "/calendar?date=2026-10-25")
|
||||
assert.equal(count, 1)
|
||||
await env.router.push("/news")
|
||||
assert.equal(count, 1)
|
||||
for (const target of ["//evil.test", "https://evil.test", "/\\evil.test", "/login?redirect=/login"])
|
||||
assert.equal(env.guards.safeRedirect(target), "/news")
|
||||
})
|
||||
|
||||
test("身份恢复 401 保留深链回跳;403 不清身份,网络错误允许重试", async () => {
|
||||
for (const mode of ["expired", "forbidden", "network"]) {
|
||||
const env = environment(() => mode === "expired" ? failure(401) : mode === "network" ? failure(503) : ok({ username: "former-admin", is_admin: false }))
|
||||
env.user.setToken("current")
|
||||
env.guards.installGuards(env.router)
|
||||
await env.router.push("/calendar?date=today")
|
||||
const route = env.router.currentRoute.value
|
||||
assert.equal(route.name, mode === "expired" ? "login" : mode === "forbidden" ? "forbidden" : "connection-error")
|
||||
assert.equal(env.user.token, mode === "expired" ? "" : "current")
|
||||
if (mode === "expired") assert.equal(route.query.redirect, "/calendar?date=today")
|
||||
if (mode === "forbidden") assert.equal(env.user.identity.username, "former-admin")
|
||||
}
|
||||
})
|
||||
|
||||
test("共享来源缓存只有一个在途请求,切换会话清空且丢弃旧响应", async () => {
|
||||
const pending = Promise.withResolvers<any>()
|
||||
let count = 0
|
||||
const env = environment(() => {
|
||||
count++
|
||||
return pending.promise
|
||||
})
|
||||
env.user.setToken("old")
|
||||
const sources = env.load("@/pinia/stores/sources").useSourcesStore()
|
||||
const first = sources.load()
|
||||
assert.equal(first, sources.load())
|
||||
const rejected = assert.rejects(first, (error: any) => axios.isCancel(error))
|
||||
await setImmediate()
|
||||
env.user.setToken("new")
|
||||
pending.resolve(ok({ sources: [{ code: "private", name: "old" }] }))
|
||||
await rejected
|
||||
assert.equal(count, 1)
|
||||
assert.deepEqual(Object.keys(sources.names), [])
|
||||
})
|
||||
|
||||
test("确认框结束后不能以新会话发布旧页面的 Skill", async () => {
|
||||
const confirm = Promise.withResolvers<void>()
|
||||
const user = vue.reactive({ token: "old", sessionVersion: 1 })
|
||||
let published = 0
|
||||
const node = { id: 1, kind: "skill", name: "test" }
|
||||
const load = moduleLoader({
|
||||
"vue": { ...vue, onBeforeUnmount() {} },
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@@/apis/skills": {
|
||||
listSkillTreeApi: async () => [node],
|
||||
listSkillVersionsApi: async () => [{ id: 1, version_no: 1, content: "text" }],
|
||||
publishSkillVersionApi: async () => {
|
||||
published++
|
||||
}
|
||||
},
|
||||
"@@/components/SkillTree/index.vue": {},
|
||||
"@@/components/SkillVersionDrawer/index.vue": {},
|
||||
"@@/utils/markdown": { renderMarkdown: (text: string) => text }
|
||||
}, {
|
||||
...vue,
|
||||
onMounted() {},
|
||||
onBeforeUnmount() {},
|
||||
onBeforeRouteLeave() {},
|
||||
ElMessage: { success() {} },
|
||||
ElMessageBox: { confirm: () => confirm.promise }
|
||||
})
|
||||
const page = load("@/pages/skills/index.vue").default.setup({}, { expose() {} })
|
||||
await page.loadTree()
|
||||
await page.select(node)
|
||||
const operation = page.publishLatest()
|
||||
await setImmediate()
|
||||
user.sessionVersion++
|
||||
confirm.resolve()
|
||||
await assert.rejects(operation, (error: any) => axios.isCancel(error))
|
||||
assert.equal(published, 0)
|
||||
})
|
||||
|
||||
test("OpenAPI 仅显式原样返回;201/202 保留业务数据与 null", async () => {
|
||||
let result: { status: number, body: unknown } = { status: 200, body: { openapi: "3.1.0", info: { title: "test", version: "1" }, paths: {} } }
|
||||
const env = environment(() => result)
|
||||
env.user.setToken("current")
|
||||
const spec = await env.load("@@/apis/docs").getOpenApiApi()
|
||||
assert.equal(spec.openapi, "3.1.0")
|
||||
await assert.rejects(env.request({ url: "/news", method: "get" }), /响应格式无效/)
|
||||
for (const status of [201, 202]) {
|
||||
result = { status, body: { code: 0, data: null, message: "" } }
|
||||
assert.equal(await env.request({ url: "/write", method: "post" }), null)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { moduleLoader, require } from "./module-loader.ts"
|
||||
|
||||
const vue = require("vue")
|
||||
|
||||
test("验证码刷新保留最新响应;登录锁覆盖导航,失败刷新挑战", async () => {
|
||||
const first = Promise.withResolvers<any>()
|
||||
const second = Promise.withResolvers<any>()
|
||||
const signed = Promise.withResolvers<any>()
|
||||
const navigated = Promise.withResolvers<any>()
|
||||
let challenges = 0
|
||||
let logins = 0
|
||||
let fail = false
|
||||
const user = { token: "", setToken(value: string) {
|
||||
this.token = value
|
||||
} }
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@/router/guards": { safeRedirect: () => "/calendar" },
|
||||
"@@/apis/auth": {
|
||||
getCaptchaApi: () => ++challenges === 1 ? first.promise : challenges === 2 ? second.promise : Promise.resolve({ enabled: true, captcha_id: "retry", image: "png", expires_in: 120 }),
|
||||
loginApi: () => {
|
||||
logins++
|
||||
return fail ? Promise.reject(new Error("验证码失败")) : signed.promise
|
||||
}
|
||||
}
|
||||
}, {
|
||||
...vue,
|
||||
useRoute: () => ({ query: { redirect: "/calendar" } }),
|
||||
useRouter: () => ({ replace: () => navigated.promise }),
|
||||
onMounted() {},
|
||||
onBeforeUnmount() {},
|
||||
ElMessage: { warning() {} }
|
||||
})
|
||||
const page = load("@/pages/login/index.vue").default.setup({}, { expose() {} })
|
||||
page.formRef.value = { validate: async () => true }
|
||||
const a = page.refreshCaptcha()
|
||||
const b = page.refreshCaptcha()
|
||||
second.resolve({ enabled: true, captcha_id: "new", image: "new-png", expires_in: 120 })
|
||||
await b
|
||||
first.resolve({ enabled: true, captcha_id: "old", image: "old-png", expires_in: 120 })
|
||||
await a
|
||||
assert.equal(page.form.captcha_id, "new")
|
||||
assert.equal(page.image.value, "new-png")
|
||||
Object.assign(page.form, { username: "admin", password: "password", code: "ABCDE" })
|
||||
const sending = page.submit()
|
||||
await page.submit()
|
||||
await Promise.resolve()
|
||||
assert.equal(logins, 1)
|
||||
signed.resolve({ token: "new-token" })
|
||||
await Promise.resolve()
|
||||
assert.equal(page.loading.value, true)
|
||||
navigated.resolve(undefined)
|
||||
await sending
|
||||
assert.equal(user.token, "new-token")
|
||||
assert.equal(page.loading.value, false)
|
||||
fail = true
|
||||
await assert.rejects(page.submit())
|
||||
assert.equal(page.form.captcha_id, "retry")
|
||||
assert.equal(page.form.code, "")
|
||||
})
|
||||
|
||||
test("验证码关闭时只提交账号密码,失败无需刷新挑战", async () => {
|
||||
let challenges = 0
|
||||
let fail = false
|
||||
const bodies: unknown[] = []
|
||||
const user = { token: "", setToken(value: string) {
|
||||
this.token = value
|
||||
} }
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@/router/guards": { safeRedirect: () => "/news" },
|
||||
"@@/apis/auth": {
|
||||
getCaptchaApi: async () => {
|
||||
challenges++
|
||||
return { enabled: false }
|
||||
},
|
||||
loginApi: async (body: unknown) => {
|
||||
bodies.push(body)
|
||||
if (fail) throw new Error("用户名或密码错误")
|
||||
return { token: "password-login-token" }
|
||||
}
|
||||
}
|
||||
}, {
|
||||
...vue,
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ replace: async () => {} }),
|
||||
onMounted() {},
|
||||
onBeforeUnmount() {},
|
||||
ElMessage: { warning() {
|
||||
assert.fail("不应要求验证码")
|
||||
} }
|
||||
})
|
||||
const page = load("@/pages/login/index.vue").default.setup({}, { expose() {} })
|
||||
page.formRef.value = { validate: async () => true }
|
||||
await page.refreshCaptcha()
|
||||
assert.equal(page.captchaEnabled.value, false)
|
||||
Object.assign(page.form, { username: "admin", password: "password" })
|
||||
await page.submit()
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(bodies)), [{ username: "admin", password: "password" }])
|
||||
assert.equal(user.token, "password-login-token")
|
||||
fail = true
|
||||
await assert.rejects(page.submit())
|
||||
assert.equal(challenges, 1)
|
||||
assert.equal(page.loading.value, false)
|
||||
})
|
||||
|
||||
test("验证配置读取失败阻止登录,重试成功后解除;表单失败不发送请求", async () => {
|
||||
let fail = true
|
||||
let logins = 0
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => ({ sessionVersion: 0 }) },
|
||||
"@/router/guards": { safeRedirect: () => "/news" },
|
||||
"@@/apis/auth": {
|
||||
getCaptchaApi: async () => {
|
||||
if (fail) throw new Error("配置读取失败")
|
||||
return { enabled: false }
|
||||
},
|
||||
loginApi: () => {
|
||||
logins++
|
||||
}
|
||||
}
|
||||
}, { ...vue, useRoute: () => ({ query: {} }), useRouter: () => ({}), onMounted() {}, onBeforeUnmount() {} })
|
||||
const page = load("@/pages/login/index.vue").default.setup({}, { expose() {} })
|
||||
await assert.rejects(page.refreshCaptcha(), /配置读取失败/)
|
||||
await page.submit()
|
||||
assert.equal(logins, 0)
|
||||
assert.equal(page.captchaReady.value, false)
|
||||
assert.equal(page.captchaError.value, "配置读取失败")
|
||||
fail = false
|
||||
await page.refreshCaptcha()
|
||||
page.formRef.value = { validate: async () => {
|
||||
throw new Error("表单无效")
|
||||
} }
|
||||
await assert.rejects(page.submit(), /表单无效/)
|
||||
assert.equal(logins, 0)
|
||||
assert.equal(page.captchaReady.value, true)
|
||||
assert.equal(page.loading.value, false)
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { createRequire, Module } from "node:module"
|
||||
import { dirname, resolve } from "node:path"
|
||||
import { runInNewContext } from "node:vm"
|
||||
import ts from "typescript"
|
||||
import { compileScript, parse } from "vue/compiler-sfc"
|
||||
|
||||
export const require = createRequire(import.meta.url)
|
||||
const source = new URL("../src/", import.meta.url).pathname
|
||||
|
||||
/** 使用现有 TS/Vue 编译器执行真实模块;只替换 I/O 和浏览器生命周期。 */
|
||||
export function moduleLoader(mocks: Record<string, unknown>, globals: Record<string, unknown> = {}) {
|
||||
const cache = new Map<string, Module>()
|
||||
function load(name: string, parent = source): any {
|
||||
if (name in mocks) return mocks[name]
|
||||
if (!name.startsWith("@/") && !name.startsWith("@@/") && !name.startsWith(".")) return require(name)
|
||||
let filename = name.startsWith("@@/")
|
||||
? resolve(source, "common", name.slice(3))
|
||||
: name.startsWith("@/") ? resolve(source, name.slice(2)) : resolve(parent, name)
|
||||
if (!/\.(?:vue|ts)$/.test(filename)) filename = existsSync(`${filename}.ts`) ? `${filename}.ts` : resolve(filename, "index.ts")
|
||||
if (cache.has(filename)) return cache.get(filename)!.exports
|
||||
const mod = new Module(filename)
|
||||
cache.set(filename, mod)
|
||||
let script = readFileSync(filename, "utf8")
|
||||
if (filename.endsWith(".vue")) script = compileScript(parse(script).descriptor, { id: filename }).content
|
||||
const code = ts.transpileModule(script, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
runInNewContext(code, {
|
||||
module: mod,
|
||||
exports: mod.exports,
|
||||
require: (value: string) => load(value, dirname(filename)),
|
||||
console,
|
||||
Error,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
AbortController,
|
||||
TextDecoder,
|
||||
URL,
|
||||
...globals
|
||||
}, { filename })
|
||||
return mod.exports
|
||||
}
|
||||
return load
|
||||
}
|
||||
@@ -1,12 +1,6 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { createRequire, Module } from "node:module"
|
||||
import { runInNewContext } from "node:vm"
|
||||
import ts from "typescript"
|
||||
import * as vue from "vue"
|
||||
import { compileScript, parse } from "vue/compiler-sfc"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
import { moduleLoader } from "./module-loader.ts"
|
||||
|
||||
interface Version {
|
||||
id: number
|
||||
@@ -15,12 +9,9 @@ interface Version {
|
||||
}
|
||||
|
||||
function setupComponent(component: string, modules: Record<string, unknown>, confirm: () => Promise<unknown> = async () => {}) {
|
||||
const filename = new URL(`../src/pages/${component}`, import.meta.url)
|
||||
const { descriptor } = parse(readFileSync(filename, "utf8"))
|
||||
const script = compileScript(descriptor, { id: component }).content
|
||||
const code = ts.transpileModule(script, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const globals = {
|
||||
ref: vue.ref,
|
||||
shallowRef: vue.shallowRef,
|
||||
reactive: vue.reactive,
|
||||
computed: vue.computed,
|
||||
watch: vue.watch,
|
||||
@@ -31,18 +22,16 @@ function setupComponent(component: string, modules: Record<string, unknown>, con
|
||||
ElMessage: { success() {}, warning() {} },
|
||||
ElMessageBox: { confirm }
|
||||
}
|
||||
const mod = new Module(filename.pathname)
|
||||
const load = (path: string) => path in modules ? modules[path] : require(path)
|
||||
runInNewContext(code, { require: load, module: mod, exports: mod.exports, ...globals }, { filename: component })
|
||||
return mod.exports.default.setup({}, { expose() {} })
|
||||
const load = moduleLoader({ "@/http/axios": { logRequestError() {} }, "@@/composables/useSessionScope": { useSessionScope: () => ({ isCurrent: () => true, assertCurrent() {} }) }, ...modules }, globals)
|
||||
return load(`@/pages/${component}`).default.setup({}, { expose() {} })
|
||||
}
|
||||
|
||||
function skills(api: Record<string, unknown>, confirm?: () => Promise<unknown>) {
|
||||
return setupComponent("skills/index.vue", {
|
||||
"@/apis/skills": api,
|
||||
"@/components/SkillTree.vue": {},
|
||||
"@/components/SkillVersionDrawer.vue": {},
|
||||
"@/utils/markdown": { renderMarkdown: (text: string) => text }
|
||||
"@@/apis/skills": api,
|
||||
"@@/components/SkillTree/index.vue": {},
|
||||
"@@/components/SkillVersionDrawer/index.vue": {},
|
||||
"@@/utils/markdown": { renderMarkdown: (text: string) => text }
|
||||
}, confirm)
|
||||
}
|
||||
|
||||
@@ -52,9 +41,9 @@ async function checkSkillSwitch() {
|
||||
const drafts: { id: number, content: string }[] = []
|
||||
const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }]
|
||||
const page = skills({
|
||||
listSkillTree: async () => nodes,
|
||||
listSkillVersions: (id: number) => id === 1 ? a.promise : b.promise,
|
||||
saveSkillDraft: async (id: number, content: string) => {
|
||||
listSkillTreeApi: async () => nodes,
|
||||
listSkillVersionsApi: (id: number) => id === 1 ? a.promise : b.promise,
|
||||
saveSkillDraftApi: async (id: number, content: string) => {
|
||||
drafts.push({ id, content })
|
||||
return { id: 20, version_no: 2, content }
|
||||
}
|
||||
@@ -79,8 +68,8 @@ async function checkSkillReload() {
|
||||
const old = Promise.withResolvers<Version[]>()
|
||||
let calls = 0
|
||||
const page = skills({
|
||||
listSkillTree: async () => [{ id: 1, kind: "skill" }],
|
||||
listSkillVersions: () => ++calls === 1 ? old.promise : Promise.resolve([{ id: 2, content: "最新" }])
|
||||
listSkillTreeApi: async () => [{ id: 1, kind: "skill" }],
|
||||
listSkillVersionsApi: () => ++calls === 1 ? old.promise : Promise.resolve([{ id: 2, content: "最新" }])
|
||||
})
|
||||
await page.loadTree()
|
||||
page.selectedId.value = 1
|
||||
@@ -96,10 +85,10 @@ async function checkPublishSwitch() {
|
||||
const published: [number, number][] = []
|
||||
const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }]
|
||||
const page = skills({
|
||||
listSkillTree: async () => nodes,
|
||||
listSkillVersions: async (id: number) => [{ id, content: `内容 ${id}` }],
|
||||
saveSkillDraft: () => saved.promise,
|
||||
publishSkillVersion: async (id: number, version: number) => published.push([id, version])
|
||||
listSkillTreeApi: async () => nodes,
|
||||
listSkillVersionsApi: async (id: number) => [{ id, content: `内容 ${id}` }],
|
||||
saveSkillDraftApi: () => saved.promise,
|
||||
publishSkillVersionApi: async (id: number, version: number) => published.push([id, version])
|
||||
})
|
||||
await page.loadTree()
|
||||
await page.select(nodes[0])
|
||||
@@ -115,15 +104,17 @@ async function checkPublishSwitch() {
|
||||
async function checkTickerSwitch() {
|
||||
const btc = Promise.withResolvers<Record<string, string>>()
|
||||
const eth = Promise.withResolvers<Record<string, string>>()
|
||||
const page = setupComponent("market/index.vue", {
|
||||
"@/apis/market": { getTicker: (_: string, symbol: string) => symbol === "BTCUSDT" ? btc.promise : eth.promise },
|
||||
"@/utils/storage": { usePersisted: (_: string, value: unknown) => vue.ref(value) },
|
||||
const load = moduleLoader({
|
||||
"@/http/axios": { logRequestError() {} },
|
||||
"@@/apis/market": { getTickerApi: (_: string, symbol: string) => symbol === "BTCUSDT" ? btc.promise : eth.promise },
|
||||
"@@/utils/storage": { usePersisted: (_: string, value: unknown) => vue.ref(value) },
|
||||
"./chart": { DEFAULT_FAVORITE_INTERVALS: [], INTERVAL_LABELS: {}, MAIN_INDICATORS: [], SUB_INDICATORS: [] },
|
||||
"./useMarketSocket": { useMarketSocket: () => ({ connected: vue.ref(true), subscribe() {} }) },
|
||||
"./components/AddSymbolDialog.vue": {},
|
||||
"./components/SymbolManager.vue": {},
|
||||
"klinecharts": {}
|
||||
})
|
||||
}, { ...vue, onMounted() {}, onBeforeUnmount() {}, setInterval() {} })
|
||||
const page = load("@/pages/market/composables/useMarketChart").useMarketChart()
|
||||
const first = page.loadTicker()
|
||||
page.selection.value = { market: "um", symbol: "ETHUSDT" }
|
||||
await vue.nextTick()
|
||||
@@ -141,9 +132,9 @@ async function checkPublishConfirmationSwitch() {
|
||||
const published: [number, number][] = []
|
||||
const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }]
|
||||
const page = skills({
|
||||
listSkillTree: async () => nodes,
|
||||
listSkillVersions: async (id: number) => [{ id, content: `内容 ${id}`, version_no: 1 }],
|
||||
publishSkillVersion: async (id: number, version: number) => published.push([id, version])
|
||||
listSkillTreeApi: async () => nodes,
|
||||
listSkillVersionsApi: async (id: number) => [{ id, content: `内容 ${id}`, version_no: 1 }],
|
||||
publishSkillVersionApi: async (id: number, version: number) => published.push([id, version])
|
||||
}, () => confirmation.promise)
|
||||
await page.loadTree()
|
||||
await page.select(nodes[0])
|
||||
@@ -157,21 +148,21 @@ async function checkPublishConfirmationSwitch() {
|
||||
|
||||
function contextEditor(api: Record<string, unknown>) {
|
||||
return setupComponent("chat/components/ContextTab.vue", {
|
||||
"@/apis/chat": api,
|
||||
"@/utils/markdown": { renderMarkdown: (text: string) => text },
|
||||
"@/utils/text": { formatTime: (text: string) => text }
|
||||
"@@/apis/chat": api,
|
||||
"@@/utils/markdown": { renderMarkdown: (text: string) => text },
|
||||
"@@/utils/text": { formatTime: (text: string) => text }
|
||||
})
|
||||
}
|
||||
|
||||
async function checkContextRefresh() {
|
||||
const updated = Promise.withResolvers<{ id: number, content: string }>()
|
||||
const api = {
|
||||
getChatContext: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersions: async () => []
|
||||
getChatContextApi: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersionsApi: async () => []
|
||||
}
|
||||
const page = contextEditor(api)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
api.getChatContext = () => updated.promise
|
||||
api.getChatContextApi = () => updated.promise
|
||||
const refresh = page.refreshFromChat()
|
||||
page.content.value = "刷新期间的新输入"
|
||||
updated.resolve({ id: 2, content: "Chat 更新" })
|
||||
@@ -185,9 +176,9 @@ async function checkContextSave() {
|
||||
const saved = Promise.withResolvers<{ id: number, content: string }>()
|
||||
let submitted = ""
|
||||
const page = contextEditor({
|
||||
getChatContext: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersions: async () => [],
|
||||
saveChatContext: (content: string) => {
|
||||
getChatContextApi: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersionsApi: async () => [],
|
||||
saveChatContextApi: (content: string) => {
|
||||
submitted = content
|
||||
return saved.promise
|
||||
}
|
||||
@@ -207,13 +198,13 @@ async function checkContextSave() {
|
||||
async function checkContextReloadAfterSave() {
|
||||
const stale = Promise.withResolvers<{ id: number, content: string }>()
|
||||
const api = {
|
||||
getChatContext: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersions: async () => [],
|
||||
saveChatContext: async (content: string) => ({ id: 2, content })
|
||||
getChatContextApi: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersionsApi: async () => [],
|
||||
saveChatContextApi: async (content: string) => ({ id: 2, content })
|
||||
}
|
||||
const page = contextEditor(api)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
api.getChatContext = () => stale.promise
|
||||
api.getChatContextApi = () => stale.promise
|
||||
const loading = page.load()
|
||||
page.content.value = "已保存修改"
|
||||
await page.save()
|
||||
@@ -227,9 +218,9 @@ async function checkContextReloadAfterSave() {
|
||||
async function checkContextRefreshDuringSave() {
|
||||
const saved = Promise.withResolvers<{ id: number, content: string }>()
|
||||
const page = contextEditor({
|
||||
getChatContext: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersions: async () => [],
|
||||
saveChatContext: () => saved.promise
|
||||
getChatContextApi: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersionsApi: async () => [],
|
||||
saveChatContextApi: () => saved.promise
|
||||
})
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
page.content.value = "提交的修改"
|
||||
@@ -245,15 +236,15 @@ async function checkContextFailedSave() {
|
||||
const saved = Promise.withResolvers<{ id: number, content: string }>()
|
||||
const stale = Promise.withResolvers<{ id: number, content: string }>()
|
||||
const api = {
|
||||
getChatContext: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersions: async () => [],
|
||||
saveChatContext: () => saved.promise
|
||||
getChatContextApi: async () => ({ id: 1, content: "原内容" }),
|
||||
listChatContextVersionsApi: async () => [],
|
||||
saveChatContextApi: () => saved.promise
|
||||
}
|
||||
const page = contextEditor(api)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
page.content.value = "保存失败后应保留的草稿"
|
||||
const saving = page.save()
|
||||
api.getChatContext = () => stale.promise
|
||||
api.getChatContextApi = () => stale.promise
|
||||
const loading = page.load()
|
||||
saved.reject(new Error("保存失败"))
|
||||
await assert.rejects(saving, /保存失败/)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { setImmediate } from "node:timers/promises"
|
||||
import * as vue from "vue"
|
||||
import { parseEvents } from "../src/http/sse.ts"
|
||||
|
||||
import { moduleLoader, require } from "./module-loader.ts"
|
||||
|
||||
const axios = require("axios").default
|
||||
|
||||
function body(parts: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
return new ReadableStream({ start(controller) {
|
||||
for (const part of parts) controller.enqueue(encoder.encode(part))
|
||||
controller.close()
|
||||
} })
|
||||
}
|
||||
|
||||
test("SSE 分块、CRLF、注释、多行 data 与 EOF", async () => {
|
||||
const events = []
|
||||
for await (const event of parseEvents(body([": ping\r", "\nevent: delta\r\ndata: 第一", "行\r\ndata: 第二行\r\n\r", "\nevent: done\ndata: {}"])))
|
||||
events.push(event)
|
||||
assert.deepEqual(events, [{ event: "delta", data: "第一行\n第二行" }, { event: "done", data: "{}" }])
|
||||
})
|
||||
|
||||
function environment(fetcher: typeof fetch, websocket?: unknown, post?: () => Promise<unknown>) {
|
||||
const user = vue.reactive({ token: "old", sessionVersion: 1 })
|
||||
const messages: string[] = []
|
||||
const timers: { run: () => void, delay: number }[] = []
|
||||
const unmount: (() => void)[] = []
|
||||
class ApiError extends Error {
|
||||
code: number
|
||||
constructor(code: number, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@/http/axios": {
|
||||
ApiError,
|
||||
request: post,
|
||||
readEnvelope: (value: any) => {
|
||||
assert.equal(typeof value.code, "number")
|
||||
return value
|
||||
},
|
||||
rejectAuthentication: (version: number) => {
|
||||
if (version === user.sessionVersion) {
|
||||
user.sessionVersion++
|
||||
user.token = ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"element-plus": { ElMessage: { error: (value: string) => messages.push(value) } }
|
||||
}, {
|
||||
...vue,
|
||||
WebSocket: websocket,
|
||||
location: { protocol: "https:", host: "preview.test" },
|
||||
Error,
|
||||
ReadableStream,
|
||||
TextEncoder,
|
||||
fetch: fetcher,
|
||||
onBeforeUnmount: (callback: () => void) => unmount.push(callback),
|
||||
setTimeout: (run: () => void, delay: number) => {
|
||||
timers.push({ run, delay })
|
||||
return timers.length
|
||||
},
|
||||
clearTimeout() {}
|
||||
})
|
||||
return { load, user, messages, timers, unmount }
|
||||
}
|
||||
|
||||
function response(text: string) {
|
||||
return new Response(body([text]), { headers: { "Content-Type": "text/event-stream" } })
|
||||
}
|
||||
|
||||
test("旧流响应不触发回调;Chat POST 失败明确拒绝且不重发", async () => {
|
||||
const pending = Promise.withResolvers<Response>()
|
||||
const env = environment(async () => pending.promise)
|
||||
let called = 0
|
||||
const operation = env.load("@/http/stream").consumeStream("/api/news/stream", {}, 1, () => called++, () => called++)
|
||||
const rejected = assert.rejects(operation, (error: any) => axios.isCancel(error))
|
||||
env.user.sessionVersion++
|
||||
pending.resolve(response("event: news\ndata: {}\n\n"))
|
||||
await rejected
|
||||
assert.equal(called, 0)
|
||||
|
||||
let requests = 0
|
||||
const events: string[] = []
|
||||
const chatEnv = environment(async (_, init) => {
|
||||
requests++
|
||||
assert.equal(init?.method, "POST")
|
||||
assert.equal((init?.headers as Record<string, string>).Authorization, "Bearer old")
|
||||
return response("event: error\ndata: {\"message\":\"模型失败\"}\n\n")
|
||||
})
|
||||
const chat = chatEnv.load("@/pages/chat/composables/useChatStream").useChatStream()
|
||||
await assert.rejects(chat.send({ text: "test" }, (event: { name: string }) => events.push(event.name)), /模型失败/)
|
||||
assert.deepEqual(events, ["error"])
|
||||
assert.equal(requests, 1)
|
||||
assert.equal(chatEnv.timers.length, 0)
|
||||
assert.equal(chat.streaming.value, false)
|
||||
})
|
||||
|
||||
test("只读订阅有限退避重连,403 停止,当前身份保留", async () => {
|
||||
let calls = 0
|
||||
const env = environment(async () => {
|
||||
calls++
|
||||
if (calls < 3) throw new Error("network unavailable")
|
||||
return new Response(JSON.stringify({ code: 403, data: null, message: "权限不足" }), { status: 403 })
|
||||
})
|
||||
const source = env.load("@/http/useEventSource").useEventSource(env.load("@@/apis/news").subscribeNewsApi, () => {})
|
||||
await setImmediate()
|
||||
assert.equal(env.timers[0].delay, 1000)
|
||||
env.timers[0].run()
|
||||
await setImmediate()
|
||||
assert.equal(env.timers[1].delay, 2000)
|
||||
env.timers[1].run()
|
||||
await setImmediate()
|
||||
assert.equal(env.timers.length, 2)
|
||||
assert.equal(env.user.token, "old")
|
||||
assert.equal(source.connected.value, false)
|
||||
assert.deepEqual(env.messages, ["network unavailable", "权限不足"])
|
||||
})
|
||||
|
||||
test("服务端 auth_error 清理当前身份;换会话立即 abort 旧 Chat", async () => {
|
||||
const env = environment(async () => response("event: auth_error\ndata: {\"code\":401,\"message\":\"已注销\"}\n\n"))
|
||||
env.load("@/http/useEventSource").useEventSource(env.load("@@/apis/news").subscribeNewsApi, () => assert.fail("不能继续推送"))
|
||||
await setImmediate()
|
||||
assert.equal(env.user.token, "")
|
||||
assert.equal(env.timers.length, 0)
|
||||
let signal: AbortSignal | undefined
|
||||
const pending = Promise.withResolvers<Response>()
|
||||
const chatEnv = environment(async (_, init) => {
|
||||
signal = init?.signal as AbortSignal
|
||||
return pending.promise
|
||||
})
|
||||
const chat = chatEnv.load("@/pages/chat/composables/useChatStream").useChatStream()
|
||||
const sending = chat.send({}, () => assert.fail("旧会话不能回调"))
|
||||
const rejected = assert.rejects(sending, (error: any) => axios.isCancel(error))
|
||||
chatEnv.user.sessionVersion++
|
||||
assert.equal(signal?.aborted, true)
|
||||
pending.resolve(response("event: done\ndata: {}\n\n"))
|
||||
await rejected
|
||||
})
|
||||
|
||||
test("行情重连申请新票据,丢弃旧连接消息,切换会话关闭连接", async () => {
|
||||
const sockets: FakeSocket[] = []
|
||||
class FakeSocket {
|
||||
static OPEN = 1
|
||||
readyState = 0
|
||||
sent: unknown[] = []
|
||||
onopen = () => {}
|
||||
onmessage = (_: any) => {}
|
||||
onclose = (_: any) => {}
|
||||
constructor(url: string, protocols: string[]) {
|
||||
assert.equal(url, "wss://preview.test/api/market/ws")
|
||||
assert.equal(protocols[0], "alphapulse.market.v1")
|
||||
assert.equal(protocols[1], `ticket.ticket-${sockets.length + 1}`)
|
||||
sockets.push(this)
|
||||
}
|
||||
|
||||
send(value: string) {
|
||||
this.sent.push(JSON.parse(value))
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 3
|
||||
}
|
||||
}
|
||||
let tickets = 0
|
||||
const env = environment(async () => assert.fail("不使用 fetch 连接 WS"), FakeSocket, async () => ({ ticket: `ticket-${++tickets}`, expires_in: 30 }))
|
||||
const prices: unknown[] = []
|
||||
const stream = env.load("@/pages/market/composables/useMarketSocket").useMarketSocket(() => {}, (price: unknown) => prices.push(price))
|
||||
stream.subscribe({ market: "um", symbol: "BTCUSDT", interval: "15m" })
|
||||
await setImmediate()
|
||||
sockets[0].readyState = 1
|
||||
sockets[0].onopen()
|
||||
assert.equal(stream.connected.value, true)
|
||||
assert.equal((sockets[0].sent[0] as any).symbol, "BTCUSDT")
|
||||
sockets[0].onclose({ code: 1011 })
|
||||
env.timers[0].run()
|
||||
await setImmediate()
|
||||
assert.equal(tickets, 2)
|
||||
sockets[0].onmessage({ data: JSON.stringify({ type: "ticker", market: "um", symbol: "BTCUSDT", data: 1 }) })
|
||||
sockets[1].onmessage({ data: JSON.stringify({ type: "ticker", market: "um", symbol: "BTCUSDT", data: 2 }) })
|
||||
assert.deepEqual(prices, [2])
|
||||
env.user.sessionVersion++
|
||||
assert.equal(sockets[1].readyState, 3)
|
||||
assert.equal(stream.connected.value, false)
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { setImmediate } from "node:timers/promises"
|
||||
import * as vue from "vue"
|
||||
import { moduleLoader } from "./module-loader.ts"
|
||||
|
||||
const globals = { ...vue, onMounted() {}, onBeforeUnmount() {}, onBeforeRouteLeave() {}, ElMessage: { success() {}, warning() {} } }
|
||||
|
||||
const tradingStatus = (limit = 120) => ({ exchange: "bybit", configured: true, provider_id: null, reasoning_effort: null, snapshot: null, today: { analyses: 10, limit, input_tokens: 0, output_tokens: 0 } })
|
||||
|
||||
test("交易配置草稿抵抗轮询、仅保存改动字段;失败保留草稿并拒绝重复提交", async () => {
|
||||
const pending = Promise.withResolvers<unknown>()
|
||||
const bodies: unknown[] = []
|
||||
let fail = false
|
||||
const props = vue.reactive({ status: tradingStatus(), providers: [] })
|
||||
const load = moduleLoader({ "@@/apis/trading": { patchConfigApi: (body: unknown) => {
|
||||
bodies.push(body)
|
||||
return fail ? Promise.reject(new Error("保存失败")) : pending.promise
|
||||
} } }, globals)
|
||||
const emitted: unknown[] = []
|
||||
const page = load("@/pages/trading/components/TradingConfig.vue").default.setup(props, { expose() {}, emit: (...args: unknown[]) => emitted.push(args) })
|
||||
page.formRef.value = { validate: async () => true, clearValidate() {} }
|
||||
page.form.daily_analysis_limit = 200
|
||||
props.status = tradingStatus(150)
|
||||
await vue.nextTick()
|
||||
assert.equal(page.form.daily_analysis_limit, 200)
|
||||
const saving = page.save()
|
||||
await page.save()
|
||||
await setImmediate()
|
||||
assert.equal(bodies.length, 1)
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(bodies[0])), { daily_analysis_limit: 200 })
|
||||
pending.resolve(tradingStatus(200))
|
||||
await saving
|
||||
assert.equal(page.dirty.value, false)
|
||||
assert.equal(emitted.length, 1)
|
||||
fail = true
|
||||
page.form.provider_id = 2
|
||||
await assert.rejects(page.save(), /保存失败/)
|
||||
assert.equal(page.form.provider_id, 2)
|
||||
assert.equal(page.dirty.value, true)
|
||||
assert.equal(page.saving.value, false)
|
||||
page.reset()
|
||||
assert.equal(page.form.daily_analysis_limit, 150)
|
||||
})
|
||||
|
||||
test("模型表单按请求字段编辑;校验失败不请求,保存失败保留输入和弹窗", async () => {
|
||||
let calls = 0
|
||||
const load = moduleLoader({
|
||||
"@@/apis/settings": { patchProviderApi: async () => {
|
||||
calls++
|
||||
throw new Error("保存失败")
|
||||
} },
|
||||
"@@/composables/useSessionScope": { useSessionScope: () => ({ assertCurrent() {} }) }
|
||||
}, globals)
|
||||
const page = load("@/pages/settings/components/ProviderSettings.vue").default.setup({}, { expose() {} })
|
||||
page.openEdit({ id: 1, name: "test", protocol: "openai", base_url: "https://example.test", api_key_hint: "abcd", model: "model", max_tokens: 8192, context_window: 200000, reasoning_effort: null, is_default: false, updated_at: "now" })
|
||||
assert.equal(page.form.value.api_key, "")
|
||||
assert.equal("api_key_hint" in page.form.value, false)
|
||||
assert.equal("updated_at" in page.form.value, false)
|
||||
page.formRef.value = { validate: async () => {
|
||||
throw new Error("校验失败")
|
||||
} }
|
||||
await assert.rejects(page.save(), /校验失败/)
|
||||
assert.equal(calls, 0)
|
||||
page.formRef.value = { validate: async () => true }
|
||||
page.form.value.name = "new-name"
|
||||
await assert.rejects(page.save(), /保存失败/)
|
||||
assert.equal(page.dialogVisible.value, true)
|
||||
assert.equal(page.form.value.name, "new-name")
|
||||
assert.equal(page.saving.value, false)
|
||||
})
|
||||
|
||||
test("新闻搜索/页容量重置只有一次请求;失败区别于空数据且旧响应不覆盖新查询", async () => {
|
||||
const queries: { page: number }[] = []
|
||||
const pending: ReturnType<typeof Promise.withResolvers>[] = []
|
||||
const load = moduleLoader({
|
||||
"@@/apis/news": { listNewsApi: (query: { page: number }) => {
|
||||
queries.push(query)
|
||||
const item = Promise.withResolvers()
|
||||
pending.push(item)
|
||||
return item.promise
|
||||
} },
|
||||
"@@/composables/useSourceNames": { useSourceNames: () => vue.ref({}) },
|
||||
"@@/utils/storage": { usePersisted: (_: string, value: unknown) => vue.ref(value) },
|
||||
"@/http/useEventSource": { useEventSource: () => ({ connected: vue.ref(false) }) }
|
||||
}, globals)
|
||||
const page = load("@/pages/news/composables/useNewsFeed").useNewsFeed()
|
||||
const first = page.load()
|
||||
const next = page.load()
|
||||
pending[1].resolve({ items: [{ id: 2 }], total: 1 })
|
||||
await next
|
||||
pending[0].resolve({ items: [{ id: 1 }], total: 1 })
|
||||
await first
|
||||
assert.equal(page.items.value[0].id, 2)
|
||||
page.page.value = 3
|
||||
await vue.nextTick()
|
||||
pending[2].resolve({ items: [], total: 0 })
|
||||
await setImmediate()
|
||||
page.search()
|
||||
await vue.nextTick()
|
||||
assert.equal(queries.length, 4)
|
||||
assert.equal(queries[3].page, 1)
|
||||
pending[3].resolve({ items: [], total: 0 })
|
||||
await setImmediate()
|
||||
page.page.value = 3
|
||||
await vue.nextTick()
|
||||
pending[4].resolve({ items: [], total: 0 })
|
||||
await setImmediate()
|
||||
page.pageSize.value = 50
|
||||
await vue.nextTick()
|
||||
assert.equal(queries.length, 6)
|
||||
assert.equal(queries[5].page, 1)
|
||||
pending[5].resolve({ items: [], total: 0 })
|
||||
await setImmediate()
|
||||
const failing = page.load()
|
||||
pending[6].reject(new Error("读取失败"))
|
||||
await assert.rejects(failing, /读取失败/)
|
||||
assert.equal(page.failed.value, true)
|
||||
})
|
||||
|
||||
test("交易确认绑定发起币种;交易所或会话切换后拒绝旧操作", async () => {
|
||||
let confirm = Promise.withResolvers<void>()
|
||||
let active = true
|
||||
let exchange = "bybit"
|
||||
const writes: unknown[] = []
|
||||
const data = {
|
||||
status: vue.ref(tradingStatus()),
|
||||
symbols: vue.ref([]),
|
||||
selected: vue.ref("BTCUSDT"),
|
||||
events: vue.ref([]),
|
||||
eventsExhausted: vue.ref(true),
|
||||
latestAnalysis: vue.ref(null),
|
||||
livePlan: vue.ref(null),
|
||||
planOrders: vue.ref([]),
|
||||
current: vue.ref(null),
|
||||
loading: vue.ref(false),
|
||||
failed: vue.ref(false),
|
||||
refresh: async () => {},
|
||||
refreshEvents: async () => {},
|
||||
loadMoreEvents: async () => {}
|
||||
}
|
||||
const children = Object.fromEntries(["AddSymbolDialog", "AnalysisDrawer", "NewsSignalDrawer", "PlanCard", "PositionHistoryDrawer", "TradingConfig"].map(name => [`./components/${name}.vue`, {}]))
|
||||
const load = moduleLoader({
|
||||
...children,
|
||||
"./composables/useTradingData": { useTradingData: () => data },
|
||||
"@@/apis/settings": {},
|
||||
"@@/apis/trading": {
|
||||
getStatusApi: async () => ({ ...tradingStatus(), exchange }),
|
||||
manualActionApi: async (...args: unknown[]) => writes.push(args)
|
||||
},
|
||||
"@@/composables/useSessionScope": { useSessionScope: () => ({ assertCurrent() {
|
||||
if (!active) throw new Error("会话已切换")
|
||||
} }) }
|
||||
}, { ...globals, ElMessageBox: { confirm: () => confirm.promise } })
|
||||
const page = load("@/pages/trading/index.vue").default.setup({}, { expose() {} })
|
||||
const first = page.clearOrders()
|
||||
data.selected.value = "ETHUSDT"
|
||||
confirm.resolve()
|
||||
await first
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(writes)), [["BTCUSDT", "cancel"]])
|
||||
confirm = Promise.withResolvers<void>()
|
||||
const second = page.clearOrders()
|
||||
exchange = "binance"
|
||||
confirm.resolve()
|
||||
await assert.rejects(second, /交易所已切换/)
|
||||
assert.equal(writes.length, 1)
|
||||
exchange = "bybit"
|
||||
confirm = Promise.withResolvers<void>()
|
||||
const third = page.closePosition("BTCUSDT")
|
||||
active = false
|
||||
confirm.resolve()
|
||||
await assert.rejects(third, /会话已切换/)
|
||||
assert.equal(writes.length, 1)
|
||||
})
|
||||
+2
-1
@@ -7,6 +7,7 @@
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"paths": {
|
||||
"@@/*": ["./src/common/*"],
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
@@ -17,6 +18,6 @@
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "types/**/*.d.ts", "*.ts"],
|
||||
"include": ["tests/**/*.ts", "src/**/*.ts", "src/**/*.vue", "types/**/*.d.ts", "*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
File renamed without changes.
@@ -44,6 +44,7 @@ declare module 'vue' {
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElResult: typeof import('element-plus/es')['ElResult']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
@@ -58,8 +59,6 @@ declare module 'vue' {
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SkillTree: typeof import('./../src/components/SkillTree.vue')['default']
|
||||
SkillVersionDrawer: typeof import('./../src/components/SkillVersionDrawer.vue')['default']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user