接入uuquant 的agent数据交互
This commit is contained in:
parent
c38011c544
commit
a628313e9a
@ -17,3 +17,9 @@ export const deepseekHttp = axios.create({
|
||||
baseURL: '/deepseek',
|
||||
timeout: 90000,
|
||||
})
|
||||
|
||||
/** 开发环境走 Vite 代理 /uuquant → uuquant-agent */
|
||||
export const uuquantHttp = axios.create({
|
||||
baseURL: '/uuquant',
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
17
src/api/uuquant/client.ts
Normal file
17
src/api/uuquant/client.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import {
|
||||
isUuquantSuccess,
|
||||
UuquantApiError,
|
||||
type UuquantResult,
|
||||
} from './types'
|
||||
|
||||
export async function unwrapUuquant<T>(promise: Promise<AxiosResponse<UuquantResult<T>>>): Promise<T> {
|
||||
const { data: body } = await promise
|
||||
if (!body || typeof body !== 'object') {
|
||||
throw new UuquantApiError(1, 'uuquant 响应格式异常')
|
||||
}
|
||||
if (!isUuquantSuccess(body.code)) {
|
||||
throw new UuquantApiError(body.code, body.msg || 'uuquant 请求失败')
|
||||
}
|
||||
return body.data
|
||||
}
|
||||
11
src/api/uuquant/keys.ts
Normal file
11
src/api/uuquant/keys.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/** 共享数据与动态表格的业务键名约定 */
|
||||
export const SHARED_SETTINGS_KEY = 'ai-trade-settings'
|
||||
export const SHARED_SELECTED_ID_KEY = 'ai-trade-selected-id'
|
||||
|
||||
/** 自选合约动态表格编码 */
|
||||
export const CONTRACTS_TABLE_CODE = 'ai_trade_contracts'
|
||||
|
||||
/** 按合约代码存最新 AI 解读 */
|
||||
export function adviceSharedKey(contractCode: string): string {
|
||||
return `ai-advice-${String(contractCode || '').trim().toUpperCase()}`
|
||||
}
|
||||
56
src/api/uuquant/models.ts
Normal file
56
src/api/uuquant/models.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import type { AiAdvice, AppSettings, ContractItem } from '../../types'
|
||||
import { CONTRACTS_TABLE_CODE } from './keys'
|
||||
import type { CreateUniversalTableDto, UniversalColumnDto } from './types'
|
||||
|
||||
export const CONTRACT_TABLE_COLUMNS: UniversalColumnDto[] = [
|
||||
{ title: '合约ID', name: 'contractId', type: 'string' },
|
||||
{ title: '代码', name: 'code', type: 'string' },
|
||||
{ title: '品种', name: 'variety', type: 'string' },
|
||||
{ title: '名称', name: 'name', type: 'string' },
|
||||
{ title: '交易所', name: 'exchange', type: 'string' },
|
||||
{ title: '大单手数', name: 'largeOrderLots', type: 'number' },
|
||||
{ title: '大单提醒手数', name: 'largeOrderAlertLots', type: 'number' },
|
||||
{ title: '关键字', name: 'keywords', type: 'string' },
|
||||
]
|
||||
|
||||
export const CONTRACTS_TABLE_DTO: CreateUniversalTableDto = {
|
||||
code: CONTRACTS_TABLE_CODE,
|
||||
tableName: 'AI交易助手自选合约',
|
||||
columns: CONTRACT_TABLE_COLUMNS,
|
||||
}
|
||||
|
||||
/** 业务行字段(不含表格系统 id) */
|
||||
export interface ContractRowData {
|
||||
contractId: string
|
||||
code: string
|
||||
variety: string
|
||||
name: string
|
||||
exchange: string
|
||||
largeOrderLots: number
|
||||
largeOrderAlertLots: number
|
||||
keywords: string
|
||||
}
|
||||
|
||||
export function contractToRowData(item: ContractItem): ContractRowData {
|
||||
return {
|
||||
contractId: item.id,
|
||||
code: item.code,
|
||||
variety: item.variety,
|
||||
name: item.name,
|
||||
exchange: item.exchange,
|
||||
largeOrderLots: item.largeOrderLots,
|
||||
largeOrderAlertLots: item.largeOrderAlertLots,
|
||||
keywords: item.keywords,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SelectedIdPayload {
|
||||
selectedId: string
|
||||
}
|
||||
|
||||
export type SettingsPayload = AppSettings
|
||||
|
||||
export type AdvicePayload = AiAdvice & {
|
||||
/** 对应合约代码,便于排查 */
|
||||
contractCode?: string
|
||||
}
|
||||
20
src/api/uuquant/sharedData.ts
Normal file
20
src/api/uuquant/sharedData.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { uuquantHttp } from '../http'
|
||||
import { unwrapUuquant } from './client'
|
||||
|
||||
/** 写入共享数据(任意 JSON 对象) */
|
||||
export async function putSharedData<T extends object>(key: string, value: T): Promise<void> {
|
||||
await unwrapUuquant(uuquantHttp.post(`/shared-data/${encodeURIComponent(key)}`, value))
|
||||
}
|
||||
|
||||
/** 读取共享数据;不存在时返回 null */
|
||||
export async function getSharedData<T>(key: string): Promise<T | null> {
|
||||
const data = await unwrapUuquant<T | null>(
|
||||
uuquantHttp.get(`/shared-data/${encodeURIComponent(key)}`),
|
||||
)
|
||||
return data ?? null
|
||||
}
|
||||
|
||||
/** 删除共享数据 */
|
||||
export async function deleteSharedData(key: string): Promise<boolean> {
|
||||
return unwrapUuquant<boolean>(uuquantHttp.delete(`/shared-data/${encodeURIComponent(key)}`))
|
||||
}
|
||||
65
src/api/uuquant/types.ts
Normal file
65
src/api/uuquant/types.ts
Normal file
@ -0,0 +1,65 @@
|
||||
/** uuquant-agent 统一 Result 结构(线上成功码为 200,文档示例为 0) */
|
||||
export interface UuquantResult<T = unknown> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export function isUuquantSuccess(code: number): boolean {
|
||||
return code === 0 || code === 200
|
||||
}
|
||||
|
||||
export class UuquantApiError extends Error {
|
||||
readonly code: number
|
||||
|
||||
constructor(code: number, msg: string) {
|
||||
super(msg || `uuquant 请求失败(code=${code})`)
|
||||
this.name = 'UuquantApiError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
/** 动态表格列定义 */
|
||||
export interface UniversalColumnDto {
|
||||
title: string
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface CreateUniversalTableDto {
|
||||
code: string
|
||||
tableName: string
|
||||
columns: UniversalColumnDto[]
|
||||
}
|
||||
|
||||
export interface UniversalTableMeta {
|
||||
id: number
|
||||
code: string
|
||||
tableName: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface UniversalColumnMeta extends UniversalColumnDto {
|
||||
id: number
|
||||
tableCode: string
|
||||
}
|
||||
|
||||
export interface UniversalSchemaData {
|
||||
table: UniversalTableMeta
|
||||
columns: UniversalColumnMeta[]
|
||||
}
|
||||
|
||||
/** query 返回的行:系统字段 + 业务列 */
|
||||
export type UniversalRow = {
|
||||
id: number
|
||||
tableCode: string
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
} & Record<string, unknown>
|
||||
|
||||
export interface UniversalQueryData {
|
||||
rows: UniversalRow[]
|
||||
total?: number
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
}
|
||||
79
src/api/uuquant/universalTable.ts
Normal file
79
src/api/uuquant/universalTable.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { uuquantHttp } from '../http'
|
||||
import { unwrapUuquant } from './client'
|
||||
import { isUuquantSuccess, type UuquantResult } from './types'
|
||||
import type {
|
||||
CreateUniversalTableDto,
|
||||
UniversalQueryData,
|
||||
UniversalRow,
|
||||
UniversalSchemaData,
|
||||
} from './types'
|
||||
|
||||
/** 查询表格结构;不存在时返回 null(不抛错) */
|
||||
export async function getTableSchema(tableCode: string): Promise<UniversalSchemaData | null> {
|
||||
const { data: body } = await uuquantHttp.get<UuquantResult<UniversalSchemaData>>(
|
||||
`/universal-table/schema/${encodeURIComponent(tableCode)}`,
|
||||
)
|
||||
if (!body || typeof body !== 'object') return null
|
||||
if (!isUuquantSuccess(body.code)) return null
|
||||
return body.data ?? null
|
||||
}
|
||||
|
||||
/** 创建表格(code 已存在会失败) */
|
||||
export async function createTable(dto: CreateUniversalTableDto): Promise<string> {
|
||||
return unwrapUuquant<string>(uuquantHttp.post('/universal-table/create', dto))
|
||||
}
|
||||
|
||||
/** 确保表格存在:无则按 dto 创建 */
|
||||
export async function ensureTable(dto: CreateUniversalTableDto): Promise<void> {
|
||||
const schema = await getTableSchema(dto.code)
|
||||
if (schema) return
|
||||
try {
|
||||
await createTable(dto)
|
||||
} catch (e) {
|
||||
// 并发创建时可能已存在,再确认一次
|
||||
const again = await getTableSchema(dto.code)
|
||||
if (!again) throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** 追加行 */
|
||||
export async function appendRows(
|
||||
tableCode: string,
|
||||
rows: object[],
|
||||
): Promise<unknown> {
|
||||
return unwrapUuquant(
|
||||
uuquantHttp.post(`/universal-table/append/${encodeURIComponent(tableCode)}`, rows),
|
||||
)
|
||||
}
|
||||
|
||||
/** 按行 id 整行覆盖更新 */
|
||||
export async function updateRow(
|
||||
tableCode: string,
|
||||
row: object & { id: number },
|
||||
): Promise<number> {
|
||||
return unwrapUuquant<number>(
|
||||
uuquantHttp.post(`/universal-table/update-row/${encodeURIComponent(tableCode)}`, row),
|
||||
)
|
||||
}
|
||||
|
||||
/** 删除单行 */
|
||||
export async function deleteRow(tableCode: string, rowId: number): Promise<boolean> {
|
||||
return unwrapUuquant<boolean>(
|
||||
uuquantHttp.delete(
|
||||
`/universal-table/delete-row/${encodeURIComponent(tableCode)}/${rowId}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** 查询全部行(不分页) */
|
||||
export async function queryAllRows(
|
||||
tableCode: string,
|
||||
order: 'asc' | 'desc' = 'asc',
|
||||
): Promise<UniversalRow[]> {
|
||||
const data = await unwrapUuquant<UniversalQueryData>(
|
||||
uuquantHttp.get(`/universal-table/query/${encodeURIComponent(tableCode)}`, {
|
||||
params: { order },
|
||||
}),
|
||||
)
|
||||
return Array.isArray(data?.rows) ? data.rows : []
|
||||
}
|
||||
12
src/bootstrap/hydrate.ts
Normal file
12
src/bootstrap/hydrate.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useContractStore } from '../stores/contract'
|
||||
|
||||
/**
|
||||
* 启动时从 uuquant 拉取 AI 设置与自选合约。
|
||||
* 失败不阻断应用:各 store 会回退到 localStorage 缓存。
|
||||
*/
|
||||
export async function hydrateRemoteStores(): Promise<void> {
|
||||
const settings = useSettingsStore()
|
||||
const contracts = useContractStore()
|
||||
await Promise.all([settings.hydrate(), contracts.hydrate()])
|
||||
}
|
||||
@ -190,7 +190,7 @@ async function onRemove(id: string) {
|
||||
return
|
||||
}
|
||||
const wasSelected = id === selectedId.value
|
||||
if (!contractStore.remove(id)) {
|
||||
if (!(await contractStore.remove(id))) {
|
||||
ElMessage.warning('至少保留一个合约')
|
||||
return
|
||||
}
|
||||
@ -200,7 +200,7 @@ async function onRemove(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function saveForm() {
|
||||
async function saveForm() {
|
||||
const payload = {
|
||||
code: form.code.trim(),
|
||||
name: form.name.trim(),
|
||||
@ -227,7 +227,7 @@ function saveForm() {
|
||||
!!prev &&
|
||||
(prev.code !== payload.code.toUpperCase() ||
|
||||
(prev.variety || '') !== (payload.variety.toUpperCase() || prev.variety))
|
||||
if (!contractStore.update(id, payload)) {
|
||||
if (!(await contractStore.update(id, payload))) {
|
||||
ElMessage.error('保存失败')
|
||||
return
|
||||
}
|
||||
@ -239,7 +239,7 @@ function saveForm() {
|
||||
return
|
||||
}
|
||||
|
||||
const item = contractStore.add(payload)
|
||||
const item = await contractStore.add(payload)
|
||||
if (!item) {
|
||||
ElMessage.error('保存失败')
|
||||
return
|
||||
|
||||
@ -3,6 +3,9 @@ import { ElMessage } from 'element-plus'
|
||||
import type { AiAdvice } from '../types'
|
||||
import { fetchDeepSeekAdvice } from '../api/deepseek/analyze'
|
||||
import type { AiAnalysisPayload } from '../api/deepseek/types'
|
||||
import { getSharedData, putSharedData } from '../api/uuquant/sharedData'
|
||||
import { adviceSharedKey } from '../api/uuquant/keys'
|
||||
import type { AdvicePayload } from '../api/uuquant/models'
|
||||
import { useQuotaStore, type KlinePeriod } from '../stores/quota'
|
||||
import { useContractStore } from '../stores/contract'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
@ -34,6 +37,24 @@ function emptyAdvice(): AiAdvice {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAdvice(raw: Partial<AiAdvice> | null | undefined): AiAdvice | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const direction = raw.direction
|
||||
if (direction !== 'long' && direction !== 'short' && direction !== 'neutral') return null
|
||||
const summary = String(raw.summary || '').trim()
|
||||
if (!summary) return null
|
||||
return {
|
||||
action: String(raw.action || '—'),
|
||||
direction,
|
||||
confidence: Number.isFinite(Number(raw.confidence)) ? Number(raw.confidence) : 0,
|
||||
summary,
|
||||
reasons: Array.isArray(raw.reasons)
|
||||
? raw.reasons.map((r) => String(r)).filter(Boolean)
|
||||
: [],
|
||||
updatedAt: String(raw.updatedAt || ''),
|
||||
}
|
||||
}
|
||||
|
||||
export function useAiAdvice() {
|
||||
const data = ref<AiAdvice>(emptyAdvice())
|
||||
const loading = ref(false)
|
||||
@ -56,13 +77,49 @@ export function useAiAdvice() {
|
||||
lastExecutedAt.value = null
|
||||
}
|
||||
|
||||
async function loadCachedAdvice(contractCode: string, epoch: number) {
|
||||
if (!contractCode) {
|
||||
clearAdvice()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const remote = await getSharedData<AdvicePayload>(adviceSharedKey(contractCode))
|
||||
if (epoch !== analysisEpoch) return
|
||||
const advice = normalizeAdvice(remote)
|
||||
if (advice) {
|
||||
data.value = advice
|
||||
const ts = Date.parse(advice.updatedAt)
|
||||
lastExecutedAt.value = Number.isFinite(ts) ? ts : null
|
||||
} else {
|
||||
clearAdvice()
|
||||
}
|
||||
} catch (e) {
|
||||
if (epoch !== analysisEpoch) return
|
||||
console.warn('[AI] 加载缓存解读失败', e)
|
||||
clearAdvice()
|
||||
}
|
||||
}
|
||||
|
||||
async function persistAdvice(contractCode: string, advice: AiAdvice) {
|
||||
if (!contractCode) return
|
||||
const payload: AdvicePayload = { ...advice, contractCode }
|
||||
try {
|
||||
await putSharedData(adviceSharedKey(contractCode), payload)
|
||||
} catch (e) {
|
||||
console.error('[AI] 保存解读到 uuquant 失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => contractStore.selectedId,
|
||||
() => {
|
||||
() => [contractStore.selectedId, contractStore.current?.code] as const,
|
||||
([, code]) => {
|
||||
analysisEpoch += 1
|
||||
loading.value = false
|
||||
const epoch = analysisEpoch
|
||||
clearAdvice()
|
||||
void loadCachedAdvice(String(code || ''), epoch)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** 尽量补全日/周/月 K 线,失败不阻断主流程 */
|
||||
@ -95,6 +152,7 @@ export function useAiAdvice() {
|
||||
if (loading.value) return
|
||||
|
||||
const epoch = analysisEpoch
|
||||
const contractCode = contractStore.current?.code || ''
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
@ -118,6 +176,7 @@ export function useAiAdvice() {
|
||||
|
||||
data.value = advice
|
||||
lastExecutedAt.value = Date.now()
|
||||
void persistAdvice(contractCode || payload.snapshot.contract.code, advice)
|
||||
if (!silent) ElMessage.success('AI 建议已更新')
|
||||
} catch (e) {
|
||||
if (epoch !== analysisEpoch) return
|
||||
|
||||
@ -6,8 +6,13 @@ import 'element-plus/dist/index.css'
|
||||
import './styles/variables.css'
|
||||
import './styles/global.css'
|
||||
import App from './App.vue'
|
||||
import { hydrateRemoteStores } from './bootstrap/hydrate'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.mount('#app')
|
||||
|
||||
void hydrateRemoteStores().finally(() => {
|
||||
app.mount('#app')
|
||||
})
|
||||
|
||||
@ -2,6 +2,22 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { ContractItem, ContractStorage } from '../types'
|
||||
import { extractVariety } from '../utils/tradingDate'
|
||||
import { getSharedData, putSharedData } from '../api/uuquant/sharedData'
|
||||
import { SHARED_SELECTED_ID_KEY, CONTRACTS_TABLE_CODE } from '../api/uuquant/keys'
|
||||
import {
|
||||
CONTRACTS_TABLE_DTO,
|
||||
contractToRowData,
|
||||
type SelectedIdPayload,
|
||||
} from '../api/uuquant/models'
|
||||
import {
|
||||
appendRows,
|
||||
deleteRow,
|
||||
ensureTable,
|
||||
queryAllRows,
|
||||
updateRow,
|
||||
} from '../api/uuquant/universalTable'
|
||||
import type { UniversalRow } from '../api/uuquant/types'
|
||||
import { debounce } from '../utils/debounce'
|
||||
|
||||
const STORAGE_KEY = 'ai-trade-contracts'
|
||||
|
||||
@ -45,7 +61,7 @@ function normalizeItem(raw: Partial<ContractItem> & { code?: string }): Contract
|
||||
}
|
||||
}
|
||||
|
||||
function load(): ContractStorage {
|
||||
function loadLocal(): ContractStorage {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') as Partial<ContractStorage>
|
||||
const list = Array.isArray(parsed.contracts)
|
||||
@ -66,45 +82,177 @@ function load(): ContractStorage {
|
||||
}
|
||||
}
|
||||
|
||||
function saveLocal(payload: ContractStorage) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
function rowToContract(row: UniversalRow): { item: ContractItem; rowId: number } | null {
|
||||
const item = normalizeItem({
|
||||
id: String(row.contractId || ''),
|
||||
code: String(row.code || ''),
|
||||
variety: String(row.variety || ''),
|
||||
name: String(row.name || ''),
|
||||
exchange: String(row.exchange || ''),
|
||||
largeOrderLots: Number(row.largeOrderLots),
|
||||
largeOrderAlertLots: Number(row.largeOrderAlertLots),
|
||||
keywords: String(row.keywords ?? ''),
|
||||
})
|
||||
if (!item) return null
|
||||
const rowId = Number(row.id)
|
||||
if (!Number.isFinite(rowId)) return null
|
||||
return { item, rowId }
|
||||
}
|
||||
|
||||
export const useContractStore = defineStore('contract', () => {
|
||||
const saved = load()
|
||||
const saved = loadLocal()
|
||||
const contracts = ref<ContractItem[]>(saved.contracts)
|
||||
const selectedId = ref(saved.selectedId)
|
||||
const hydrated = ref(false)
|
||||
const hydrateError = ref<string | null>(null)
|
||||
/** contractId → 动态表格行 id */
|
||||
const rowIdByContractId = ref<Record<string, number>>({})
|
||||
|
||||
let remotePersistEnabled = false
|
||||
|
||||
const current = computed(() => {
|
||||
const found = contracts.value.find((c) => c.id === selectedId.value)
|
||||
return found ?? contracts.value[0]
|
||||
})
|
||||
|
||||
function syncLocalCache() {
|
||||
if (!contracts.value.some((c) => c.id === selectedId.value) && contracts.value[0]) {
|
||||
selectedId.value = contracts.value[0].id
|
||||
}
|
||||
saveLocal({
|
||||
contracts: contracts.value,
|
||||
selectedId: selectedId.value,
|
||||
})
|
||||
}
|
||||
|
||||
const persistSelectedRemote = debounce(() => {
|
||||
if (!remotePersistEnabled) return
|
||||
const payload: SelectedIdPayload = { selectedId: selectedId.value }
|
||||
void putSharedData(SHARED_SELECTED_ID_KEY, payload).catch((e) => {
|
||||
console.error('[contract] 同步选中合约失败', e)
|
||||
})
|
||||
}, 300)
|
||||
|
||||
watch(
|
||||
[contracts, selectedId],
|
||||
() => {
|
||||
// 选中项失效时回落到第一项
|
||||
if (!contracts.value.some((c) => c.id === selectedId.value) && contracts.value[0]) {
|
||||
selectedId.value = contracts.value[0].id
|
||||
}
|
||||
const payload: ContractStorage = {
|
||||
contracts: contracts.value,
|
||||
selectedId: selectedId.value,
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
|
||||
syncLocalCache()
|
||||
persistSelectedRemote()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function applyFromRows(rows: UniversalRow[]) {
|
||||
const list: ContractItem[] = []
|
||||
const map: Record<string, number> = {}
|
||||
for (const row of rows) {
|
||||
const mapped = rowToContract(row)
|
||||
if (!mapped) continue
|
||||
list.push(mapped.item)
|
||||
map[mapped.item.id] = mapped.rowId
|
||||
}
|
||||
contracts.value = list
|
||||
rowIdByContractId.value = map
|
||||
}
|
||||
|
||||
async function resolveSelectedId(preferred?: string | null) {
|
||||
const list = contracts.value
|
||||
if (list.length === 0) return
|
||||
if (preferred && list.some((c) => c.id === preferred)) {
|
||||
selectedId.value = preferred
|
||||
return
|
||||
}
|
||||
if (!list.some((c) => c.id === selectedId.value)) {
|
||||
selectedId.value = list[0].id
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateLocalToRemote(local: ContractStorage) {
|
||||
if (local.contracts.length === 0) return
|
||||
await appendRows(
|
||||
CONTRACTS_TABLE_CODE,
|
||||
local.contracts.map((c) => contractToRowData(c)),
|
||||
)
|
||||
const rows = await queryAllRows(CONTRACTS_TABLE_CODE)
|
||||
applyFromRows(rows)
|
||||
await resolveSelectedId(local.selectedId)
|
||||
await putSharedData(SHARED_SELECTED_ID_KEY, { selectedId: selectedId.value } satisfies SelectedIdPayload)
|
||||
}
|
||||
|
||||
async function ensureDefaultRemote() {
|
||||
const first = normalizeItem({ ...DEFAULT_CONTRACT, id: createId() })!
|
||||
await appendRows(CONTRACTS_TABLE_CODE, [contractToRowData(first)])
|
||||
const rows = await queryAllRows(CONTRACTS_TABLE_CODE)
|
||||
applyFromRows(rows)
|
||||
selectedId.value = first.id
|
||||
await putSharedData(SHARED_SELECTED_ID_KEY, { selectedId: first.id } satisfies SelectedIdPayload)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 uuquant 动态表格拉取自选;空表时迁移本地缓存或写入默认合约。
|
||||
*/
|
||||
async function hydrate(): Promise<void> {
|
||||
hydrateError.value = null
|
||||
try {
|
||||
await ensureTable(CONTRACTS_TABLE_DTO)
|
||||
const rows = await queryAllRows(CONTRACTS_TABLE_CODE)
|
||||
const selectedRemote = await getSharedData<SelectedIdPayload>(SHARED_SELECTED_ID_KEY)
|
||||
|
||||
if (rows.length > 0) {
|
||||
applyFromRows(rows)
|
||||
await resolveSelectedId(selectedRemote?.selectedId)
|
||||
} else {
|
||||
const local = loadLocal()
|
||||
if (local.contracts.length > 0) {
|
||||
await migrateLocalToRemote(local)
|
||||
} else {
|
||||
await ensureDefaultRemote()
|
||||
}
|
||||
}
|
||||
|
||||
remotePersistEnabled = true
|
||||
hydrated.value = true
|
||||
syncLocalCache()
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '加载自选合约失败'
|
||||
hydrateError.value = msg
|
||||
console.error('[contract] hydrate 失败,使用本地缓存', e)
|
||||
remotePersistEnabled = true
|
||||
hydrated.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function select(id: string) {
|
||||
if (!contracts.value.some((c) => c.id === id)) return
|
||||
selectedId.value = id
|
||||
}
|
||||
|
||||
function add(input: Omit<ContractItem, 'id'>): ContractItem | null {
|
||||
async function add(input: Omit<ContractItem, 'id'>): Promise<ContractItem | null> {
|
||||
const item = normalizeItem({ ...input, id: createId() })
|
||||
if (!item) return null
|
||||
contracts.value = [...contracts.value, item]
|
||||
|
||||
if (remotePersistEnabled) {
|
||||
try {
|
||||
await appendRows(CONTRACTS_TABLE_CODE, [contractToRowData(item)])
|
||||
const rows = await queryAllRows(CONTRACTS_TABLE_CODE)
|
||||
const mapped = rows.map(rowToContract).filter(Boolean) as { item: ContractItem; rowId: number }[]
|
||||
const found = mapped.find((m) => m.item.id === item.id)
|
||||
if (found) {
|
||||
rowIdByContractId.value = { ...rowIdByContractId.value, [item.id]: found.rowId }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[contract] 追加自选失败', e)
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
function update(id: string, input: Partial<Omit<ContractItem, 'id'>>): boolean {
|
||||
async function update(id: string, input: Partial<Omit<ContractItem, 'id'>>): Promise<boolean> {
|
||||
const idx = contracts.value.findIndex((c) => c.id === id)
|
||||
if (idx < 0) return false
|
||||
const merged = normalizeItem({ ...contracts.value[idx], ...input, id })
|
||||
@ -112,17 +260,39 @@ export const useContractStore = defineStore('contract', () => {
|
||||
const next = contracts.value.slice()
|
||||
next[idx] = merged
|
||||
contracts.value = next
|
||||
|
||||
const rowId = rowIdByContractId.value[id]
|
||||
if (remotePersistEnabled && rowId != null) {
|
||||
try {
|
||||
await updateRow(CONTRACTS_TABLE_CODE, { id: rowId, ...contractToRowData(merged) })
|
||||
} catch (e) {
|
||||
console.error('[contract] 更新自选失败', e)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function remove(id: string): boolean {
|
||||
async function remove(id: string): Promise<boolean> {
|
||||
if (contracts.value.length <= 1) return false
|
||||
const next = contracts.value.filter((c) => c.id !== id)
|
||||
if (next.length === contracts.value.length) return false
|
||||
|
||||
const rowId = rowIdByContractId.value[id]
|
||||
contracts.value = next
|
||||
if (selectedId.value === id) {
|
||||
selectedId.value = next[0].id
|
||||
}
|
||||
const rest = { ...rowIdByContractId.value }
|
||||
delete rest[id]
|
||||
rowIdByContractId.value = rest
|
||||
|
||||
if (remotePersistEnabled && rowId != null) {
|
||||
try {
|
||||
await deleteRow(CONTRACTS_TABLE_CODE, rowId)
|
||||
} catch (e) {
|
||||
console.error('[contract] 删除自选失败', e)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@ -130,6 +300,9 @@ export const useContractStore = defineStore('contract', () => {
|
||||
contracts,
|
||||
selectedId,
|
||||
current,
|
||||
hydrated,
|
||||
hydrateError,
|
||||
hydrate,
|
||||
select,
|
||||
add,
|
||||
update,
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import type { AppSettings } from '../types'
|
||||
import { getSharedData, putSharedData } from '../api/uuquant/sharedData'
|
||||
import { SHARED_SETTINGS_KEY } from '../api/uuquant/keys'
|
||||
import { debounce } from '../utils/debounce'
|
||||
|
||||
const STORAGE_KEY = 'ai-trade-settings'
|
||||
|
||||
@ -11,7 +14,7 @@ export const DEFAULT_KEYWORDS =
|
||||
/** 定时分析默认间隔(分钟) */
|
||||
export const DEFAULT_SCHEDULE_MINUTES = 5
|
||||
|
||||
function load(): Partial<AppSettings> {
|
||||
function loadLocal(): Partial<AppSettings> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') as Partial<AppSettings>
|
||||
} catch {
|
||||
@ -19,37 +22,104 @@ function load(): Partial<AppSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
function saveLocal(payload: AppSettings) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
function clampMinutes(n: unknown): number {
|
||||
const v = typeof n === 'number' ? n : Number(n)
|
||||
if (!Number.isFinite(v) || v < 1) return DEFAULT_SCHEDULE_MINUTES
|
||||
return Math.min(1440, Math.floor(v))
|
||||
}
|
||||
|
||||
function normalizeSettings(raw: Partial<AppSettings> | null | undefined): AppSettings {
|
||||
return {
|
||||
apiKey: String(raw?.apiKey || ''),
|
||||
keywords: String(raw?.keywords || DEFAULT_KEYWORDS),
|
||||
scheduledAnalysisEnabled: Boolean(raw?.scheduledAnalysisEnabled),
|
||||
scheduledAnalysisMinutes: clampMinutes(raw?.scheduledAnalysisMinutes),
|
||||
}
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const saved = load()
|
||||
const apiKey = ref(saved.apiKey || '')
|
||||
const keywords = ref(saved.keywords || DEFAULT_KEYWORDS)
|
||||
const scheduledAnalysisEnabled = ref(Boolean(saved.scheduledAnalysisEnabled))
|
||||
const scheduledAnalysisMinutes = ref(clampMinutes(saved.scheduledAnalysisMinutes))
|
||||
const saved = normalizeSettings(loadLocal())
|
||||
const apiKey = ref(saved.apiKey)
|
||||
const keywords = ref(saved.keywords)
|
||||
const scheduledAnalysisEnabled = ref(saved.scheduledAnalysisEnabled)
|
||||
const scheduledAnalysisMinutes = ref(saved.scheduledAnalysisMinutes)
|
||||
const hydrated = ref(false)
|
||||
const hydrateError = ref<string | null>(null)
|
||||
|
||||
/** hydrate 完成前不写远端,避免用默认值覆盖云端 */
|
||||
let remotePersistEnabled = false
|
||||
|
||||
function currentPayload(): AppSettings {
|
||||
return {
|
||||
apiKey: apiKey.value,
|
||||
keywords: keywords.value,
|
||||
scheduledAnalysisEnabled: scheduledAnalysisEnabled.value,
|
||||
scheduledAnalysisMinutes: clampMinutes(scheduledAnalysisMinutes.value),
|
||||
}
|
||||
}
|
||||
|
||||
function applySettings(payload: AppSettings) {
|
||||
apiKey.value = payload.apiKey
|
||||
keywords.value = payload.keywords
|
||||
scheduledAnalysisEnabled.value = payload.scheduledAnalysisEnabled
|
||||
scheduledAnalysisMinutes.value = payload.scheduledAnalysisMinutes
|
||||
}
|
||||
|
||||
const persistRemote = debounce(() => {
|
||||
if (!remotePersistEnabled) return
|
||||
const payload = currentPayload()
|
||||
void putSharedData(SHARED_SETTINGS_KEY, payload).catch((e) => {
|
||||
console.error('[settings] 同步到 uuquant 失败', e)
|
||||
})
|
||||
}, 400)
|
||||
|
||||
watch(
|
||||
[apiKey, keywords, scheduledAnalysisEnabled, scheduledAnalysisMinutes],
|
||||
() => {
|
||||
const payload: AppSettings = {
|
||||
apiKey: apiKey.value,
|
||||
keywords: keywords.value,
|
||||
scheduledAnalysisEnabled: scheduledAnalysisEnabled.value,
|
||||
scheduledAnalysisMinutes: clampMinutes(scheduledAnalysisMinutes.value),
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
|
||||
const payload = currentPayload()
|
||||
saveLocal(payload)
|
||||
persistRemote()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* 从 uuquant 共享数据拉取 AI 设置;远端为空则用本地缓存迁移上去。
|
||||
*/
|
||||
async function hydrate(): Promise<void> {
|
||||
hydrateError.value = null
|
||||
try {
|
||||
const remote = await getSharedData<Partial<AppSettings>>(SHARED_SETTINGS_KEY)
|
||||
if (remote && typeof remote === 'object') {
|
||||
applySettings(normalizeSettings(remote))
|
||||
} else {
|
||||
const local = normalizeSettings(loadLocal())
|
||||
applySettings(local)
|
||||
await putSharedData(SHARED_SETTINGS_KEY, local)
|
||||
}
|
||||
remotePersistEnabled = true
|
||||
hydrated.value = true
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '加载 AI 设置失败'
|
||||
hydrateError.value = msg
|
||||
console.error('[settings] hydrate 失败,使用本地缓存', e)
|
||||
// 远端不可用时仍允许后续写入尝试
|
||||
remotePersistEnabled = true
|
||||
hydrated.value = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
keywords,
|
||||
scheduledAnalysisEnabled,
|
||||
scheduledAnalysisMinutes,
|
||||
hydrated,
|
||||
hydrateError,
|
||||
hydrate,
|
||||
}
|
||||
})
|
||||
|
||||
@ -145,7 +145,7 @@ export interface AppSettings {
|
||||
scheduledAnalysisMinutes: number
|
||||
}
|
||||
|
||||
/** 单个期货合约配置(可多份,存 localStorage) */
|
||||
/** 单个期货合约配置(可多份,主存 uuquant 动态表格,本地缓存) */
|
||||
export interface ContractItem {
|
||||
id: string
|
||||
/** 合约代码,对应百度行情 / 同花顺持仓接口 code/contract */
|
||||
@ -172,7 +172,7 @@ export interface ContractItem {
|
||||
keywords: string
|
||||
}
|
||||
|
||||
/** localStorage 中的合约列表与当前选中 */
|
||||
/** 合约列表与当前选中(本地缓存结构) */
|
||||
export interface ContractStorage {
|
||||
contracts: ContractItem[]
|
||||
selectedId: string
|
||||
|
||||
18
src/utils/debounce.ts
Normal file
18
src/utils/debounce.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/** 简易防抖:返回可取消的调度函数 */
|
||||
export function debounce<T extends (...args: never[]) => void>(fn: T, waitMs: number): T & { cancel: () => void } {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const wrapped = ((...args: Parameters<T>) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
fn(...args)
|
||||
}, waitMs)
|
||||
}) as T & { cancel: () => void }
|
||||
wrapped.cancel = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
@ -51,6 +51,11 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/deepseek/, ''),
|
||||
},
|
||||
'/uuquant': {
|
||||
target: 'http://162.14.77.110:18880',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/uuquant/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user