From a628313e9aa23aaf0eb4b81216f88ad4cf6ae4aa Mon Sep 17 00:00:00 2001 From: dongzp <975303544@qq.com> Date: Fri, 24 Jul 2026 16:22:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5uuquant=20=E7=9A=84agent?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/http.ts | 6 + src/api/uuquant/client.ts | 17 ++ src/api/uuquant/keys.ts | 11 + src/api/uuquant/models.ts | 56 +++++ src/api/uuquant/sharedData.ts | 20 ++ src/api/uuquant/types.ts | 65 ++++++ src/api/uuquant/universalTable.ts | 79 +++++++ src/bootstrap/hydrate.ts | 12 ++ .../header/ContractSettingsDialog.vue | 8 +- src/composables/useAiAdvice.ts | 63 +++++- src/main.ts | 9 +- src/stores/contract.ts | 201 ++++++++++++++++-- src/stores/settings.ts | 96 +++++++-- src/types/index.ts | 4 +- src/utils/debounce.ts | 18 ++ vite.config.ts | 5 + 16 files changed, 633 insertions(+), 37 deletions(-) create mode 100644 src/api/uuquant/client.ts create mode 100644 src/api/uuquant/keys.ts create mode 100644 src/api/uuquant/models.ts create mode 100644 src/api/uuquant/sharedData.ts create mode 100644 src/api/uuquant/types.ts create mode 100644 src/api/uuquant/universalTable.ts create mode 100644 src/bootstrap/hydrate.ts create mode 100644 src/utils/debounce.ts diff --git a/src/api/http.ts b/src/api/http.ts index 07d4d7d..bbc829d 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -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, +}) diff --git a/src/api/uuquant/client.ts b/src/api/uuquant/client.ts new file mode 100644 index 0000000..fc78d6a --- /dev/null +++ b/src/api/uuquant/client.ts @@ -0,0 +1,17 @@ +import type { AxiosResponse } from 'axios' +import { + isUuquantSuccess, + UuquantApiError, + type UuquantResult, +} from './types' + +export async function unwrapUuquant(promise: Promise>>): Promise { + 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 +} diff --git a/src/api/uuquant/keys.ts b/src/api/uuquant/keys.ts new file mode 100644 index 0000000..45cc4cf --- /dev/null +++ b/src/api/uuquant/keys.ts @@ -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()}` +} diff --git a/src/api/uuquant/models.ts b/src/api/uuquant/models.ts new file mode 100644 index 0000000..f392151 --- /dev/null +++ b/src/api/uuquant/models.ts @@ -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 +} diff --git a/src/api/uuquant/sharedData.ts b/src/api/uuquant/sharedData.ts new file mode 100644 index 0000000..3db7a82 --- /dev/null +++ b/src/api/uuquant/sharedData.ts @@ -0,0 +1,20 @@ +import { uuquantHttp } from '../http' +import { unwrapUuquant } from './client' + +/** 写入共享数据(任意 JSON 对象) */ +export async function putSharedData(key: string, value: T): Promise { + await unwrapUuquant(uuquantHttp.post(`/shared-data/${encodeURIComponent(key)}`, value)) +} + +/** 读取共享数据;不存在时返回 null */ +export async function getSharedData(key: string): Promise { + const data = await unwrapUuquant( + uuquantHttp.get(`/shared-data/${encodeURIComponent(key)}`), + ) + return data ?? null +} + +/** 删除共享数据 */ +export async function deleteSharedData(key: string): Promise { + return unwrapUuquant(uuquantHttp.delete(`/shared-data/${encodeURIComponent(key)}`)) +} diff --git a/src/api/uuquant/types.ts b/src/api/uuquant/types.ts new file mode 100644 index 0000000..e15f431 --- /dev/null +++ b/src/api/uuquant/types.ts @@ -0,0 +1,65 @@ +/** uuquant-agent 统一 Result 结构(线上成功码为 200,文档示例为 0) */ +export interface UuquantResult { + 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 + +export interface UniversalQueryData { + rows: UniversalRow[] + total?: number + pageNum?: number + pageSize?: number +} diff --git a/src/api/uuquant/universalTable.ts b/src/api/uuquant/universalTable.ts new file mode 100644 index 0000000..32b1210 --- /dev/null +++ b/src/api/uuquant/universalTable.ts @@ -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 { + const { data: body } = await uuquantHttp.get>( + `/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 { + return unwrapUuquant(uuquantHttp.post('/universal-table/create', dto)) +} + +/** 确保表格存在:无则按 dto 创建 */ +export async function ensureTable(dto: CreateUniversalTableDto): Promise { + 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 { + return unwrapUuquant( + uuquantHttp.post(`/universal-table/append/${encodeURIComponent(tableCode)}`, rows), + ) +} + +/** 按行 id 整行覆盖更新 */ +export async function updateRow( + tableCode: string, + row: object & { id: number }, +): Promise { + return unwrapUuquant( + uuquantHttp.post(`/universal-table/update-row/${encodeURIComponent(tableCode)}`, row), + ) +} + +/** 删除单行 */ +export async function deleteRow(tableCode: string, rowId: number): Promise { + return unwrapUuquant( + uuquantHttp.delete( + `/universal-table/delete-row/${encodeURIComponent(tableCode)}/${rowId}`, + ), + ) +} + +/** 查询全部行(不分页) */ +export async function queryAllRows( + tableCode: string, + order: 'asc' | 'desc' = 'asc', +): Promise { + const data = await unwrapUuquant( + uuquantHttp.get(`/universal-table/query/${encodeURIComponent(tableCode)}`, { + params: { order }, + }), + ) + return Array.isArray(data?.rows) ? data.rows : [] +} diff --git a/src/bootstrap/hydrate.ts b/src/bootstrap/hydrate.ts new file mode 100644 index 0000000..3f49cef --- /dev/null +++ b/src/bootstrap/hydrate.ts @@ -0,0 +1,12 @@ +import { useSettingsStore } from '../stores/settings' +import { useContractStore } from '../stores/contract' + +/** + * 启动时从 uuquant 拉取 AI 设置与自选合约。 + * 失败不阻断应用:各 store 会回退到 localStorage 缓存。 + */ +export async function hydrateRemoteStores(): Promise { + const settings = useSettingsStore() + const contracts = useContractStore() + await Promise.all([settings.hydrate(), contracts.hydrate()]) +} diff --git a/src/components/header/ContractSettingsDialog.vue b/src/components/header/ContractSettingsDialog.vue index 6823b6a..7d102a4 100644 --- a/src/components/header/ContractSettingsDialog.vue +++ b/src/components/header/ContractSettingsDialog.vue @@ -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 diff --git a/src/composables/useAiAdvice.ts b/src/composables/useAiAdvice.ts index aa6f343..f3a78d5 100644 --- a/src/composables/useAiAdvice.ts +++ b/src/composables/useAiAdvice.ts @@ -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 | 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(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(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 diff --git a/src/main.ts b/src/main.ts index 055a36c..d1340e7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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') +}) diff --git a/src/stores/contract.ts b/src/stores/contract.ts index d2938fe..8573564 100644 --- a/src/stores/contract.ts +++ b/src/stores/contract.ts @@ -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 & { code?: string }): Contract } } -function load(): ContractStorage { +function loadLocal(): ContractStorage { try { const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') as Partial 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(saved.contracts) const selectedId = ref(saved.selectedId) + const hydrated = ref(false) + const hydrateError = ref(null) + /** contractId → 动态表格行 id */ + const rowIdByContractId = ref>({}) + + 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 = {} + 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 { + hydrateError.value = null + try { + await ensureTable(CONTRACTS_TABLE_DTO) + const rows = await queryAllRows(CONTRACTS_TABLE_CODE) + const selectedRemote = await getSharedData(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 | null { + async function add(input: Omit): Promise { 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>): boolean { + async function update(id: string, input: Partial>): Promise { 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 { 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, diff --git a/src/stores/settings.ts b/src/stores/settings.ts index ab9fd3d..3ebdcb7 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -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 { +function loadLocal(): Partial { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') as Partial } catch { @@ -19,37 +22,104 @@ function load(): Partial { } } +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 | 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(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 { + hydrateError.value = null + try { + const remote = await getSharedData>(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, } }) diff --git a/src/types/index.ts b/src/types/index.ts index 0da4559..ee24f1d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts new file mode 100644 index 0000000..d1f1ca0 --- /dev/null +++ b/src/utils/debounce.ts @@ -0,0 +1,18 @@ +/** 简易防抖:返回可取消的调度函数 */ +export function debounce void>(fn: T, waitMs: number): T & { cancel: () => void } { + let timer: ReturnType | null = null + const wrapped = ((...args: Parameters) => { + 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 +} diff --git a/vite.config.ts b/vite.config.ts index 86d5a25..61d352e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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/, ''), + }, }, }, })