接入uuquant 的agent数据交互

This commit is contained in:
dongzp 2026-07-24 16:22:50 +08:00
parent c38011c544
commit a628313e9a
16 changed files with 633 additions and 37 deletions

View File

@ -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
View 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
View 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
View 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
}

View 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
View 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
}

View 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
View 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()])
}

View File

@ -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

View File

@ -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

View File

@ -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')
})

View File

@ -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,

View File

@ -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,
}
})

View File

@ -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
View 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
}

View File

@ -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/, ''),
},
},
},
})