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
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
export interface ProcessStatus {
|
||||
state: "running" | "stopped" | "restarting"
|
||||
pid: number | null
|
||||
started_at: string | null
|
||||
next_run_at: string
|
||||
}
|
||||
|
||||
export interface SourceInfo {
|
||||
code: string
|
||||
name: string
|
||||
region_code: string | null
|
||||
list_url: string
|
||||
enabled: boolean
|
||||
interval_seconds: number
|
||||
backfill_hours: number
|
||||
consecutive_failures: number
|
||||
last_success_at: string | null
|
||||
last_error: string | null
|
||||
paused_until: string | null
|
||||
supports_history: boolean
|
||||
history_target: string | null
|
||||
history_position: string | null
|
||||
history_done: boolean
|
||||
process: ProcessStatus | null
|
||||
today_new: number
|
||||
today_revised: number
|
||||
total_items: number
|
||||
}
|
||||
|
||||
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
|
||||
run_id: string | null
|
||||
ts: string
|
||||
level: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface SourcePatch {
|
||||
enabled?: boolean
|
||||
interval_seconds?: number
|
||||
/** ISO 时间表示回补到该时间;null 表示取消回补 */
|
||||
history_target?: string | null
|
||||
}
|
||||
|
||||
export type PatchSourceResponseData = null
|
||||
|
||||
export type ListLogsResponseData = CrawlLog[]
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface NewsBrief {
|
||||
id: number
|
||||
source_code: string
|
||||
url: string
|
||||
title: string
|
||||
summary: string
|
||||
excerpt: string
|
||||
author: string | null
|
||||
tags: string[]
|
||||
is_paywalled: boolean
|
||||
published_at: string
|
||||
first_seen_at: string
|
||||
revision_no: number
|
||||
}
|
||||
|
||||
export interface 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
|
||||
last_seen_at: string
|
||||
extra: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface Revision {
|
||||
revision_no: number
|
||||
title: string
|
||||
summary: string
|
||||
content_html: string
|
||||
captured_at: string
|
||||
}
|
||||
|
||||
export interface NewsEvent {
|
||||
id: number
|
||||
source_code: string
|
||||
kind: "new" | "revised"
|
||||
}
|
||||
|
||||
export type GetNewsResponseData = NewsDetail
|
||||
|
||||
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"
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import type { Effort } from "../settings/type"
|
||||
|
||||
/** 以下数值字段都是交易所返回的十进制字符串,避免浮点误差 */
|
||||
export interface SnapshotPosition {
|
||||
symbol: string
|
||||
side: "LONG" | "SHORT"
|
||||
amount: string
|
||||
entry_price: string
|
||||
break_even_price: string
|
||||
mark_price: string
|
||||
unrealized_pnl: string
|
||||
liquidation_price: string
|
||||
notional: string
|
||||
margin: string
|
||||
leverage: number
|
||||
margin_type: string
|
||||
}
|
||||
|
||||
export interface SnapshotOrder {
|
||||
symbol: string
|
||||
client_id: string
|
||||
side: "BUY" | "SELL"
|
||||
type: string
|
||||
price: string
|
||||
quantity: string
|
||||
filled: string
|
||||
reduce_only: boolean
|
||||
time: number
|
||||
}
|
||||
|
||||
export interface SnapshotAlgoOrder {
|
||||
symbol: string
|
||||
client_id: string
|
||||
side: "BUY" | "SELL"
|
||||
type: string
|
||||
trigger_price: string
|
||||
quantity: string
|
||||
close_position: boolean
|
||||
reduce_only: boolean
|
||||
time: number
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
exchange: "binance" | "bybit"
|
||||
updated_at: string
|
||||
/** 最近一次成功同步交易所的时间 */
|
||||
synced_at: string | null
|
||||
error: string | null
|
||||
account: { wallet_balance: string, margin_balance: string, available_balance: string, unrealized_pnl: string } | null
|
||||
positions: SnapshotPosition[]
|
||||
orders: SnapshotOrder[]
|
||||
algo_orders: SnapshotAlgoOrder[]
|
||||
}
|
||||
|
||||
export interface TradingStatus {
|
||||
exchange: "binance" | "bybit"
|
||||
configured: boolean
|
||||
provider_id: number | null
|
||||
reasoning_effort: Effort | null
|
||||
/** trader 进程离线时为空 */
|
||||
snapshot: Snapshot | null
|
||||
today: { analyses: number, limit: number, input_tokens: number, output_tokens: number }
|
||||
}
|
||||
|
||||
export interface TradingSymbol {
|
||||
symbol: string
|
||||
added_at: string
|
||||
analysis_requested_at: string | null
|
||||
analyzing: boolean
|
||||
/** 该币是否在交易;停止时分析为试运行,不挂新的入场单 */
|
||||
running: boolean
|
||||
running_changed_at: string | null
|
||||
/** 该币的交易资金,十进制字符串;未设置时不能开始交易 */
|
||||
capital_usdt: string | null
|
||||
/** 实际用于仓位计算的资金:min(交易资金, 账户权益) */
|
||||
effective_capital: string | null
|
||||
/** 执行器正在管理的计划 */
|
||||
plan_id: number | null
|
||||
plan_status: "pending" | "open" | "closing" | null
|
||||
close_deadline: string | null
|
||||
}
|
||||
|
||||
export interface SymbolOption {
|
||||
symbol: string
|
||||
base: string
|
||||
}
|
||||
|
||||
export interface TradingEvent {
|
||||
id: number
|
||||
created_at: string
|
||||
symbol: string | null
|
||||
analysis_id: number | null
|
||||
kind: string
|
||||
level: "info" | "warn" | "error"
|
||||
message: string
|
||||
data: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export type GetStatusResponseData = TradingStatus
|
||||
|
||||
export type StartTradingResponseData = null
|
||||
|
||||
export type StopTradingResponseData = null
|
||||
|
||||
export type PatchSymbolResponseData = null
|
||||
|
||||
export interface PatchSymbolBodyRequestData { capital_usdt: string | null }
|
||||
|
||||
export type PatchConfigResponseData = TradingStatus
|
||||
|
||||
export interface PatchConfigBodyRequestData { provider_id?: number | null, reasoning_effort?: Effort | null, daily_analysis_limit?: number }
|
||||
|
||||
export type ListSymbolOptionsResponseData = SymbolOption[]
|
||||
|
||||
export type ListSymbolsResponseData = TradingSymbol[]
|
||||
|
||||
export type AddSymbolResponseData = null
|
||||
|
||||
export type DeleteSymbolResponseData = null
|
||||
|
||||
export type ListEventsResponseData = TradingEvent[]
|
||||
|
||||
export interface ListEventsParamsRequestData { symbol?: string, before?: number, limit?: number }
|
||||
|
||||
export interface PlanCondition {
|
||||
direction: "above" | "below"
|
||||
price: string
|
||||
timeframe: "mark" | "1m" | "5m" | "15m" | "1h" | "4h"
|
||||
basis: string
|
||||
}
|
||||
|
||||
/** submit_trade_plan 的参数原样保存;数值为字符串 */
|
||||
export interface TradeDecision {
|
||||
action: "plan" | "keep" | "exit" | "wait"
|
||||
summary: string
|
||||
reasons: string
|
||||
risks: string
|
||||
reevaluate: PlanCondition[]
|
||||
direction: "long" | "short" | null
|
||||
confidence: "high" | "medium" | "low" | null
|
||||
/** 区间与挂单价;更早格式的分析没有这个字段 */
|
||||
setup?: { zone_low: string, zone_high: string, entry_price?: string, basis: string } | null
|
||||
valid_hours?: string | null
|
||||
}
|
||||
|
||||
/** 程序按止损和交易资金算出的数量与风险 */
|
||||
export interface EntrySizing {
|
||||
price: string
|
||||
stop: string
|
||||
stop_pct: string
|
||||
quantity: string
|
||||
notional: string
|
||||
leverage: string
|
||||
risk_amount: string
|
||||
risk_at_stop: string
|
||||
risk_pct: string
|
||||
capped_by_leverage: boolean
|
||||
}
|
||||
|
||||
export interface Sizing {
|
||||
direction: "long" | "short"
|
||||
setup: { zone_low: string, zone_high: string }
|
||||
/** 限价、止损和数量 */
|
||||
entry: EntrySizing
|
||||
atr: string
|
||||
position_qty: string
|
||||
}
|
||||
|
||||
export interface Analysis {
|
||||
id: number
|
||||
symbol: string
|
||||
reasons: string[]
|
||||
dry_run: boolean
|
||||
status: "running" | "done" | "failed"
|
||||
action: TradeDecision["action"] | null
|
||||
decision: TradeDecision | null
|
||||
sizing: Sizing | null
|
||||
error: string | null
|
||||
model: string | null
|
||||
reasoning_effort: string | null
|
||||
usage: { input_tokens: number, output_tokens: number } | null
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
plan_status: PlanStatus | null
|
||||
valid_until: string | null
|
||||
first_fill_at: string | null
|
||||
close_deadline: string | null
|
||||
closed_at: string | null
|
||||
close_reason: string | null
|
||||
result: { realized_pnl: string, commission: string, net: string } | null
|
||||
}
|
||||
|
||||
/** 与协议无关的消息格式,同 Chat */
|
||||
export type TranscriptMessage
|
||||
= | { role: "user", text: string }
|
||||
| { role: "assistant", text: string, tool_calls: { id: string, name: string, arguments: Record<string, unknown> }[] }
|
||||
| { role: "tool", tool_call_id: string, name: string, result: string }
|
||||
|
||||
export interface AnalysisDetail extends Analysis {
|
||||
text: string | null
|
||||
transcript: TranscriptMessage[] | null
|
||||
}
|
||||
|
||||
export type PlanStatus = "proposed" | "pending" | "open" | "closing" | "closed" | "canceled" | "replaced" | "stale" | "applied"
|
||||
|
||||
/** 执行器的订单流水 */
|
||||
export interface OrderRow {
|
||||
id: number
|
||||
purpose: "entry" | "stop" | "tp" | "close"
|
||||
leg: number | null
|
||||
side: "BUY" | "SELL"
|
||||
type: string
|
||||
price: string | null
|
||||
trigger_price: string | null
|
||||
quantity: string
|
||||
status: string
|
||||
filled_qty: string
|
||||
avg_price: string | null
|
||||
error: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 由当前交易所成交记录还原的已平仓仓位(含程序外的手动仓位);数值为十进制字符串 */
|
||||
export interface PositionHistory {
|
||||
symbol: string
|
||||
direction: "long" | "short"
|
||||
opened_at: string
|
||||
closed_at: string
|
||||
quantity: string
|
||||
entry_price: string
|
||||
exit_price: string
|
||||
realized_pnl: string
|
||||
commission: string
|
||||
net: string
|
||||
source: "program" | "manual"
|
||||
/** 手动仓位没有关联计划,为空 */
|
||||
analysis_id: number | null
|
||||
close_reason: string | null
|
||||
}
|
||||
|
||||
export interface PositionHistoryQuery {
|
||||
symbol?: string
|
||||
/** 按平仓时间筛选,ISO 时间 */
|
||||
start?: string
|
||||
end?: string
|
||||
outcome?: "profit" | "loss"
|
||||
source?: "program" | "manual"
|
||||
page: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
export interface PositionHistoryPage {
|
||||
/** 按筛选条件统计全部仓位 */
|
||||
summary: { count: number, wins: number, net: string, commission: string }
|
||||
total: number
|
||||
items: PositionHistory[]
|
||||
}
|
||||
|
||||
export type ListPositionHistoryResponseData = PositionHistoryPage
|
||||
|
||||
export type ListPositionHistorySymbolsResponseData = string[]
|
||||
|
||||
export type SignalHorizon = "1h" | "4h"
|
||||
|
||||
/** 新闻信号前向记录:按模型标注方向(利多做多、利空做空)交易的统计;收益单位 bp */
|
||||
export interface NewsSignalStat {
|
||||
category: "price_report" | "event" | "irrelevant"
|
||||
symbol: string
|
||||
horizon: SignalHorizon
|
||||
n: number
|
||||
mean_bp: number | null
|
||||
t: number | null
|
||||
/** 扣除来回手续费后的平均收益 */
|
||||
net_bp: number | null
|
||||
win_rate: number | null
|
||||
}
|
||||
|
||||
export interface NewsSignalItem {
|
||||
news_id: number
|
||||
published_at: string
|
||||
source_code: string
|
||||
title: string
|
||||
category: NewsSignalStat["category"]
|
||||
direction: "bullish" | "bearish" | "neutral"
|
||||
/** 进场后的原始涨跌(bp,未按方向取正负);发布未满 4 小时为空 */
|
||||
returns: Record<string, Record<SignalHorizon, number>> | null
|
||||
}
|
||||
|
||||
export interface NewsSignals {
|
||||
/** 前向记录的起点:之后发布的新闻才计入 */
|
||||
start: string
|
||||
labeled: number
|
||||
filled: number
|
||||
stats: NewsSignalStat[]
|
||||
recent: NewsSignalItem[]
|
||||
}
|
||||
|
||||
export type GetNewsSignalsResponseData = NewsSignals
|
||||
|
||||
export interface GetSymbolPlanResponseData { plan: Analysis | null, orders: OrderRow[] }
|
||||
|
||||
export type RequestAnalysisResponseData = null
|
||||
|
||||
export type ManualActionResponseData = null
|
||||
|
||||
export type ListAnalysesResponseData = Analysis[]
|
||||
|
||||
export interface ListAnalysesParamsRequestData { symbol?: string, limit?: number }
|
||||
|
||||
export type GetAnalysisResponseData = AnalysisDetail
|
||||
@@ -0,0 +1,201 @@
|
||||
/* 浅色主题(Stitch「Daylight」稿):色值只在这里定义,页面里用 Element 变量或 UnoCSS 色板类 */
|
||||
:root {
|
||||
--ap-font-sans:
|
||||
"Inter Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
--ap-font-mono:
|
||||
"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Consolas, "PingFang SC", "Microsoft YaHei",
|
||||
monospace;
|
||||
|
||||
--el-font-family: var(--ap-font-sans);
|
||||
--el-border-radius-base: 4px;
|
||||
|
||||
--el-color-primary: #006194;
|
||||
--el-color-success: #16a34a;
|
||||
--el-color-warning: #d97706;
|
||||
--el-color-danger: #dc2626;
|
||||
--el-color-error: #dc2626;
|
||||
--el-color-info: #64748b;
|
||||
|
||||
--el-text-color-primary: #0f172a;
|
||||
--el-text-color-regular: #334155;
|
||||
--el-text-color-secondary: #64748b;
|
||||
--el-text-color-placeholder: #94a3b8;
|
||||
--el-border-color: #e2e8f0;
|
||||
--el-border-color-light: #e2e8f0;
|
||||
--el-border-color-lighter: #f1f5f9;
|
||||
--el-border-color-extra-light: #f8fafc;
|
||||
--el-border-color-dark: #cbd5e1;
|
||||
--el-border-color-darker: #94a3b8;
|
||||
--el-fill-color: #f1f5f9;
|
||||
--el-fill-color-light: #f8fafc;
|
||||
--el-fill-color-lighter: #f8fafc;
|
||||
--el-fill-color-extra-light: #fcfdfe;
|
||||
--el-fill-color-dark: #e2e8f0;
|
||||
--el-fill-color-darker: #cbd5e1;
|
||||
--el-bg-color-page: #f8fafc;
|
||||
--el-box-shadow-lighter: 0 1px 3px 0 rgb(15 23 42 / 3%), 0 1px 2px -1px rgb(15 23 42 / 2%);
|
||||
--el-box-shadow-light: 0 4px 6px -1px rgb(15 23 42 / 5%), 0 2px 4px -2px rgb(15 23 42 / 3%);
|
||||
}
|
||||
|
||||
/* Element 的深浅档按与白色、黑色的混合比例生成,与它的 SCSS 算法一致 */
|
||||
:root {
|
||||
--el-color-primary-light-3: color-mix(in srgb, var(--el-color-primary), white 30%);
|
||||
--el-color-primary-light-5: color-mix(in srgb, var(--el-color-primary), white 50%);
|
||||
--el-color-primary-light-7: color-mix(in srgb, var(--el-color-primary), white 70%);
|
||||
--el-color-primary-light-8: color-mix(in srgb, var(--el-color-primary), white 80%);
|
||||
--el-color-primary-light-9: color-mix(in srgb, var(--el-color-primary), white 90%);
|
||||
--el-color-primary-dark-2: color-mix(in srgb, var(--el-color-primary), black 20%);
|
||||
--el-color-success-light-3: color-mix(in srgb, var(--el-color-success), white 30%);
|
||||
--el-color-success-light-5: color-mix(in srgb, var(--el-color-success), white 50%);
|
||||
--el-color-success-light-7: color-mix(in srgb, var(--el-color-success), white 70%);
|
||||
--el-color-success-light-8: color-mix(in srgb, var(--el-color-success), white 80%);
|
||||
--el-color-success-light-9: color-mix(in srgb, var(--el-color-success), white 90%);
|
||||
--el-color-success-dark-2: color-mix(in srgb, var(--el-color-success), black 20%);
|
||||
--el-color-warning-light-3: color-mix(in srgb, var(--el-color-warning), white 30%);
|
||||
--el-color-warning-light-5: color-mix(in srgb, var(--el-color-warning), white 50%);
|
||||
--el-color-warning-light-7: color-mix(in srgb, var(--el-color-warning), white 70%);
|
||||
--el-color-warning-light-8: color-mix(in srgb, var(--el-color-warning), white 80%);
|
||||
--el-color-warning-light-9: color-mix(in srgb, var(--el-color-warning), white 90%);
|
||||
--el-color-warning-dark-2: color-mix(in srgb, var(--el-color-warning), black 20%);
|
||||
--el-color-danger-light-3: color-mix(in srgb, var(--el-color-danger), white 30%);
|
||||
--el-color-danger-light-5: color-mix(in srgb, var(--el-color-danger), white 50%);
|
||||
--el-color-danger-light-7: color-mix(in srgb, var(--el-color-danger), white 70%);
|
||||
--el-color-danger-light-8: color-mix(in srgb, var(--el-color-danger), white 80%);
|
||||
--el-color-danger-light-9: color-mix(in srgb, var(--el-color-danger), white 90%);
|
||||
--el-color-danger-dark-2: color-mix(in srgb, var(--el-color-danger), black 20%);
|
||||
--el-color-error-light-3: color-mix(in srgb, var(--el-color-error), white 30%);
|
||||
--el-color-error-light-5: color-mix(in srgb, var(--el-color-error), white 50%);
|
||||
--el-color-error-light-7: color-mix(in srgb, var(--el-color-error), white 70%);
|
||||
--el-color-error-light-8: color-mix(in srgb, var(--el-color-error), white 80%);
|
||||
--el-color-error-light-9: color-mix(in srgb, var(--el-color-error), white 90%);
|
||||
--el-color-error-dark-2: color-mix(in srgb, var(--el-color-error), black 20%);
|
||||
--el-color-info-light-3: color-mix(in srgb, var(--el-color-info), white 30%);
|
||||
--el-color-info-light-5: color-mix(in srgb, var(--el-color-info), white 50%);
|
||||
--el-color-info-light-7: color-mix(in srgb, var(--el-color-info), white 70%);
|
||||
--el-color-info-light-8: color-mix(in srgb, var(--el-color-info), white 80%);
|
||||
--el-color-info-light-9: color-mix(in srgb, var(--el-color-info), white 90%);
|
||||
--el-color-info-dark-2: color-mix(in srgb, var(--el-color-info), black 20%);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--el-text-color-regular);
|
||||
font-family: var(--ap-font-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: var(--el-bg-color-page);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 4px;
|
||||
background: var(--el-border-color-dark);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--el-border-color-darker);
|
||||
}
|
||||
|
||||
/* 面板:白底、细边框、8px 圆角,头部是带分隔线的标题条 */
|
||||
.el-card {
|
||||
--el-card-border-radius: 8px;
|
||||
--el-card-padding: 12px;
|
||||
}
|
||||
.el-card.is-never-shadow {
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
}
|
||||
.el-card__header {
|
||||
padding: 10px 12px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-header-bg-color: var(--el-fill-color-light);
|
||||
--el-table-header-text-color: var(--el-text-color-secondary);
|
||||
--el-table-row-hover-bg-color: var(--el-fill-color-light);
|
||||
}
|
||||
.el-table th.el-table__cell {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Markdown 渲染(Chat 回复、skill 预览、CONTEXT.md 预览) */
|
||||
.markdown-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
word-break: break-word;
|
||||
}
|
||||
.markdown-body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4 {
|
||||
margin: 1em 0 0.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.markdown-body h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.markdown-body h2 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.markdown-body h3 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.markdown-body p,
|
||||
.markdown-body ul,
|
||||
.markdown-body ol,
|
||||
.markdown-body pre,
|
||||
.markdown-body table,
|
||||
.markdown-body blockquote {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.markdown-body code {
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 3px;
|
||||
background: var(--el-fill-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.markdown-body pre {
|
||||
padding: 0.6em 0.8em;
|
||||
overflow: auto;
|
||||
border-radius: 4px;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
.markdown-body pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
.markdown-body blockquote {
|
||||
padding-left: 0.8em;
|
||||
border-left: 3px solid var(--el-border-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.markdown-body table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
padding: 0.3em 0.6em;
|
||||
border: 1px solid var(--el-border-color);
|
||||
}
|
||||
.markdown-body th {
|
||||
color: var(--el-text-color-primary);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts" setup>
|
||||
import { SwitchButton, TrendCharts } from "@element-plus/icons-vue"
|
||||
import { useUserStore } from "@/pinia/stores/user"
|
||||
import { menuRoutes } from "@/router/routes"
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
async function logout() {
|
||||
await userStore.logout()
|
||||
router.replace({ name: "login" })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<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="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="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>
|
||||
{{ userStore.username }}
|
||||
</div>
|
||||
<el-button link :icon="SwitchButton" @click="logout">
|
||||
退出登录
|
||||
</el-button>
|
||||
</header>
|
||||
<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;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.nav-link.active {
|
||||
border-bottom-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<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 { createSkillNodeApi, deleteSkillNodeApi, moveSkillNodeApi, patchSkillNodeApi } from "@@/apis/skills"
|
||||
import { useSessionScope } from "@@/composables/useSessionScope"
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: SkillNode[]
|
||||
editable?: boolean
|
||||
selectedId?: number | null
|
||||
/** 需要高亮的节点(如 Chat 刚生成草稿的 skill) */
|
||||
highlightIds?: number[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [node: SkillNode]
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
/** skills 树:Skills 页可编辑(拖拽、增删改),Chat 侧边栏只读 */
|
||||
const sessionScope = useSessionScope()
|
||||
|
||||
interface TreeItem {
|
||||
id: number
|
||||
label: string
|
||||
data: SkillNode
|
||||
children: TreeItem[]
|
||||
}
|
||||
|
||||
const treeData = computed(() => {
|
||||
const items = new Map<number, TreeItem>(props.nodes.map(n => [n.id, { id: n.id, label: n.name, data: n, children: [] }]))
|
||||
const roots: TreeItem[] = []
|
||||
// 后端已按 parent_id、sort_order 排好序
|
||||
for (const n of props.nodes) {
|
||||
const item = items.get(n.id)!
|
||||
if (n.parent_id === null)
|
||||
roots.push(item)
|
||||
else
|
||||
items.get(n.parent_id)?.children.push(item)
|
||||
}
|
||||
return roots
|
||||
})
|
||||
|
||||
// 新出现的文件夹默认展开,保留用户手动折叠的状态
|
||||
const expanded = ref<number[]>([])
|
||||
const seenFolders = new Set<number>()
|
||||
watch(() => props.nodes, (nodes) => {
|
||||
for (const n of nodes) {
|
||||
if (n.kind === "folder" && !seenFolders.has(n.id)) {
|
||||
seenFolders.add(n.id)
|
||||
expanded.value.push(n.id)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function allowDrop(_dragging: Node, drop: Node, type: AllowDropType) {
|
||||
return type !== "inner" || (drop.data as TreeItem).data.kind === "folder"
|
||||
}
|
||||
|
||||
async function onDrop(dragging: Node, drop: Node, type: NodeDropType) {
|
||||
const dragged = (dragging.data as TreeItem).data
|
||||
const target = (drop.data as TreeItem).data
|
||||
const parentId = type === "inner" ? target.id : target.parent_id
|
||||
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 moveSkillNodeApi(dragged.id, parentId, index)
|
||||
} finally {
|
||||
// 失败时也刷新,让树回到服务端的真实顺序
|
||||
emit("changed")
|
||||
}
|
||||
}
|
||||
|
||||
const NAME_RULE = (v: string) => (!!v?.trim() && !v.includes("/")) || "名称不能为空,也不能包含 /"
|
||||
|
||||
async function create(parentId: number | null, kind: NodeKind) {
|
||||
const { value } = await ElMessageBox.prompt(kind === "folder" ? "文件夹名称" : "skill 名称", kind === "folder" ? "新建文件夹" : "新建 skill", {
|
||||
inputValidator: NAME_RULE
|
||||
})
|
||||
sessionScope.assertCurrent()
|
||||
const node = await createSkillNodeApi({ parent_id: parentId, kind, name: value.trim() })
|
||||
emit("changed")
|
||||
if (kind === "skill")
|
||||
emit("select", node)
|
||||
}
|
||||
|
||||
async function rename(node: SkillNode) {
|
||||
const { value } = await ElMessageBox.prompt("新名称", "重命名", { inputValue: node.name, inputValidator: NAME_RULE })
|
||||
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" })
|
||||
sessionScope.assertCurrent()
|
||||
await deleteSkillNodeApi(node.id)
|
||||
ElMessage.success("已删除")
|
||||
emit("changed")
|
||||
}
|
||||
|
||||
function onExpand(item: TreeItem) {
|
||||
if (!expanded.value.includes(item.id))
|
||||
expanded.value.push(item.id)
|
||||
}
|
||||
|
||||
function onCollapse(item: TreeItem) {
|
||||
expanded.value = expanded.value.filter(id => id !== item.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<div v-if="editable" class="mb-2 flex gap-2">
|
||||
<el-button size="small" @click="create(null, 'folder')">
|
||||
新建文件夹
|
||||
</el-button>
|
||||
<el-button size="small" @click="create(null, 'skill')">
|
||||
新建 skill
|
||||
</el-button>
|
||||
</div>
|
||||
<el-scrollbar class="min-h-0 flex-1">
|
||||
<el-empty v-if="nodes.length === 0" description="还没有 skill" :image-size="60" />
|
||||
<el-tree
|
||||
v-else
|
||||
:data="treeData" node-key="id" :props="{ label: 'label', children: 'children' }"
|
||||
:default-expanded-keys="expanded" :current-node-key="selectedId ?? undefined" highlight-current
|
||||
:draggable="editable" :allow-drop="allowDrop" :expand-on-click-node="false"
|
||||
@node-drop="onDrop" @node-click="(item: TreeItem) => emit('select', item.data)"
|
||||
@node-expand="onExpand" @node-collapse="onCollapse"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div
|
||||
class="group w-full flex items-center gap-1 overflow-hidden rounded pr-1 text-sm"
|
||||
:class="{ 'opacity-50': !data.data.enabled, 'bg-amber-100': highlightIds?.includes(data.id) }"
|
||||
>
|
||||
<span>{{ data.data.kind === "folder" ? "📁" : "📄" }}</span>
|
||||
<span class="truncate">{{ data.data.name }}</span>
|
||||
<template v-if="data.data.kind === 'skill'">
|
||||
<el-tag size="small" effect="plain" class="shrink-0">
|
||||
P{{ data.data.priority }}
|
||||
</el-tag>
|
||||
<el-tag v-if="data.data.published_version_no === null" size="small" type="info" class="shrink-0">
|
||||
未发布
|
||||
</el-tag>
|
||||
<el-tag v-else-if="data.data.has_draft" size="small" type="warning" class="shrink-0">
|
||||
草稿
|
||||
</el-tag>
|
||||
</template>
|
||||
<div v-if="editable" class="ml-auto hidden shrink-0 gap-1 group-hover:flex" @click.stop>
|
||||
<template v-if="data.data.kind === 'folder'">
|
||||
<el-button link size="small" @click="create(data.id, 'skill')">
|
||||
+skill
|
||||
</el-button>
|
||||
<el-button link size="small" @click="create(data.id, 'folder')">
|
||||
+目录
|
||||
</el-button>
|
||||
</template>
|
||||
<el-button link size="small" @click="rename(data.data)">
|
||||
改名
|
||||
</el-button>
|
||||
<el-button link size="small" type="danger" @click="remove(data.data)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts" setup>
|
||||
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 sessionScope = useSessionScope()
|
||||
|
||||
const visible = defineModel<boolean>({ required: true })
|
||||
|
||||
const versions = ref<SkillVersion[]>([])
|
||||
const selectedId = ref<number | null>(null)
|
||||
const publishing = ref(false)
|
||||
|
||||
const published = computed(() => versions.value.find(v => v.version_no === props.skill?.published_version_no) ?? null)
|
||||
const selected = computed(() => versions.value.find(v => v.id === selectedId.value) ?? null)
|
||||
const diff = computed(() => selected.value ? diffLines((published.value?.content ?? "").split("\n"), selected.value.content.split("\n")) : [])
|
||||
|
||||
async function load() {
|
||||
if (!props.skill)
|
||||
return
|
||||
versions.value = await listSkillVersionsApi(props.skill.id)
|
||||
// 默认选中最新版本,直接看到草稿相对已发布版本的改动
|
||||
selectedId.value = versions.value[0]?.id ?? null
|
||||
}
|
||||
|
||||
watch([visible, () => props.skill?.id, () => props.skill?.latest_version_no], ([open]) => {
|
||||
if (open)
|
||||
load()
|
||||
})
|
||||
|
||||
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 publishSkillVersionApi(props.skill!.id, version.id)
|
||||
ElMessage.success(`已${action} v${version.version_no}`)
|
||||
emit("published")
|
||||
} finally {
|
||||
publishing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-drawer v-model="visible" :title="`版本历史:${skill?.path ?? ''}`" size="60%">
|
||||
<div class="h-full flex gap-3">
|
||||
<el-scrollbar class="w-60 shrink-0">
|
||||
<div
|
||||
v-for="v in versions" :key="v.id"
|
||||
class="mb-2 cursor-pointer border rounded-lg border-solid px-3 py-2 text-sm"
|
||||
:class="v.id === selectedId ? 'border-[var(--el-color-primary)] bg-[var(--el-color-primary-light-9)]' : 'border-slate-200 hover:border-slate-300'"
|
||||
@click="selectedId = v.id"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<b>v{{ v.version_no }}</b>
|
||||
<el-tag v-if="v.version_no === skill?.published_version_no" size="small" type="success">
|
||||
已发布
|
||||
</el-tag>
|
||||
<el-tag size="small" :type="v.source === 'chat' ? 'warning' : 'info'">
|
||||
{{ v.source === "chat" ? "Chat" : "我" }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ formatTime(v.created_at, "MM-DD HH:mm:ss") }}
|
||||
</div>
|
||||
<div v-if="v.note" class="truncate text-xs" :title="v.note">
|
||||
{{ v.note }}
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
<div v-if="selected" class="min-w-0 flex flex-1 flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm">v{{ selected.version_no }} 相对已发布版本{{ published ? ` v${published.version_no}` : "(暂无)" }}的改动:</span>
|
||||
<el-button
|
||||
v-if="selected.version_no !== skill?.published_version_no" type="primary" size="small" class="ml-auto"
|
||||
:loading="publishing" @click="publish(selected)"
|
||||
>
|
||||
发布此版本
|
||||
</el-button>
|
||||
</div>
|
||||
<el-scrollbar class="min-h-0 flex-1 border border-slate-200 rounded-lg border-solid">
|
||||
<div class="p-2 text-xs font-mono">
|
||||
<div
|
||||
v-for="(line, i) in diff" :key="i" class="whitespace-pre-wrap"
|
||||
:class="{ 'bg-emerald-50 text-emerald-900': line.type === 'add', 'bg-rose-50 text-rose-900 line-through': line.type === 'del' }"
|
||||
>
|
||||
{{ line.type === "add" ? "+ " : line.type === "del" ? "- " : " " }}{{ line.text }}
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
@@ -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
|
||||
@@ -0,0 +1,16 @@
|
||||
import MarkdownIt from "markdown-it"
|
||||
|
||||
// html: false —— 模型输出和 skill 内容里的 HTML 一律按文本显示,不执行
|
||||
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
|
||||
|
||||
// 链接一律新窗口打开
|
||||
const defaultLinkOpen = md.renderer.rules.link_open ?? ((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options))
|
||||
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||
tokens[idx].attrSet("target", "_blank")
|
||||
tokens[idx].attrSet("rel", "noopener noreferrer")
|
||||
return defaultLinkOpen(tokens, idx, options, env, self)
|
||||
}
|
||||
|
||||
export function renderMarkdown(source: string): string {
|
||||
return md.render(source)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Ref } from "vue"
|
||||
|
||||
/** 持久化到 localStorage 的 ref;值需可 JSON 序列化 */
|
||||
export function usePersisted<T>(key: string, initial: T): Ref<T> {
|
||||
const fullKey = `alphapulse.${key}`
|
||||
const raw = localStorage.getItem(fullKey)
|
||||
const state = ref(raw === null ? initial : JSON.parse(raw)) as Ref<T>
|
||||
watch(state, value => localStorage.setItem(fullKey, JSON.stringify(value)), { deep: true })
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import dayjs from "dayjs"
|
||||
|
||||
export function formatTime(value: string | null | undefined, pattern = "MM-DD HH:mm:ss") {
|
||||
return value ? dayjs(value).format(pattern) : "-"
|
||||
}
|
||||
|
||||
/** HTML 转纯文本行;DOMParser 解析的文档不会执行脚本 */
|
||||
export function htmlToLines(html: string): string[] {
|
||||
const marked = html.replace(/<br\s*\/?>|<\/(?:p|div|li|h\d)>/gi, "\n")
|
||||
const text = new DOMParser().parseFromString(marked, "text/html").body.textContent ?? ""
|
||||
return text.split("\n").map(line => line.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
export interface DiffLine {
|
||||
type: "same" | "add" | "del"
|
||||
text: string
|
||||
}
|
||||
|
||||
/** 基于最长公共子序列的逐行对比 */
|
||||
export function diffLines(before: string[], after: string[]): DiffLine[] {
|
||||
const n = before.length
|
||||
const m = after.length
|
||||
const lcs = Array.from({ length: n + 1 }, () => Array.from<number>({ length: m + 1 }).fill(0))
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--)
|
||||
lcs[i][j] = before[i] === after[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1])
|
||||
}
|
||||
const result: DiffLine[] = []
|
||||
let i = 0
|
||||
let j = 0
|
||||
while (i < n && j < m) {
|
||||
if (before[i] === after[j]) {
|
||||
result.push({ type: "same", text: before[i] })
|
||||
i++
|
||||
j++
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
result.push({ type: "del", text: before[i++] })
|
||||
} else {
|
||||
result.push({ type: "add", text: after[j++] })
|
||||
}
|
||||
}
|
||||
while (i < n) result.push({ type: "del", text: before[i++] })
|
||||
while (j < m) result.push({ type: "add", text: after[j++] })
|
||||
return result
|
||||
}
|
||||
Reference in new issue
Block a user